Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
351ad59dd8 | ||
|
|
dcc901b0d2 | ||
|
|
99bf093f00 | ||
|
|
619ccd28cb | ||
|
|
f22f0a4729 | ||
|
|
78194daf5f | ||
|
|
f141767fc2 | ||
|
|
05cd8e154b | ||
|
|
004cc03ba6 | ||
|
|
236e89989f | ||
|
|
b0a2de8ca1 | ||
|
|
e9ac7be8c3 | ||
|
|
47690c58d9 | ||
|
|
5d72088837 | ||
|
|
7a60295bc1 | ||
|
|
b48467fb6e | ||
|
|
b5e828c9e8 | ||
|
|
98284f4387 | ||
|
|
e35e8fc839 | ||
|
|
2cd9bc1c89 | ||
|
|
39980581b1 | ||
|
|
6f478eb817 | ||
|
|
ff3a94b888 | ||
|
|
631894084a | ||
|
|
296e0179cb | ||
|
|
600126a913 | ||
|
|
4872a26786 | ||
|
|
f0c86a3bdf | ||
|
|
1b286762f6 | ||
|
|
3c77c20de8 | ||
|
|
9e53f21746 | ||
|
|
ad35b32f5b | ||
|
|
d20d3b08fa | ||
|
|
c3c58581cc | ||
|
|
c558b81471 | ||
|
|
963fa9c877 | ||
|
|
a02747d02e | ||
|
|
db5b5e173f | ||
|
|
2657780e7a | ||
|
|
129be23a9d | ||
|
|
d31486ae1b | ||
|
|
4f3f3601d2 | ||
|
|
b022722d39 | ||
|
|
bf96dba50e | ||
|
|
2b4611f7ac | ||
|
|
fdbd591c73 | ||
|
|
1989d6cd98 | ||
|
|
763eafa4f8 | ||
|
|
3dff45350b | ||
|
|
40dee57688 | ||
|
|
b7561ed4e5 | ||
|
|
15fdf591e0 | ||
|
|
e09a61c0af | ||
|
|
55bae898f3 | ||
|
|
a9d602d021 | ||
|
|
2fe08ad7e9 | ||
|
|
1ad5b5d6db | ||
|
|
50907448d2 | ||
|
|
0962745bbc | ||
|
|
38c51e5a3e | ||
|
|
c26f120e42 | ||
|
|
d206bb0541 | ||
|
|
42d1ec99a8 | ||
|
|
91d33918bb | ||
|
|
efa5b36389 | ||
|
|
332c7760ca | ||
|
|
138f708a87 | ||
|
|
9ec3cbf901 | ||
|
|
86ce1b3ff7 | ||
|
|
307946d5aa | ||
|
|
1967e966ce | ||
|
|
19b76044ff | ||
|
|
257e4fa89d | ||
|
|
f06009b152 | ||
|
|
aeee7aeccf | ||
|
|
d69ab709b2 | ||
|
|
d7c90dea07 | ||
|
|
4edb3bf441 | ||
|
|
73a06227ba | ||
|
|
7c30d26878 | ||
|
|
19596ff2a3 | ||
|
|
c3c16083f7 | ||
|
|
e37a09ef0d | ||
|
|
02e84ed548 | ||
|
|
7e66b23ef8 | ||
|
|
5c91db0d4c | ||
|
|
fab87c82c6 | ||
|
|
9494199306 | ||
|
|
aff6736b18 | ||
|
|
e6ef9bc536 | ||
|
|
407a610cfb | ||
|
|
4ea7f369f1 | ||
|
|
2166a483ca | ||
|
|
f9c3fc5379 | ||
|
|
f62b0054db | ||
|
|
de83b54be6 | ||
|
|
aaf154168e | ||
|
|
e215ccc979 | ||
|
|
7f5f082dad | ||
|
|
abbde30b47 | ||
|
|
91b355cd3e | ||
|
|
6fa50eb2d1 | ||
|
|
679aa91bd0 | ||
|
|
a0813b6e84 |
@@ -32,20 +32,86 @@ jobs:
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/keymanager-agent-linux-amd64 ./cmd
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
GOOS=linux GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/keymanager-agent-linux-arm64 ./cmd
|
||||
-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 keymanager-agent-linux-amd64 keymanager-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
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
agent/dist/keymanager-agent-linux-amd64
|
||||
agent/dist/keymanager-agent-linux-arm64
|
||||
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: Extract version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
|
||||
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
# MSI ProductVersion must be numeric x.x.x.x
|
||||
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Build agent exe
|
||||
working-directory: agent
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
$env:GOOS = "windows"; $env:GOARCH = "amd64"
|
||||
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o ../installer/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- 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 -d Version=${{ steps.version.outputs.MSIVERSION }} -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"
|
||||
}
|
||||
|
||||
@@ -1,50 +1,37 @@
|
||||
name: Server Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "server/**"
|
||||
- "web/**"
|
||||
- "proto/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
deploy:
|
||||
runs-on: ubuntu-docker
|
||||
container: docker:dind
|
||||
steps:
|
||||
- name: Setup Node
|
||||
run: apk add --update nodejs npm
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login ${{ vars.GITEA_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push server image
|
||||
run: |
|
||||
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
|
||||
docker build -t "$IMAGE" -f server/Dockerfile server/
|
||||
docker push "$IMAGE"
|
||||
- name: Build and push server image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
|
||||
docker build -t "$IMAGE" -f server/Dockerfile server/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
run: |
|
||||
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="https://${{ vars.GITEA_HOST }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: https://github.com/appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/keymanager
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
- name: Build and push web image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
.env
|
||||
docs
|
||||
.superpowers
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
+10
-4
@@ -1,11 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mrhid6/keymanager/agent/internal/config"
|
||||
agentsync "github.com/mrhid6/keymanager/agent/internal/sync"
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
@@ -26,8 +29,11 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
||||
if err := agentsync.Run(cfg); err != nil {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("vantage-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
||||
if err := agentsync.Run(ctx, cfg, Version); err != nil {
|
||||
log.Fatalf("agent error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
module github.com/mrhid6/keymanager/agent
|
||||
module github.com/mrhid6/vantage/agent
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -2,12 +2,26 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigPath = "/etc/keymanager/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/keymanager", 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
|
||||
// Stdout and Stderr so output interleaves in real execution order. The mutex
|
||||
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
emit func(seq uint64, data []byte)
|
||||
}
|
||||
|
||||
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.emit != nil {
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
w.emit(w.seq, buf)
|
||||
w.seq++
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
|
||||
// the script to append KEY=value output to, executes it under the requested
|
||||
// interpreter, and streams output via emit, returning the terminal result
|
||||
// with empty stdout/stderr but populated exit_code/output_env.
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
|
||||
|
||||
dir, err := os.MkdirTemp("", "vantage-step-")
|
||||
if err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "create temp dir: " + err.Error()
|
||||
return res
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
envFile := filepath.Join(dir, "workflow_env")
|
||||
if err := os.WriteFile(envFile, nil, 0600); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "create env file: " + err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
var scriptPath string
|
||||
var c *exec.Cmd
|
||||
timeout := time.Duration(cmd.TimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
switch cmd.Interpreter {
|
||||
case "powershell":
|
||||
scriptPath = filepath.Join(dir, "step.ps1")
|
||||
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0600); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = err.Error()
|
||||
return res
|
||||
}
|
||||
shell := "pwsh"
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := exec.LookPath("pwsh"); err != nil {
|
||||
shell = "powershell.exe"
|
||||
}
|
||||
}
|
||||
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
|
||||
default: // "bash"
|
||||
scriptPath = filepath.Join(dir, "step.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = err.Error()
|
||||
return res
|
||||
}
|
||||
c = exec.CommandContext(ctx, "bash", scriptPath)
|
||||
}
|
||||
|
||||
c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile)
|
||||
for k, v := range cmd.Env {
|
||||
c.Env = append(c.Env, k+"="+v)
|
||||
}
|
||||
|
||||
sw := &streamWriter{emit: emit}
|
||||
c.Stdout = sw
|
||||
c.Stderr = sw
|
||||
runErr := c.Run()
|
||||
|
||||
// stdout/stderr are streamed via emit, not returned in the result.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = 124
|
||||
res.Stderr = "[vantage] step timed out"
|
||||
} else if ee, ok := runErr.(*exec.ExitError); ok {
|
||||
res.ExitCode = ee.ExitCode()
|
||||
} else if runErr != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "[vantage] " + runErr.Error()
|
||||
}
|
||||
|
||||
res.OutputEnv = parseEnvFile(envFile)
|
||||
return res
|
||||
}
|
||||
|
||||
// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines
|
||||
// without '=' are ignored.
|
||||
func parseEnvFile(path string) map[string]string {
|
||||
out := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
i := strings.IndexByte(line, '=')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
out[line[:i]] = line[i+1:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,13 +3,15 @@ package grpcclient
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/encoding"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -18,11 +20,22 @@ func init() {
|
||||
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
client pb.KeyManagerClient
|
||||
client pb.VantageClient
|
||||
}
|
||||
|
||||
func New(serverURL string, useTLS bool) (*Client, error) {
|
||||
var dialOpts []grpc.DialOption
|
||||
serverURL = strings.TrimPrefix(serverURL, "https://")
|
||||
serverURL = strings.TrimPrefix(serverURL, "http://")
|
||||
|
||||
// Send a ping every 30s so proxies with a 60s idle timeout don't kill the
|
||||
// long-lived CommandStream when no commands are flowing.
|
||||
dialOpts := []grpc.DialOption{
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
}
|
||||
|
||||
if useTLS {
|
||||
tlsCfg := &tls.Config{
|
||||
@@ -44,7 +57,7 @@ func New(serverURL string, useTLS bool) (*Client, error) {
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
client: pb.NewKeyManagerClient(conn),
|
||||
client: pb.NewVantageClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -69,13 +82,14 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
AgentVersion: version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -83,7 +97,7 @@ func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) {
|
||||
return resp.PublicKeys, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label string) (string, error) {
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -91,6 +105,7 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
PublicKey: publicKey,
|
||||
PrivateKey: privateKey,
|
||||
Label: label,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -98,3 +113,21 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
|
||||
}
|
||||
return resp.KeyId, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.PackageUpdate) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.client.ReportUpdates(ctx, &pb.ReportUpdatesRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
Updates: updates,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// Hand-written gRPC bindings for keymanager.proto (agent side, JSON codec).
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
type KeyManagerClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedKeyManagerServer struct{}
|
||||
|
||||
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
OutputEnv map[string]string `json:"output_env,omitempty"`
|
||||
}
|
||||
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CommandStream server-side interface (included for completeness)
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
+115
-7
@@ -11,6 +11,9 @@ import (
|
||||
)
|
||||
|
||||
const authorizedKeysPath = "/root/.ssh/authorized_keys"
|
||||
const sshConfigPath = "/root/.ssh/config"
|
||||
const managedConfigPath = "/root/.ssh/vantage.conf"
|
||||
const includeDirective = "Include /root/.ssh/vantage.conf"
|
||||
|
||||
func ReadAuthorizedKeys() ([]string, error) {
|
||||
data, err := os.ReadFile(authorizedKeysPath)
|
||||
@@ -93,19 +96,36 @@ func fingerprint(pubKey string) string {
|
||||
return "MD5:" + strings.Join(pairs, ":")
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates an ed25519 SSH keypair and returns the public key.
|
||||
// KeyGenOptions controls how ssh-keygen is invoked.
|
||||
type KeyGenOptions struct {
|
||||
KeyType string // ed25519 (default), rsa, ecdsa
|
||||
KeySize int // bits; used for rsa and ecdsa
|
||||
Passphrase string // empty = no passphrase
|
||||
Comment string // embedded in the public key
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates an SSH keypair and returns the public key.
|
||||
// The private key is written to keyPath; keyPath+".pub" holds the public key.
|
||||
func GenerateKeyPair(keyPath, comment string) (string, error) {
|
||||
func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-t", "ed25519",
|
||||
"-f", keyPath,
|
||||
"-N", "",
|
||||
"-C", comment,
|
||||
keyType := opts.KeyType
|
||||
if keyType == "" {
|
||||
keyType = "ed25519"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-t", keyType,
|
||||
"-f", keyPath,
|
||||
"-N", opts.Passphrase,
|
||||
"-C", opts.Comment,
|
||||
}
|
||||
if opts.KeySize > 0 && keyType != "ed25519" {
|
||||
args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize))
|
||||
}
|
||||
|
||||
cmd := exec.Command("ssh-keygen", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -118,3 +138,91 @@ func GenerateKeyPair(keyPath, comment string) (string, error) {
|
||||
}
|
||||
return strings.TrimSpace(string(pubData)), nil
|
||||
}
|
||||
|
||||
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
|
||||
// vantage.conf include file, and ensures ~/.ssh/config includes it.
|
||||
func AddSSHIdentity(keyPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
|
||||
return fmt.Errorf("mkdir .ssh: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureIncludeDirective(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read existing managed config (it may not exist yet).
|
||||
var existing string
|
||||
data, err := os.ReadFile(managedConfigPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read %s: %w", managedConfigPath, err)
|
||||
}
|
||||
existing = string(data)
|
||||
|
||||
line := "IdentityFile " + keyPath
|
||||
for _, l := range strings.Split(existing, "\n") {
|
||||
if strings.TrimSpace(l) == line {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
|
||||
if existing != "" && !strings.HasSuffix(existing, "\n") {
|
||||
existing += "\n"
|
||||
}
|
||||
updated := existing + line + "\n"
|
||||
|
||||
if err := os.WriteFile(managedConfigPath, []byte(updated), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", managedConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config.
|
||||
func RemoveSSHIdentity(keyPath string) error {
|
||||
data, err := os.ReadFile(managedConfigPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", managedConfigPath, err)
|
||||
}
|
||||
|
||||
line := "IdentityFile " + keyPath
|
||||
var kept []string
|
||||
for _, l := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
||||
if strings.TrimSpace(l) != line {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(kept, "\n")
|
||||
if len(kept) > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
if err := os.WriteFile(managedConfigPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", managedConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top
|
||||
// of ~/.ssh/config if it is not already present. The Include must appear before
|
||||
// any Host stanzas to be effective for all connections.
|
||||
func ensureIncludeDirective() error {
|
||||
data, err := os.ReadFile(sshConfigPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read %s: %w", sshConfigPath, err)
|
||||
}
|
||||
|
||||
for _, l := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(l) == includeDirective {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend the Include directive so it takes effect before any Host blocks.
|
||||
updated := includeDirective + "\n" + string(data)
|
||||
if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", sshConfigPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+418
-15
@@ -1,20 +1,31 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/keymanager/agent/internal/config"
|
||||
grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc"
|
||||
"github.com/mrhid6/keymanager/agent/internal/keys"
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
agentexec "github.com/mrhid6/vantage/agent/internal/exec"
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/agent/internal/keys"
|
||||
"github.com/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
func Run(cfg *config.Config) error {
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial grpc: %w", err)
|
||||
@@ -40,7 +51,6 @@ func Run(cfg *config.Config) error {
|
||||
}
|
||||
log.Println("registration successful")
|
||||
|
||||
// Reconnect with potentially updated state
|
||||
client.Close()
|
||||
client, err = grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
@@ -52,28 +62,43 @@ func Run(cfg *config.Config) error {
|
||||
return fmt.Errorf("no agent token available — registration required")
|
||||
}
|
||||
|
||||
// Start the command stream alongside the poll loop.
|
||||
go runCommandStream(ctx, cfg)
|
||||
|
||||
// Check for OS updates on startup and then hourly.
|
||||
go runUpdateCheck(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
if err := poll(client, cfg); err != nil {
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
|
||||
for range ticker.C {
|
||||
if err := poll(client, cfg); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken)
|
||||
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)
|
||||
@@ -91,6 +116,375 @@ func poll(client *grpcclient.Client, cfg *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommandStream maintains a persistent bidirectional stream with the server
|
||||
// for instant command delivery. Reconnects with exponential backoff on failure.
|
||||
func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 2 * time.Minute
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := connectAndHandleStream(ctx, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
} else {
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.CommandStream(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open stream: %w", err)
|
||||
}
|
||||
|
||||
if err := stream.Send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Ready: &pb.AgentReady{},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send auth: %w", err)
|
||||
}
|
||||
|
||||
log.Println("command stream connected")
|
||||
|
||||
// grpc streams are not safe for concurrent Send; RunStep results are sent
|
||||
// from per-command goroutines, so all sends on this stream must go through
|
||||
// this mutex-protected helper.
|
||||
var sendMu sync.Mutex
|
||||
send := func(msg *pb.AgentMessage) error {
|
||||
sendMu.Lock()
|
||||
defer sendMu.Unlock()
|
||||
return stream.Send(msg)
|
||||
}
|
||||
|
||||
for {
|
||||
cmd, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recv: %w", err)
|
||||
}
|
||||
|
||||
if cmd.GenerateKey != nil {
|
||||
go handleGenerateKey(cfg, cmd)
|
||||
}
|
||||
if cmd.DeleteKey != nil {
|
||||
go handleDeleteKey(cmd)
|
||||
}
|
||||
if cmd.UpdateAgent != nil {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data},
|
||||
})
|
||||
}
|
||||
res := agentexec.RunStep(rc, emit)
|
||||
res.CommandId = cid
|
||||
// Final eof marker so the server closes the log file.
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true},
|
||||
})
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepResult: res,
|
||||
})
|
||||
}(cmd.RunStep, cmd.CommandId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
const interval = time.Hour
|
||||
|
||||
doCheck := func() {
|
||||
pkgs, err := updates.CheckAvailable()
|
||||
if err != nil {
|
||||
log.Printf("update check error: %v", err)
|
||||
return
|
||||
}
|
||||
pbUpdates := make([]pb.PackageUpdate, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
pbUpdates[i] = pb.PackageUpdate{
|
||||
Name: p.Name,
|
||||
CurrentVersion: p.CurrentVersion,
|
||||
NewVersion: p.NewVersion,
|
||||
}
|
||||
}
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("update report dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, pbUpdates); err != nil {
|
||||
log.Printf("ReportUpdates error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d available OS updates", len(pkgs))
|
||||
}
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
doCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
|
||||
if err := updates.ApplyAll(); err != nil {
|
||||
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
|
||||
|
||||
// Re-report the (now empty) update list so the server reflects the new state.
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
if err := keys.RemoveSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
}
|
||||
|
||||
for _, path := range []string{keyPath, keyPath + ".pub"} {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("delete key file %s (cmd=%s): %v", path, cmd.CommandId, err)
|
||||
}
|
||||
}
|
||||
log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
if runtime.GOOS == "windows" {
|
||||
handleUpdateAgentWindows(cmd)
|
||||
return
|
||||
}
|
||||
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH // "amd64" or "arm64"
|
||||
tag := "agent%2Fv" + u.Version
|
||||
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
// Download binary
|
||||
tmpBin := "/tmp/vantage-agent-update"
|
||||
if err := downloadFile(binaryURL, tmpBin); err != nil {
|
||||
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Download and verify checksum
|
||||
checksumData, err := httpGetBytes(checksumURL)
|
||||
if err != nil {
|
||||
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := verifyChecksum(tmpBin, fmt.Sprintf("vantage-agent-linux-%s", arch), checksumData); err != nil {
|
||||
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
|
||||
os.Remove(tmpBin)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpBin, 0755); err != nil {
|
||||
log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpBin, "/usr/local/bin/vantage-agent"); err != nil {
|
||||
log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId)
|
||||
exec.Command("systemctl", "restart", "vantage-agent").Run()
|
||||
}
|
||||
|
||||
// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to
|
||||
// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so
|
||||
// that when the upgrade stops the VantageAgent service, nssm's process-tree
|
||||
// kill of this agent does not also kill the installer mid-flight. Config
|
||||
// (server_id, agent_token) is preserved by setup.ps1 on upgrade.
|
||||
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
tag := "agent%2Fv" + u.Version
|
||||
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
msiPath := filepath.Join(os.TempDir(), "vantage-agent-update.msi")
|
||||
if err := downloadFile(msiURL, msiPath); err != nil {
|
||||
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
checksumData, err := httpGetBytes(checksumURL)
|
||||
if err != nil {
|
||||
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
if err := verifyChecksum(msiPath, "vantage-agent.msi", checksumData); err != nil {
|
||||
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
|
||||
os.Remove(msiPath)
|
||||
return
|
||||
}
|
||||
|
||||
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
|
||||
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
// "start" detaches msiexec from this process tree so the service stop
|
||||
// during the upgrade does not terminate the installer.
|
||||
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
|
||||
if err := up.Start(); err != nil {
|
||||
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func httpGetBytes(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func verifyChecksum(filePath, filename string, checksumData []byte) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
for _, line := range strings.Split(string(checksumData), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[1] == filename {
|
||||
if fields[0] != actual {
|
||||
return fmt.Errorf("expected %s got %s", fields[0], actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no checksum entry found for %s", filename)
|
||||
}
|
||||
|
||||
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
g := cmd.GenerateKey
|
||||
label := g.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
opts := keys.KeyGenOptions{
|
||||
KeyType: g.KeyType,
|
||||
KeySize: g.KeySize,
|
||||
Passphrase: g.Passphrase,
|
||||
Comment: g.Comment,
|
||||
}
|
||||
pubKey, err := keys.GenerateKeyPair(keyPath, opts)
|
||||
if err != nil {
|
||||
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
privKeyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
log.Printf("read private key failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
|
||||
if err != nil {
|
||||
log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := keys.AddSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("add ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
}
|
||||
log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId)
|
||||
}
|
||||
|
||||
func localIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
@@ -114,16 +508,25 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
pubKey, err := keys.GenerateKeyPair(keyPath, label)
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label)
|
||||
privKeyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
|
||||
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := keys.AddSSHIdentity(keyPath); err != nil {
|
||||
log.Printf("add ssh identity: %v", err)
|
||||
}
|
||||
log.Printf("uploaded generated key %s (key_id=%s)", label, keyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
NewVersion string
|
||||
}
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckAvailable returns the list of packages with available upgrades.
|
||||
// Returns nil, nil when no supported package manager is found.
|
||||
func CheckAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyAll runs a full non-interactive upgrade using the detected package manager.
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
// Refresh lists first, then upgrade.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
// Best-effort refresh; ignore errors (cached data is fine).
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Format: package/suite version arch [upgradable from: old-ver]
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
// Exit code 100 means updates are available — not an error.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
// name.arch new-version repo
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
// Format: package old-version -> new-version
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Data rows start with "v |" (available) or "i |" (installed but updatable).
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# KeyManager
|
||||
# Vantage
|
||||
|
||||
A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state.
|
||||
|
||||
@@ -34,7 +34,7 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
keymanager/
|
||||
vantage/
|
||||
├── agent/
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
@@ -56,7 +56,7 @@ keymanager/
|
||||
│ ├── app/
|
||||
│ └── components/
|
||||
├── proto/
|
||||
│ └── keymanager/v1/keymanager.proto
|
||||
│ └── vantage/v1/vantage.proto
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── agent.service
|
||||
@@ -72,9 +72,9 @@ keymanager/
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package keymanager.v1;
|
||||
package vantage.v1;
|
||||
|
||||
service KeyManager {
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
@@ -172,10 +172,10 @@ No streaming — polling only. Poll interval: **30 seconds**.
|
||||
|
||||
## Agent Lifecycle
|
||||
|
||||
### Config file — `/etc/keymanager/config.yaml`
|
||||
### Config file — `/etc/vantage/config.yaml`
|
||||
|
||||
```yaml
|
||||
server_url: "keymanager.yourdomain.com:9090"
|
||||
server_url: "vantage.yourdomain.com:9090"
|
||||
server_id: "<uuid>"
|
||||
pre_reg_token: "<token>" # removed after first successful Register()
|
||||
agent_token: "" # written by agent after Register()
|
||||
@@ -216,15 +216,15 @@ Config file permissions: `0600`. Config directory: `0700`.
|
||||
- Uploads public key via `UploadGeneratedKey()`
|
||||
- Private key stays local on the machine
|
||||
|
||||
### Systemd unit — `/etc/systemd/system/keymanager-agent.service`
|
||||
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=KeyManager Agent
|
||||
Description=Vantage Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/keymanager-agent
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
@@ -241,14 +241,14 @@ WantedBy=multi-user.target
|
||||
2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id`
|
||||
3. UI displays a one-liner install command with copy button:
|
||||
```bash
|
||||
curl -fsSL https://keymanager.yourdomain.com/install | \
|
||||
curl -fsSL https://vantage.yourdomain.com/install | \
|
||||
bash -s -- --server-id=<id> --token=<token>
|
||||
```
|
||||
4. Install script:
|
||||
- Detects arch (`amd64` / `arm64`)
|
||||
- Downloads agent binary from Gitea release
|
||||
- Verifies SHA-256 checksum
|
||||
- Writes `/etc/keymanager/config.yaml`
|
||||
- Writes `/etc/vantage/config.yaml`
|
||||
- Installs and starts systemd unit
|
||||
5. On first `SyncKeys` call, server marks status as `active`
|
||||
|
||||
@@ -259,7 +259,7 @@ The backend serves `/install` dynamically, injecting the latest agent version by
|
||||
## Security
|
||||
|
||||
- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent)
|
||||
- Agent authenticates with a per-server token stored at `/etc/keymanager/config.yaml` (`0600`)
|
||||
- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`)
|
||||
- Server stores `SHA-256(agent_token)` — never the plaintext token
|
||||
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
|
||||
- `authorized_keys` written as `0600`, owned by root
|
||||
@@ -298,13 +298,13 @@ Build command:
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/keymanager-agent-linux-amd64 ./cmd
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
```
|
||||
|
||||
Release assets:
|
||||
|
||||
- `keymanager-agent-linux-amd64`
|
||||
- `keymanager-agent-linux-arm64`
|
||||
- `vantage-agent-linux-amd64`
|
||||
- `vantage-agent-linux-arm64`
|
||||
- `checksums.txt`
|
||||
|
||||
### Server deploy — `.gitea/workflows/server-deploy.yml`
|
||||
@@ -312,7 +312,7 @@ Release assets:
|
||||
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
|
||||
|
||||
```bash
|
||||
cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans
|
||||
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
|
||||
```
|
||||
|
||||
### Tagging convention
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
[Unit]
|
||||
Description=KeyManager Agent
|
||||
Documentation=https://github.com/your-org/keymanager
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/keymanager-agent
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=keymanager-agent
|
||||
SyslogIdentifier=vantage-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
|
||||
+31
-23
@@ -1,45 +1,53 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
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/keymanager
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
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:
|
||||
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:
|
||||
mongo_data: null
|
||||
redis_data: null
|
||||
networks: {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,653 @@
|
||||
# Fleet Inventory 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:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
|
||||
|
||||
**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
|
||||
- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
|
||||
- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
|
||||
- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
|
||||
- Module path `github.com/mrhid6/vantage`.
|
||||
- Do not add heavy dependencies; implement `/proc` parsing directly.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Inventory model + gRPC messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/server.go`
|
||||
- Modify: `proto/vantage/v1/vantage.proto`
|
||||
- Modify: `server/internal/grpc/pb/vantage.pb.go`
|
||||
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
|
||||
|
||||
- [ ] **Step 1: Add model structs**
|
||||
|
||||
In `server/internal/models/server.go` add (keep the existing `import "time"`):
|
||||
|
||||
```go
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
|
||||
|
||||
- [ ] **Step 2: Document RPC in proto**
|
||||
|
||||
In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
|
||||
|
||||
- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
|
||||
|
||||
In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
|
||||
|
||||
```go
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
```
|
||||
|
||||
Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
|
||||
- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
|
||||
- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
|
||||
- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
|
||||
- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
|
||||
- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
|
||||
|
||||
- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
|
||||
|
||||
Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./...`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
|
||||
git commit -m "feat(proto): add ReportInventory RPC and inventory model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Server handler + store service
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/inventory.go`
|
||||
- Modify: `server/internal/grpc/server.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
|
||||
- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
|
||||
|
||||
- [ ] **Step 1: Write the store service**
|
||||
|
||||
```go
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// StoreInventory upserts the latest inventory snapshot onto the server document.
|
||||
// Metrics fields update every call; static fields only when r.IncludeStatic.
|
||||
func StoreInventory(serverID string, r *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
set := bson.M{"inventory.metrics_at": now}
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
|
||||
set["inventory.cpu.load1"] = r.CPU.Load1
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
|
||||
}
|
||||
set["inventory.swap_used_bytes"] = r.SwapUsed
|
||||
|
||||
if r.IncludeStatic {
|
||||
set["inventory.static_at"] = now
|
||||
set["inventory.swap_total_bytes"] = r.SwapTotal
|
||||
set["inventory.kernel"] = r.Kernel
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.model"] = r.CPU.Model
|
||||
set["inventory.cpu.cores"] = r.CPU.Cores
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
|
||||
}
|
||||
parts := make([]bson.M, 0, len(r.Partitions))
|
||||
for _, p := range r.Partitions {
|
||||
parts = append(parts, bson.M{
|
||||
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
|
||||
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
|
||||
})
|
||||
}
|
||||
set["inventory.partitions"] = parts
|
||||
}
|
||||
|
||||
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the gRPC handler**
|
||||
|
||||
In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
|
||||
|
||||
```go
|
||||
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
if err := services.StoreInventory(srv.ServerID, req); err != nil {
|
||||
log.Printf("store inventory for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.InventoryReportResponse{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/inventory.go server/internal/grpc/server.go
|
||||
git commit -m "feat(server): store inventory and handle ReportInventory RPC"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Agent collectors
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/inventory/collect_linux.go`
|
||||
- Create: `agent/internal/inventory/collect_other.go`
|
||||
- Create: `agent/internal/inventory/inventory.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
|
||||
|
||||
- [ ] **Step 1: Common entry (`inventory.go`)**
|
||||
|
||||
```go
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// Collect gathers metrics always and static hardware info when includeStatic.
|
||||
// Platform specifics are provided by collect_linux.go / collect_other.go.
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Linux collector (`collect_linux.go`)**
|
||||
|
||||
Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
|
||||
- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
|
||||
- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
|
||||
- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
|
||||
- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
r.CPU.Load1 = load1()
|
||||
memTotal, memAvail, swapTotal, swapFree := meminfo()
|
||||
if memTotal > memAvail {
|
||||
r.Memory.UsedBytes = memTotal - memAvail
|
||||
}
|
||||
if swapTotal > swapFree {
|
||||
r.SwapUsed = swapTotal - swapFree
|
||||
}
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = memTotal
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
|
||||
|
||||
func cpuSample() (idle, total uint64) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if sc.Scan() {
|
||||
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 { // idle
|
||||
idle = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1 := cpuSample()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2 := cpuSample()
|
||||
dt := float64(t2 - t1)
|
||||
if dt <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/dt) * 100
|
||||
}
|
||||
|
||||
func load1() float64 {
|
||||
fields := strings.Fields(readProc("/proc/loadavg"))
|
||||
if len(fields) > 0 {
|
||||
v, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func meminfo() (total, avail, swapTotal, swapFree uint64) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
kb, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
b := kb * 1024
|
||||
switch strings.TrimSuffix(fields[0], ":") {
|
||||
case "MemTotal":
|
||||
total = b
|
||||
case "MemAvailable":
|
||||
avail = b
|
||||
case "SwapTotal":
|
||||
swapTotal = b
|
||||
case "SwapFree":
|
||||
swapFree = b
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
f, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "processor") {
|
||||
cores++
|
||||
} else if strings.HasPrefix(line, "model name") && model == "" {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
model = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []pb.PartitionReport
|
||||
seen := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
|
||||
continue
|
||||
}
|
||||
seen[fields[1]] = true
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
|
||||
|
||||
```go
|
||||
//go:build !linux
|
||||
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// collect is a no-op best-effort stub on non-Linux platforms.
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/inventory/
|
||||
git commit -m "feat(agent): /proc-based inventory collectors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Agent client method + scheduler
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/internal/grpc/client.go`
|
||||
- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `inventory.Collect` (T3), pb (T1).
|
||||
- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
|
||||
|
||||
- [ ] **Step 1: Add client method**
|
||||
|
||||
In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
|
||||
|
||||
```go
|
||||
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportInventory(ctx, report)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
|
||||
|
||||
- [ ] **Step 2: Add the scheduler to the agent loop**
|
||||
|
||||
Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
tick := 0
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
report := func(static bool) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = serverID
|
||||
r.AgentToken = agentToken
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
}
|
||||
report(true) // send a full snapshot on startup
|
||||
for range t.C {
|
||||
tick++
|
||||
report(tick%30 == 0) // every 30th tick = 15 min → include static
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/grpc/client.go agent/
|
||||
git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — inventory panel on server detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
|
||||
- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: server-detail query.
|
||||
|
||||
- [ ] **Step 1: Add the inventory type**
|
||||
|
||||
In `web/lib/api.ts`, add and attach to the server type used by the detail page:
|
||||
|
||||
```ts
|
||||
export interface Inventory {
|
||||
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
|
||||
memory: { total_bytes: number; used_bytes: number };
|
||||
swap_total_bytes: number;
|
||||
swap_used_bytes: number;
|
||||
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
|
||||
kernel?: string;
|
||||
metrics_at?: string;
|
||||
static_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add `inventory?: Inventory;` to the server detail interface.
|
||||
|
||||
- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
|
||||
|
||||
In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
|
||||
|
||||
```tsx
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function UsageBar({ used, total }: { used: number; total: number }) {
|
||||
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
{inv.partitions && inv.partitions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
|
||||
<div className="space-y-3">
|
||||
{inv.partitions.map((p) => (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Render `{server.inventory && <InventoryPanel inv={server.inventory} />}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api.ts web/app/servers/[id]/page.tsx
|
||||
git commit -m "feat(web): inventory panel on server detail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: End-to-end manual verification
|
||||
|
||||
- [ ] **Step 1: Build all**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Smoke (if environment available)**
|
||||
|
||||
With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
|
||||
|
||||
- [ ] **Step 3: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: fleet inventory verification fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
|
||||
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
|
||||
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
|
||||
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,747 @@
|
||||
# Workflow Builder v2 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:** Rebuild the workflow builder to match the approved mockup with drag-and-drop, base-step editing/deletion (cascade), step input parameters, an edit-workflow modal, runs navigation, and fix the save crash.
|
||||
|
||||
**Architecture:** Backend gains input-parameter fields on step/ref/resolved models, cascade delete, runner env injection, and a workflow-update handler that returns the updated workflow. Frontend adds `bash`/`pwsh`/`signal` tokens and a `Modal` primitive, then rebuilds the builder page (dotted canvas, 340px node cards, wire env chips, kicker/field inspector, HTML5 drag-and-drop), plus Edit-base-step and Edit-workflow modals and a runs list page.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2), Next.js 16 app-router + react-query + Tailwind, HTML5 Drag-and-Drop, MongoDB.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration** — no `*_test.go` or frontend tests. Verify with `go build ./...`, `go vet ./...`, `npm run build`.
|
||||
- Mongo access pattern: `db.Col("collection_name")` + `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
|
||||
- Audit every mutation with `services.LogEvent(action, actor, serverID, targetID, message)`.
|
||||
- Interpreter literals are `"bash"` and `"powershell"`.
|
||||
- Go module path: `github.com/mrhid6/vantage`.
|
||||
- Visual target: approved mockup. Builder palette adds amber signal `#f5a524` (`signal`), `#241800` (`signal-ink`), bash `#3fb950`, pwsh `#5b9bff`; keep existing `surface`/`surface-2`/`border`/`text-primary`/`text-secondary`/`danger`/`accent` tokens for panels. Node cards 340px; dotted-grid canvas; dashed-amber `passes` chips on wires; kicker/field inspector.
|
||||
- Frontend uses `@/lib/api` typed client, `@/components/ui`, react-query. Input styling follows the existing `inputClass` pattern in `web/app/secrets/page.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Models + step CRUD + cascade delete
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/workflow.go`
|
||||
- Modify: `server/internal/services/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.InputParam{Name,Default,Description}`; `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs`, `ResolvedStep.Inputs`. `DeleteStep(stepID)` now cascades to workflows.
|
||||
|
||||
- [ ] **Step 1: Add the model fields**
|
||||
|
||||
In `server/internal/models/workflow.go` add:
|
||||
|
||||
```go
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
- In `WorkflowStep`, add after `DeclaredOutputs`: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``
|
||||
- In `WorkflowStepRef`, add: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"``
|
||||
- In `ResolvedStep`, add: `Inputs map[string]string `bson:"inputs" json:"inputs"``
|
||||
|
||||
- [ ] **Step 2: Persist declared_inputs in CreateStep/UpdateStep**
|
||||
|
||||
In `server/internal/services/workflows.go`:
|
||||
- In `CreateStep`, after the `SecretRefs` nil-guard add:
|
||||
|
||||
```go
|
||||
if s.DeclaredInputs == nil {
|
||||
s.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
```
|
||||
|
||||
- In `UpdateStep`'s `$set`, add: `"declared_inputs": s.DeclaredInputs,`
|
||||
|
||||
- [ ] **Step 3: Cascade DeleteStep to workflows**
|
||||
|
||||
Replace the body of `DeleteStep` with a version that also strips the step from every workflow:
|
||||
|
||||
```go
|
||||
func DeleteStep(stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range wfs {
|
||||
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == stepID {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ref)
|
||||
}
|
||||
for i := range kept {
|
||||
kept[i].Order = i
|
||||
}
|
||||
if _, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": w.WorkflowID},
|
||||
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `models` and `time` are imported in this file (they are).
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/workflow.go server/internal/services/workflows.go
|
||||
git commit -m "feat(workflows): step input params model + cascade step delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Runner input injection + update handler returns workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/services/workflow_runner.go`
|
||||
- Modify: `server/internal/api/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `models.ResolvedStep.Inputs`, `WorkflowStep.DeclaredInputs`, `WorkflowStepRef.Inputs` (Task 1).
|
||||
- Produces: runner injects input env; `PUT /api/workflows/:id` returns the `Workflow`.
|
||||
|
||||
- [ ] **Step 1: Resolve inputs in `resolveSteps`**
|
||||
|
||||
In `server/internal/services/workflow_runner.go`, in `resolveSteps`, after loading `lib` and before/where the `ResolvedStep` is built, compute the input env and set it. Add inside the loop (after `lib, err := getStep(...)` succeeds):
|
||||
|
||||
```go
|
||||
inputs := map[string]string{}
|
||||
for _, p := range lib.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
```
|
||||
|
||||
and set `Inputs: inputs,` on the `models.ResolvedStep{...}` literal.
|
||||
|
||||
- [ ] **Step 2: Inject inputs into cmdEnv in `runServer`**
|
||||
|
||||
In `runServer`, where `cmdEnv` is built (currently: copy `runEnv`, then overlay `secretVals`), change the layering so inputs are the base layer:
|
||||
|
||||
```go
|
||||
cmdEnv := map[string]string{}
|
||||
for k, v := range step.Inputs {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range runEnv {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range secretVals {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
```
|
||||
|
||||
(Inputs first = lowest precedence; upstream outputs override; secrets win. Keep the existing `secretVals`/`allSecrets` masking logic unchanged.)
|
||||
|
||||
- [ ] **Step 3: `updateWorkflow` handler returns the workflow**
|
||||
|
||||
In `server/internal/api/workflows.go`, change `updateWorkflow` so that after a successful `services.UpdateWorkflow`, it re-fetches and returns the workflow:
|
||||
|
||||
```go
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateWorkflow(c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/workflow_runner.go server/internal/api/workflows.go
|
||||
git commit -m "feat(workflows): inject step inputs into env; update returns workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend tokens, Modal primitive, API types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/tailwind.config.ts`
|
||||
- Create: `web/components/ui/Modal.tsx`
|
||||
- Modify: `web/components/ui/index.ts`
|
||||
- Modify: `web/lib/api.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: tokens `bash`/`pwsh`/`signal`/`signal-ink`; `<Modal open title onClose>children</Modal>`; TS `InputParam`, `WorkflowStep.declared_inputs`, `WorkflowStepRef.inputs`.
|
||||
|
||||
- [ ] **Step 1: Add tokens**
|
||||
|
||||
In `web/tailwind.config.ts`, add to `theme.extend.colors`:
|
||||
|
||||
```ts
|
||||
bash: "#3fb950",
|
||||
pwsh: "#5b9bff",
|
||||
signal: "#f5a524",
|
||||
"signal-ink": "#241800",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the Modal primitive**
|
||||
|
||||
`web/components/ui/Modal.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Export it**
|
||||
|
||||
In `web/components/ui/index.ts` add: `export { Modal } from "./Modal";`
|
||||
|
||||
- [ ] **Step 4: API types**
|
||||
|
||||
In `web/lib/api.ts`:
|
||||
- Add interface:
|
||||
|
||||
```ts
|
||||
export interface InputParam {
|
||||
name: string;
|
||||
default: string;
|
||||
description: string;
|
||||
}
|
||||
```
|
||||
|
||||
- In `WorkflowStep`, add `declared_inputs: InputParam[];`
|
||||
- In `WorkflowStepRef`, add `inputs?: Record<string, string>;`
|
||||
|
||||
(`updateWorkflow` already typed to return `Workflow`; the backend now honors it.)
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web/tailwind.config.ts web/components/ui/Modal.tsx web/components/ui/index.ts web/lib/api.ts
|
||||
git commit -m "feat(web): builder tokens, Modal primitive, input-param types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Edit-base-step modal
|
||||
|
||||
**Files:**
|
||||
- Create: `web/components/workflows/EditStepModal.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.createStep`, `api.updateStep`, `api.deleteStep`, `Modal`, types (Task 3).
|
||||
- Produces: `<EditStepModal open step onClose />` where `step` is a `WorkflowStep` (edit) or `null` (new).
|
||||
|
||||
- [ ] **Step 1: Write the modal**
|
||||
|
||||
Client component. Local form state seeded from `step` (or blank for new). Fields:
|
||||
- **Name** (text), **Interpreter** (select bash/powershell), **Script** (`<textarea>` mono).
|
||||
- **Outputs** (`declared_outputs`): a list of text chips with add/remove — an input + "Add" appends a name; each name shows an `✕` to remove.
|
||||
- **Inputs** (`declared_inputs`): rows, each with `name` / `default` / `description` inputs and a remove button; an "Add input" button appends a blank `{name:"",default:"",description:""}`.
|
||||
- Hint: "Reusable steps are shared across all workflows. Editing here changes it everywhere."
|
||||
|
||||
Actions:
|
||||
- **Save**: build the `WorkflowStep` payload (`declared_outputs`, `declared_inputs`, `secret_refs: step?.secret_refs ?? []`, `description: ""` if absent). If editing (`step` truthy) call `api.updateStep(step.step_id, payload)`, else `api.createStep(payload)`. On success `queryClient.invalidateQueries({queryKey:["steps"]})` and `onClose()`.
|
||||
- **Delete** (edit mode only): a confirm (`window.confirm`) then `api.deleteStep(step.step_id)`, invalidate `["steps"]` AND `["workflow"]` (broad — deleted step is pulled from workflows server-side), `onClose()`.
|
||||
|
||||
Use the `inputClass` styling pattern and `Button` variants (`primary` save, `danger` delete, `ghost` cancel). Follow existing token names. Filter empty input rows (blank `name`) out of the payload on save.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } 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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(step?.name ?? "");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
|
||||
const [script, setScript] = useState(step?.script ?? "");
|
||||
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [newOut, setNewOut] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NOTE: because state is seeded from props, render the modal conditionally
|
||||
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(), description: step?.description ?? "", interpreter, script,
|
||||
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
|
||||
secret_refs: step?.secret_refs ?? [],
|
||||
};
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{outputs.map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{inputs.map((inp, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** because the form state seeds from `step` props, the parent must either mount `EditStepModal` only while open, or pass a React `key={step?.step_id ?? "new"}` so the state re-initializes each time a different step is edited. Note this in the component with a comment; the builder (Task 6) will mount it with a `key`.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success (the component may be unused until Task 6 — that's fine, but an unused import-free component builds).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/workflows/EditStepModal.tsx
|
||||
git commit -m "feat(web): edit-base-step modal with inputs/outputs editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Edit-workflow modal
|
||||
|
||||
**Files:**
|
||||
- Create: `web/components/workflows/EditWorkflowModal.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.updateWorkflow`, `api.deleteWorkflow`, `api.listServers`, `Modal`, `Workflow` (Task 3).
|
||||
- Produces: `<EditWorkflowModal open workflow onSaved onClose />` — saves name/targets immediately, returns the updated workflow via `onSaved`.
|
||||
|
||||
- [ ] **Step 1: Write the modal**
|
||||
|
||||
Client component. Seeds name + selected target servers from `workflow`. Uses `api.listServers` (react-query) for the multiselect (checkbox chips keyed by `server_id`, labelled `hostname`). Actions:
|
||||
- **Save**: `const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids })`; call `onSaved(updated)`; `onClose()`. (Backend now returns the workflow.)
|
||||
- **Delete workflow**: confirm, `api.deleteWorkflow(workflow.workflow_id)`, then `router.push("/workflows")`.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.includes(s.server_id);
|
||||
return (
|
||||
<label key={s.server_id} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/components/workflows/EditWorkflowModal.tsx
|
||||
git commit -m "feat(web): edit-workflow modal (name/targets/delete)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Builder rewrite — mockup styling + drag-and-drop + inspector
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/app/workflows/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.getWorkflow/updateWorkflow/listSteps/runWorkflow`, `EditStepModal` (Task 4), `EditWorkflowModal` (Task 5), tokens (Task 3).
|
||||
|
||||
Rebuild the page to the approved mockup. Reference values from the mockup:
|
||||
- Canvas dotted grid: `background: radial-gradient(circle at 1px 1px, <border> 1px, transparent 0) 0 0 / 22px 22px` over `bg-background`.
|
||||
- Node card: width `340px`, `rounded-[10px] border border-border bg-surface`, selected → `border-signal` + `ring-2 ring-signal/40`. Top row: index badge (mono, boxed), title, shell badge, then body with a mono script preview (`bg-surface-2 border border-border rounded p-2 text-xs`).
|
||||
- Shell badges: bash → `text-bash bg-bash/15`, pwsh (powershell) → `text-pwsh bg-pwsh/15`, mono uppercase `text-[10px] px-1.5 py-0.5 rounded`.
|
||||
- Wire: a 2px vertical `bg-border` segment ~26px tall between nodes.
|
||||
- `passes` chip row: dashed amber pill `border border-dashed border-signal/55 bg-surface rounded-full px-3 py-1`, label "passes" (`text-[10px] uppercase text-text-secondary`), each output name a chip `bg-signal text-signal-ink font-mono text-[11px] rounded px-2 py-0.5`.
|
||||
- Inspector fields: kicker (`text-[11px] uppercase tracking-wide text-text-secondary font-bold`), title with shell badge; each field block separated by `border-b border-border` with an uppercase label.
|
||||
|
||||
- [ ] **Step 1: Rewrite the builder page**
|
||||
|
||||
Requirements (keep everything in React state; **Save** persists via `api.updateWorkflow` and sets `wf` to the returned workflow):
|
||||
|
||||
1. **State/data:** load workflow (`["workflow", id]`), library (`["steps"]`), servers (`["servers"]`). Seed local `wf` from the query once. Keep `selected` (index in sorted order). `sortedSteps = [...wf.steps].sort(order)`.
|
||||
2. **Topbar:** brand dot + `Workflows /` crumb + `wf.name` + `· draft`. Right: a **Targets** chip showing `${wf.target_server_ids.length} servers`; a **Runs** link (`<Link href={`/workflows/${id}/runs`}>`); an **Edit** button (opens `EditWorkflowModal`); **Save** (`variant="secondary"`, persists, `setWf(returned)`); **Run workflow** (amber: `className` using `bg-signal text-signal-ink`, or `variant="primary"` acceptable) → `api.runWorkflow` then route to the run detail.
|
||||
3. **Library (left):** header "Step Library" + `+` button opening `EditStepModal` in new mode (`step={null}`). A search input filters `library` by name (case-insensitive). Group by interpreter with labels `Shared · Bash` / `Shared · PowerShell`. Each card: shell badge, name, `description`, a grip glyph (`⠿`), `cursor-grab`, and `draggable`. On `dragstart` set `dataTransfer` to `JSON.stringify({kind:"lib", stepId})`. Clicking the card still appends the step. A small edit affordance (e.g. a pencil `✎` button on hover) opens `EditStepModal` with that library step.
|
||||
4. **Canvas (center):** dotted grid. For each `sortedSteps[i]`: render a drop target above it (a thin zone; on `drop` insert at `i`), then the node card. Node card is `draggable` (`dragstart` sets `{kind:"move", from:i}`). Between consecutive nodes render the wire + `passes` chips = union of `sortedSteps.slice(0,i)` `declared_outputs` (deduped). After the last node render an end drop zone styled `+ Drop a step here` (dashed). Handle `drop`:
|
||||
- parse `dataTransfer`; if `kind==="lib"` insert a new `WorkflowStepRef{step_id, order:<pos>, on_failure:"stop", max_retries:0}` at the drop position; if `kind==="move"` move `from`→`pos`; then re-sequence all `order` to array index. Update `wf`.
|
||||
- `dragover` must `e.preventDefault()` on drop zones to allow dropping.
|
||||
- Selecting a node (click) sets `selected`.
|
||||
5. **Inspector (right):** for the selected placement (`selectedRef` = `sortedSteps[selected]`, `selectedLib` = library by `step_id`):
|
||||
- Kicker `Step ${selected+1} · Inspector`, title = shell badge + `selectedLib.name`.
|
||||
- **Command** `<textarea>` bound to `selectedRef.overrides?.script ?? selectedLib.script`; edits write `overrides.script` on the ref (per-placement override). Hint about `$WORKFLOW_ENV`.
|
||||
- **Inputs** (this step's `declared_inputs`): one row per param — label = `param.name` (+ description as sub-text), an input bound to `selectedRef.inputs?.[name] ?? ""` with `placeholder={param.default}`; edits write `ref.inputs[name]`.
|
||||
- **Inputs · from upstream** (read-only): the union of prior steps' `declared_outputs` as `IN` rows.
|
||||
- **Outputs · to $WORKFLOW_ENV** (read-only): this step's `declared_outputs` as `OUT` rows.
|
||||
- **Secret refs**: keep the existing group/KEY checklist behavior (port it over) writing `overrides.secret_refs`.
|
||||
- **On failure** select + **Max retries** (when retry).
|
||||
- **Remove from workflow** button (danger) → remove the ref, re-sequence orders, clear selection.
|
||||
6. **Modals:** render `<EditWorkflowModal>` (open state) and `<EditStepModal key={editingStep?.step_id ?? "new"} open step={editingStep} onClose>`; the library `+` and per-card edit set `editingStep`.
|
||||
7. **Bug fix:** `save()` sets `wf` to the awaited `updateWorkflow` result (now a full workflow); no direct crash. Additionally guard: if the returned object lacks `steps`, keep the prior `wf` and surface an error.
|
||||
|
||||
Provide a complete, working implementation (this is a full rewrite of the file). Preserve the secret-group lazy-fetch (`api.getSecretGroup`) logic from the current file for the secret-refs checklist. Use `inputClass` styling. Do not leave TODOs.
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: type-checks and builds. Manually confirm no `st.stdout`-style dead references and no unused imports.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/app/workflows/[id]/page.tsx
|
||||
git commit -m "feat(web): rebuild workflow builder — mockup styling, drag-and-drop, inputs inspector"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Runs list page + navigation links
|
||||
|
||||
**Files:**
|
||||
- Create: `web/app/workflows/[id]/runs/page.tsx`
|
||||
- Modify: `web/app/workflows/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.listRuns`, `api.getWorkflow`.
|
||||
|
||||
- [ ] **Step 1: Runs list page**
|
||||
|
||||
`web/app/workflows/[id]/runs/page.tsx` — client component. Load `api.getWorkflow(id)` (for the name) and `api.listRuns(id)`. Render a header "Runs · <name>" with a "← Back to builder" `Link` to `/workflows/${id}`, and a table (`@/components/ui` Table) of runs: short run id, a status `Badge` (map success→success, failed→danger, running→accent/warning, cancelled→neutral), `started_at` (localized), `triggered_by`, and server count (`server_runs.length`). Each row links to `/workflows/${id}/runs/${run.run_id}`. Empty state "No runs yet."
|
||||
|
||||
Use the existing Badge variants (success/warning/danger/neutral/accent) — verify names in `web/components/ui/Badge.tsx`.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowRun } from "@/lib/api";
|
||||
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
|
||||
|
||||
const statusVariant: Record<string, string> = {
|
||||
success: "success", failed: "danger", running: "warning", cancelled: "neutral", queued: "neutral",
|
||||
};
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
|
||||
const { data: runs } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">← Back to builder</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
|
||||
</div>
|
||||
<Card padding={false}>
|
||||
{runs && runs.length > 0 ? (
|
||||
<Table>
|
||||
<Thead><Tr><Th>Run</Th><Th>Status</Th><Th>Started</Th><Th>By</Th><Th>Servers</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{runs.map((r: WorkflowRun) => (
|
||||
<Tr key={r.run_id}>
|
||||
<Td><Link href={`/workflows/${id}/runs/${r.run_id}`} className="font-mono text-text-primary hover:text-signal">{r.run_id.slice(0, 8)}</Link></Td>
|
||||
<Td><Badge variant={(statusVariant[r.status] ?? "neutral") as never}>{r.status}</Badge></Td>
|
||||
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
|
||||
<Td className="text-text-secondary">{r.triggered_by}</Td>
|
||||
<Td className="text-text-secondary">{r.server_runs.length}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Adapt `Card padding={false}` / Badge variant prop to the real component signatures (check `Card.tsx`/`Badge.tsx`).
|
||||
|
||||
- [ ] **Step 2: Add a Runs link on the workflows list**
|
||||
|
||||
In `web/app/workflows/page.tsx`, in each workflow row add a "Runs" link/button next to "Open" → `Link href={`/workflows/${w.workflow_id}/runs`}`. Match the existing row action styling.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/app/workflows/[id]/runs/page.tsx web/app/workflows/page.tsx
|
||||
git commit -m "feat(web): workflow runs list page and navigation links"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build everything**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
|
||||
|
||||
1. Open a workflow builder; confirm it matches the mockup (dotted canvas, node cards, amber passes chips, kicker/field inspector).
|
||||
2. Drag a library step onto the canvas; drag to reorder; confirm order persists after Save (no "steps is not iterable" crash).
|
||||
3. Open Edit base step; add an input param (name/default/description) and an output; Save. Place the step; set the input value in the inspector; Run; confirm the script sees the input env var and downstream steps see the output.
|
||||
4. Delete a shared step from the Edit-base-step modal; confirm it disappears from every workflow that used it.
|
||||
5. Open Edit workflow; rename, change target servers, Save; confirm persisted. Delete a throwaway workflow; confirm redirect to `/workflows`.
|
||||
6. From the workflows list and the builder topbar, navigate to Runs; open a run.
|
||||
|
||||
- [ ] **Step 3: Commit any fixes found**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: workflow builder v2 e2e fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4.1 cascade/CRUD → T1; §4.2 update-returns-workflow → T2; §4.3 runner inputs → T2; §6.1 tokens/Modal → T3; §6.2 api types → T3; §6.4 edit-step modal → T4; §6.5 edit-workflow modal → T5; §6.3 builder restyle/DnD/inspector → T6; §6.6 runs page + nav → T7. Save crash fixed by T2 (server) + T6 (client guard). Tests omitted per Global Constraints.
|
||||
- **Dependency order:** modals (T4, T5) land before the builder (T6) that imports them; tokens/Modal/api (T3) first.
|
||||
- **Interpreter literals** `"bash"`/`"powershell"` consistent across T1/T4/T6.
|
||||
- **Modal state seeding:** EditStepModal re-seeds via `key` in the builder (documented in T4/T6).
|
||||
- **Open follow-ups (out of scope):** typed/required inputs, drag-to-trash removal, live status in canvas, keyboard reordering.
|
||||
```
|
||||
@@ -0,0 +1,887 @@
|
||||
# Workflow Log Streaming 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:** Stream workflow step output live from agents to per-server-run log files on the server, tail them live in the UI over SSE, and auto-expire them on a configurable retention period.
|
||||
|
||||
**Architecture:** Agent streams interleaved stdout/stderr chunks over the existing `CommandStream` (`AgentMessage.StepOutput`). Server appends secret-masked chunks to `<logdir>/<run_id>/<server_id>.log` via a per-command log-writer registry, records a per-step byte offset, and stops persisting log bodies in Mongo. UI tails via an SSE endpoint while running and fetches the whole file after. An hourly sweeper deletes run-log dirs older than the retention setting.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2), hand-written JSON-codec gRPC structs (no protoc), Next.js 16 app-router + react-query + EventSource, MongoDB, local filesystem for logs.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration** — do not write `*_test.go` or frontend tests. Verify each task with `go build ./...`, `go vet ./...`, and (frontend) `npm run build`.
|
||||
- gRPC uses a **JSON codec** — proto messages are hand-written Go structs in **two** files that must stay identical: `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`. No codegen. Also update `proto/vantage/v1/vantage.proto` as documentation.
|
||||
- Mongo access pattern: `db.Col("collection_name")` with `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
|
||||
- Secret values must never be written into log files unmasked — mask by literal `***` replacement at write time, boundary-safe via a carry buffer.
|
||||
- Interpreter values are the literals `"bash"` and `"powershell"`.
|
||||
- Go module path: `github.com/mrhid6/vantage`.
|
||||
- Log dir from env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`; files `0600`, dirs `0700`.
|
||||
- Retention default **30** days, stored `settings.workflow_log_retention_days`; `0`/negative = keep forever.
|
||||
- The agent's stream `Send` is only safe through the existing per-connection mutex-guarded `send()` closure in `connectAndHandleStream` — all `StepOutput`/`StepResult` sends MUST go through it.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Proto/pb — StepOutputChunk
|
||||
|
||||
**Files:**
|
||||
- Modify: `proto/vantage/v1/vantage.proto`
|
||||
- Modify: `server/internal/grpc/pb/vantage.pb.go`
|
||||
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pb.StepOutputChunk{CommandId string, Seq uint64, Data []byte, Eof bool}`; `pb.AgentMessage` gains `StepOutput *StepOutputChunk`.
|
||||
|
||||
- [ ] **Step 1: Document in the proto file**
|
||||
|
||||
In `proto/vantage/v1/vantage.proto`, add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;` and add the message:
|
||||
|
||||
```protobuf
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2;
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add struct + field to server pb file**
|
||||
|
||||
In `server/internal/grpc/pb/vantage.pb.go`, add to `type AgentMessage struct { ... }`:
|
||||
|
||||
```go
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
```
|
||||
|
||||
and add the new struct:
|
||||
|
||||
```go
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Mirror identical additions into the agent pb file**
|
||||
|
||||
Apply the identical `AgentMessage.StepOutput` field and `StepOutputChunk` struct to `agent/internal/grpc/pb/vantage.pb.go`.
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./...`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
|
||||
git commit -m "feat(proto): add StepOutputChunk streaming message"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Agent — stream step output
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/internal/exec/exec.go`
|
||||
- Modify: `agent/internal/sync/sync.go` (the `cmd.RunStep != nil` goroutine)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pb.RunStepCmd`, `pb.StepResult`, `pb.StepOutputChunk` (Task 1).
|
||||
- Produces: `exec.RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult` — streams output via `emit`, returns terminal result with empty stdout/stderr but populated exit_code/output_env.
|
||||
|
||||
- [ ] **Step 1: Rework `exec.RunStep` to stream**
|
||||
|
||||
In `agent/internal/exec/exec.go`, change the signature and replace the two `bytes.Buffer`s with a single mutex-guarded streaming writer. Full new body of the run/capture section (keep the existing temp-dir, env-file, interpreter-selection, timeout, and `parseEnvFile` logic exactly as-is):
|
||||
|
||||
Add this type at package scope:
|
||||
|
||||
```go
|
||||
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
|
||||
// Stdout and Stderr so output interleaves in real execution order. The mutex
|
||||
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
emit func(seq uint64, data []byte)
|
||||
}
|
||||
|
||||
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.emit != nil {
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
w.emit(w.seq, buf)
|
||||
w.seq++
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
```
|
||||
|
||||
Add `"sync"` to the imports. Change the signature to:
|
||||
|
||||
```go
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
```
|
||||
|
||||
Replace the block that currently declares `var stdout, stderr bytes.Buffer`, assigns `c.Stdout`/`c.Stderr`, and sets `res.Stdout`/`res.Stderr` from them, with:
|
||||
|
||||
```go
|
||||
sw := &streamWriter{emit: emit}
|
||||
c.Stdout = sw
|
||||
c.Stderr = sw
|
||||
runErr := c.Run()
|
||||
|
||||
// stdout/stderr are streamed via emit, not returned in the result.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = 124
|
||||
res.Stderr = "[vantage] step timed out"
|
||||
} else if ee, ok := runErr.(*exec.ExitError); ok {
|
||||
res.ExitCode = ee.ExitCode()
|
||||
} else if runErr != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "[vantage] " + runErr.Error()
|
||||
}
|
||||
|
||||
res.OutputEnv = parseEnvFile(envFile)
|
||||
return res
|
||||
```
|
||||
|
||||
Remove the now-unused `"bytes"` and `"bufio"` imports **only if** they are no longer referenced (`parseEnvFile` uses `bufio` + `os` — keep `bufio`; `bytes` is likely now unused — remove it if so). Verify with `go build`.
|
||||
|
||||
- [ ] **Step 2: Wire streaming into the agent loop**
|
||||
|
||||
In `agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine currently calls `agentexec.RunStep(rc)` and sends one `StepResult` via `send()`. Change it to pass an `emit` closure that streams chunks, then send an eof chunk, then the terminal result — all through the existing mutex-guarded `send()`:
|
||||
|
||||
```go
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data},
|
||||
})
|
||||
}
|
||||
res := agentexec.RunStep(rc, emit)
|
||||
res.CommandId = cid
|
||||
// Final eof marker so the server closes the log file.
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true},
|
||||
})
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepResult: res,
|
||||
})
|
||||
}(cmd.RunStep, cmd.CommandId)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
(Match the exact field names already used by the existing `send()` calls in this function — `cfg.ServerID`, `cfg.AgentToken`, and the `send` closure. If the existing RunStep branch used different local names, keep those.)
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success. Resolve any leftover unused-import error from Step 1.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/exec/exec.go agent/internal/sync/sync.go
|
||||
git commit -m "feat(agent): stream step output chunks over CommandStream"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Server log-writer registry + retention sweeper
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/steplogs.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `settings` service (retention), `db.Col("workflow_runs")` (sweeper), env `VANTAGE_WORKFLOW_LOG_DIR`.
|
||||
- Produces:
|
||||
- `WorkflowLogDir() string` — resolved base dir (env or default), created on first call.
|
||||
- `ServerRunLogPath(runID, serverID string) string` — `<logdir>/<runID>/<serverID>.log`.
|
||||
- `AppendMarker(runID, serverID, line string) (int64, error)` — appends a marker line, returns the byte offset **before** the write (the step's `log_offset`).
|
||||
- `var StepLogs *stepLogRegistry` with `Open(commandID, path string, secrets []string) error`, `Append(commandID string, data []byte)`, `Close(commandID string)`.
|
||||
- `StartLogSweeper()` — launches the hourly retention goroutine; also sweeps once immediately.
|
||||
|
||||
- [ ] **Step 1: Write the registry, paths, and sweeper**
|
||||
|
||||
```go
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
|
||||
func WorkflowLogDir() string {
|
||||
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-logs")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ServerRunLogPath is the per-server-run log file path.
|
||||
func ServerRunLogPath(runID, serverID string) string {
|
||||
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
|
||||
}
|
||||
|
||||
// AppendMarker appends a line to the server-run log and returns the byte offset
|
||||
// at which the write began (used as a step's log_offset).
|
||||
func AppendMarker(runID, serverID, line string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2) // current end = offset before write
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
}
|
||||
|
||||
// ---- streamed chunk writer, boundary-safe secret masking ----
|
||||
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte
|
||||
secrets []string
|
||||
maxSecret int
|
||||
}
|
||||
|
||||
type stepLogRegistry struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*stepLogWriter
|
||||
}
|
||||
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
// Open opens (append) the server-run file for a step's streamed chunks.
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
max := 0
|
||||
for _, s := range secrets {
|
||||
if len(s) > max {
|
||||
max = len(s)
|
||||
}
|
||||
}
|
||||
w := &stepLogWriter{f: f, secrets: secrets, maxSecret: max}
|
||||
r.mu.Lock()
|
||||
r.writers[commandID] = w
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.writers[commandID]
|
||||
}
|
||||
|
||||
// Append masks and writes a chunk, holding back the last maxSecret-1 bytes so a
|
||||
// secret split across a chunk boundary is still masked on the next append/close.
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w := r.get(commandID)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.secrets) == 0 || w.maxSecret <= 1 {
|
||||
_, _ = w.f.Write(data)
|
||||
return
|
||||
}
|
||||
buf := append(w.carry, data...)
|
||||
hold := w.maxSecret - 1
|
||||
if len(buf) <= hold {
|
||||
w.carry = buf
|
||||
return
|
||||
}
|
||||
flush := buf[:len(buf)-hold]
|
||||
w.carry = append([]byte{}, buf[len(buf)-hold:]...)
|
||||
_, _ = w.f.Write(maskBytes(flush, w.secrets))
|
||||
}
|
||||
|
||||
// Close flushes the carry (masked) and closes the file.
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
r.mu.Lock()
|
||||
w := r.writers[commandID]
|
||||
delete(r.writers, commandID)
|
||||
r.mu.Unlock()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.carry) > 0 {
|
||||
_, _ = w.f.Write(maskBytes(w.carry, w.secrets))
|
||||
w.carry = nil
|
||||
}
|
||||
_ = w.f.Close()
|
||||
}
|
||||
|
||||
func maskBytes(b []byte, secrets []string) []byte {
|
||||
s := string(b)
|
||||
for _, v := range secrets {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
s = strings.ReplaceAll(s, v, "***")
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// ---- retention sweeper ----
|
||||
|
||||
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
|
||||
func StartLogSweeper() {
|
||||
go func() {
|
||||
sweepLogs()
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepLogs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sweepLogs() {
|
||||
days := retentionDays()
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
base := WorkflowLogDir()
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
runID := e.Name()
|
||||
dir := filepath.Join(base, runID)
|
||||
if runExpired(runID, dir, cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runExpired is true when the run finished before cutoff (falling back to dir
|
||||
// mtime when the run doc is gone).
|
||||
func runExpired(runID, dir string, cutoff time.Time) bool {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var run struct {
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
}
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
|
||||
if err == nil {
|
||||
if run.FinishedAt == nil {
|
||||
return false // still running / never finished — keep
|
||||
}
|
||||
return run.FinishedAt.Before(cutoff)
|
||||
}
|
||||
// run doc gone: use dir mtime
|
||||
if fi, e := os.Stat(dir); e == nil {
|
||||
return fi.ModTime().Before(cutoff)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func retentionDays() int {
|
||||
if v, err := GetWorkflowLogRetentionDays(); err == nil {
|
||||
return v
|
||||
}
|
||||
return 30
|
||||
}
|
||||
```
|
||||
|
||||
Note: `wfCtx` is defined in `workflows.go` (same package) — reuse it. `GetWorkflowLogRetentionDays` is added in Task 4; this file references it (same package, compiles together).
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: FAIL — `GetWorkflowLogRetentionDays` undefined until Task 4. This is expected; proceed to commit the file so Task 4 completes it. (If you prefer a green build, do Task 4's settings accessor first, then return — but committing here is fine since Task 4 immediately follows.)
|
||||
|
||||
Actually to keep every commit buildable: **temporarily** add a local stub at the bottom of this file and remove it in Task 4:
|
||||
|
||||
```go
|
||||
// TEMP stub, replaced in Task 4.
|
||||
func GetWorkflowLogRetentionDays() (int, error) { return 30, nil }
|
||||
```
|
||||
|
||||
Then `cd server && go build ./... && go vet ./...` must succeed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/steplogs.go
|
||||
git commit -m "feat(server): workflow log-writer registry, paths, retention sweeper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Settings — retention accessor + startup wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/services/settings.go` (or wherever settings get/set lives — search `settings` collection usage)
|
||||
- Modify: `server/internal/services/steplogs.go` (remove the temp stub)
|
||||
- Modify: `server/cmd/main.go` (start the sweeper)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GetWorkflowLogRetentionDays() (int, error)` (default 30 when unset), `SetWorkflowLogRetentionDays(int) error`. If settings are exposed as a single document/struct, add the field there and derive these accessors.
|
||||
|
||||
- [ ] **Step 1: Inspect the settings service**
|
||||
|
||||
Read the existing settings service (search for the `settings` collection: `grep -rn "\"settings\"" server/internal/services`). Determine whether settings are a typed struct document or key/value. Match that pattern.
|
||||
|
||||
- [ ] **Step 2: Add the retention accessor**
|
||||
|
||||
If settings are a **typed document** (e.g. a `GetSettings()/UpdateSettings()`), add a field `WorkflowLogRetentionDays int `bson:"workflow_log_retention_days" json:"workflow_log_retention_days"`` to the settings struct and implement:
|
||||
|
||||
```go
|
||||
func GetWorkflowLogRetentionDays() (int, error) {
|
||||
s, err := GetSettings() // use the real accessor name
|
||||
if err != nil {
|
||||
return 30, err
|
||||
}
|
||||
if s.WorkflowLogRetentionDays == 0 && /* unset sentinel */ !s.WorkflowLogRetentionSet {
|
||||
return 30, nil
|
||||
}
|
||||
return s.WorkflowLogRetentionDays, nil
|
||||
}
|
||||
```
|
||||
|
||||
Simplify to match reality: if the settings doc uses zero-value-means-unset and you cannot distinguish "0 = keep forever" from "unset", store the retention as a pointer `*int` or default at read: **treat a missing field as 30, an explicit 0 as keep-forever.** Prefer `*int` in the struct so the three states (unset→30, 0→forever, N→N) are representable. Implement `GetWorkflowLogRetentionDays` to return 30 when the pointer is nil, else its value. `SetWorkflowLogRetentionDays(n int)` sets the pointer.
|
||||
|
||||
If settings are **key/value**, implement both accessors against that store with the same nil→30 / 0→forever semantics (store empty/absent = 30).
|
||||
|
||||
- [ ] **Step 3: Remove the temp stub from `steplogs.go`**
|
||||
|
||||
Delete the `// TEMP stub` `GetWorkflowLogRetentionDays` added in Task 3 so the real one is used.
|
||||
|
||||
- [ ] **Step 4: Start the sweeper at boot**
|
||||
|
||||
In `server/cmd/main.go`, next to `EnsureWorkflowIndexes()`, add `services.StartLogSweeper()`.
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success (real accessor now resolves the reference from Task 3).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/settings.go server/internal/services/steplogs.go server/cmd/main.go
|
||||
git commit -m "feat(server): workflow log retention setting + sweeper startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Runner + model — write to files, drop log bodies from Mongo
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/workflow.go` (`StepRun`)
|
||||
- Modify: `server/internal/services/workflow_runner.go`
|
||||
- Modify: `server/internal/grpc/server.go` (stream delivery of `StepOutput`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StepLogs`, `AppendMarker`, `ServerRunLogPath` (Task 3), `pb.StepOutputChunk` (Task 1).
|
||||
- Produces: runner writes markers + streams chunks to files; `StepRun.LogOffset` persisted; `StepRun.Stdout/Stderr` removed.
|
||||
|
||||
- [ ] **Step 1: Update the `StepRun` model**
|
||||
|
||||
In `server/internal/models/workflow.go`, in `type StepRun struct`:
|
||||
- Remove the `Stdout` and `Stderr` fields.
|
||||
- Add: `LogOffset int64 `bson:"log_offset" json:"log_offset"``
|
||||
|
||||
- [ ] **Step 2: Deliver StepOutput chunks in the gRPC receive loop**
|
||||
|
||||
In `server/internal/grpc/server.go`, after the existing `if m.StepResult != nil { services.StepResults.Deliver(m.StepResult) }` block, add:
|
||||
|
||||
```go
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rework `runServer` to open logs + write markers, drop persisted bodies**
|
||||
|
||||
In `server/internal/services/workflow_runner.go`, `runServer`:
|
||||
|
||||
Inside the per-step loop, **before** `dispatchAndWait`, add marker + open (compute `secretVals` first, which already exists in the loop):
|
||||
|
||||
```go
|
||||
// Write the step marker and remember the offset for later slicing.
|
||||
marker := fmt.Sprintf("\n===== step %d: %s =====\n", step.Order, step.Name)
|
||||
offset, _ := AppendMarker(runID, serverID, marker)
|
||||
logPath := ServerRunLogPath(runID, serverID)
|
||||
_ = StepLogs.Open(commandID_placeholder, logPath, secretsSlice(secretVals))
|
||||
```
|
||||
|
||||
There is a chicken-and-egg with `commandID`: today `dispatchAndWait` generates the `commandID` internally. Refactor so the runner owns the `commandID`:
|
||||
|
||||
1. Change `dispatchAndWait(serverID string, cmd *pb.RunStepCmd)` to `dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd)` and remove its internal `commandID := uuid.New().String()` (use the passed one).
|
||||
2. In `runServer`, generate `commandID := uuid.New().String()` at the top of each attempt-group (before the marker/open), open the log with it, then call `dispatchAndWait(serverID, commandID, cmd)`.
|
||||
3. After the step completes (result received), call `StepLogs.Close(commandID)` defensively (idempotent — the agent's eof usually closed it already; Close on a missing key is a no-op).
|
||||
|
||||
Add a helper to convert the `secretVals map[string]string` to a `[]string` of values:
|
||||
|
||||
```go
|
||||
func secretsSlice(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Update `finishStep(...)` call + signature: **remove** the `stdout, stderr string` params and the `output_env` masking stays. Persist `log_offset` instead. New `finishStep`:
|
||||
|
||||
```go
|
||||
func finishStep(runID, serverID string, order int, status string, attempts, exit int, logOffset int64, outEnv map[string]string) {
|
||||
now := time.Now()
|
||||
updateStep(runID, serverID, order, bson.M{
|
||||
"server_runs.$[s].steps.$[t].status": status,
|
||||
"server_runs.$[s].steps.$[t].attempts": attempts,
|
||||
"server_runs.$[s].steps.$[t].exit_code": exit,
|
||||
"server_runs.$[s].steps.$[t].log_offset": logOffset,
|
||||
"server_runs.$[s].steps.$[t].output_env": outEnv,
|
||||
"server_runs.$[s].steps.$[t].finished_at": now,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
In the loop, after receiving `res`, drop the `stdout, stderr := ...` masking of `res.Stdout/res.Stderr` (those are now streamed to file). Keep the `outEnv` build **with existing masking** (`maskSecrets(v, allSecrets)` per the merged secret-leak fix) — `output_env`/`run_env` masking is unchanged. Call:
|
||||
|
||||
```go
|
||||
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
|
||||
```
|
||||
|
||||
where `offset` is the marker offset captured before dispatch. If `res == nil`, still write a short note to the file so failures are visible:
|
||||
|
||||
```go
|
||||
if res == nil {
|
||||
_, _ = AppendMarker(runID, serverID, "[vantage] agent did not return a result\n")
|
||||
}
|
||||
```
|
||||
|
||||
Remove the initial `StepRun{... Status:"queued"}` `Stdout/Stderr` references if any (the model no longer has them — the queued StepRun in `TriggerWorkflow` set only `Order/Name/Status/OutputEnv`, so no change needed there; verify).
|
||||
|
||||
Ensure `fmt` is imported (it already is).
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success. Fix any remaining references to the removed `Stdout`/`Stderr` fields or the old `finishStep`/`dispatchAndWait` signatures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/workflow.go server/internal/services/workflow_runner.go server/internal/grpc/server.go
|
||||
git commit -m "feat(server): stream step logs to files, drop log bodies from run docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: REST — log fetch + SSE stream endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/api/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ServerRunLogPath`, `GetRun` (existing).
|
||||
- Produces: `GET /api/runs/:runId/servers/:serverId/logs` and `GET /api/runs/:runId/servers/:serverId/logs/stream` (SSE).
|
||||
|
||||
- [ ] **Step 1: Add the two handlers + routes**
|
||||
|
||||
In `registerWorkflowRoutes`, add:
|
||||
|
||||
```go
|
||||
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
|
||||
```
|
||||
|
||||
Add a UUID-ish validator and the handlers:
|
||||
|
||||
```go
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
|
||||
}
|
||||
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
|
||||
return
|
||||
}
|
||||
|
||||
var offset int64
|
||||
sendNew := func() bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return true // file may not exist yet; keep waiting
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return true
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, _ := f.Read(buf)
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
// SSE data frame; split on newlines to keep frames well-formed.
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(runID, serverID) {
|
||||
sendNew() // final drain
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverRunTerminal reports whether the given server-run has reached a terminal status.
|
||||
func serverRunTerminal(runID, serverID string) bool {
|
||||
r, err := services.GetRun(runID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, sr := range r.ServerRuns {
|
||||
if sr.ServerID == serverID {
|
||||
switch sr.Status {
|
||||
case "success", "failed", "skipped", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
|
||||
// separate data lines; carriage returns stripped).
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
```
|
||||
|
||||
Add imports: `"os"`, `"regexp"`, `"strings"`, `"time"`, `"net/http"` (already present). Confirm `services.GetRun` and `ServerRun.Status`/`ServerID` fields exist (they do from the Workflows feature).
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/api/workflows.go
|
||||
git commit -m "feat(api): server-run log fetch and SSE stream endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — live SSE tail + retention setting
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts`
|
||||
- Modify: `web/app/workflows/[id]/runs/[runId]/page.tsx`
|
||||
- Modify: `web/app/settings/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: SSE endpoint, logs endpoint, settings mutation.
|
||||
|
||||
- [ ] **Step 1: Update API types + helpers**
|
||||
|
||||
In `web/lib/api.ts`:
|
||||
- In `StepRun`, remove `stdout` and `stderr`; add `log_offset: number`.
|
||||
- Add: `getServerRunLog: (runId: string, serverId: string) => request<string>(...)` — but the logs endpoint returns `text/plain`, so add a dedicated fetch that reads text. If `request<T>` assumes JSON, add a sibling:
|
||||
|
||||
```ts
|
||||
async getServerRunLog(runId: string, serverId: string): Promise<string> {
|
||||
const res = await fetch(`${API_BASE}/api/runs/${runId}/servers/${serverId}/logs`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("no logs");
|
||||
return res.text();
|
||||
},
|
||||
```
|
||||
|
||||
(Use the file's real base-URL constant / credentials pattern — inspect how `request` builds URLs and mirror it. If the app is same-origin with a rewrite, a relative `/api/...` fetch is fine.)
|
||||
- Export a helper to build the SSE URL: `serverRunLogStreamUrl(runId, serverId)` returning the `/api/runs/:runId/servers/:serverId/logs/stream` URL against the same base.
|
||||
- In the Settings type, add `workflow_log_retention_days?: number | null`.
|
||||
|
||||
- [ ] **Step 2: Live tail in the run detail page**
|
||||
|
||||
In `web/app/workflows/[id]/runs/[runId]/page.tsx`:
|
||||
- Remove all use of `st.stdout` / `st.stderr` (fields gone). Step `<details>` now show status/exit/attempts pills only.
|
||||
- Add a per-server live terminal. For each `server_run`, render a `<pre>` and, while `sr.status === "running"`, subscribe via `EventSource`:
|
||||
|
||||
```tsx
|
||||
function ServerLog({ runId, serverId, status }: { runId: string; serverId: string; status: string }) {
|
||||
const [text, setText] = useState("");
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const running = status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), { withCredentials: true });
|
||||
es.onmessage = (e) => setText((t) => t + e.data + "\n");
|
||||
es.addEventListener("done", () => es.close());
|
||||
es.onerror = () => es.close();
|
||||
return () => es.close();
|
||||
}
|
||||
// terminal: fetch the whole file once
|
||||
api.getServerRunLog(runId, serverId).then(setText).catch(() => setText(""));
|
||||
}, [running, runId, serverId]);
|
||||
|
||||
useEffect(() => { preRef.current?.scrollTo(0, preRef.current.scrollHeight); }, [text]);
|
||||
|
||||
return (
|
||||
<pre ref={preRef} className="mt-2 max-h-80 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary whitespace-pre-wrap">
|
||||
{text || (running ? "Waiting for output…" : "No output.")}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Render `<ServerLog runId={run.run_id} serverId={sr.server_id} status={sr.status} />` inside each server card, below the step pills. Keep the existing react-query `refetchInterval` on the run (drives status pills); the SSE handles live text.
|
||||
|
||||
- [ ] **Step 3: Retention field in Settings**
|
||||
|
||||
In `web/app/settings/page.tsx`, add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved through the existing settings save mutation. Add helper text: "0 = keep forever." Match the page's existing input styling.
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: type-checks and builds. Fix any lingering `st.stdout`/`st.stderr` references.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api.ts web/app/workflows/[id]/runs/[runId]/page.tsx web/app/settings/page.tsx
|
||||
git commit -m "feat(web): live SSE log tail and log retention setting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build everything**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./... && cd ../agent && go build ./... && go vet ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
|
||||
|
||||
With server + MongoDB + a connected agent:
|
||||
1. Run a workflow with a step that emits output slowly (e.g. `for i in $(seq 1 10); do echo "line $i"; sleep 1; done`). Open the run detail page while running; confirm lines appear live (SSE), not only at the end.
|
||||
2. Confirm `<logdir>/<run_id>/<server_id>.log` exists on the server with step markers and the output.
|
||||
3. Confirm `workflow_runs` doc no longer stores stdout/stderr bodies; `steps[].log_offset` is set.
|
||||
4. Add a secret ref and echo it; confirm the file shows `***`, including when the secret would straddle a chunk boundary.
|
||||
5. Set retention to 0 in Settings → confirm sweeper keeps files; set to a small value and backdate a run's `finished_at` → confirm the dir is removed within the hour (or call `sweepLogs` path manually).
|
||||
|
||||
- [ ] **Step 3: Commit any fixes found**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: workflow log streaming e2e fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 proto → T1; §4 agent streaming → T2; §5.1 registry + §7 sweeper → T3; §7.1 setting + startup → T4; §5.3/§5.4 runner+model → T5; §6 REST/SSE → T6; §8 frontend → T7. Tests omitted per Global Constraints.
|
||||
- **Masking** boundary-safe carry buffer in `StepLogs.Append`, flushed in `Close` (T3); `output_env`/`run_env` masking unchanged (T5 keeps the merged fix).
|
||||
- **commandID ownership** moved to the runner so the log file can be opened before dispatch (T5) — mirrors the `StepResults.Await`-before-dispatch ordering.
|
||||
- **Buildable commits:** T3 adds a temp stub for `GetWorkflowLogRetentionDays`, removed in T4.
|
||||
- **Removed fields** `StepRun.Stdout/Stderr` — every reader updated in T5 (runner) and T7 (frontend).
|
||||
- **Open follow-ups (out of scope):** per-step SSE channels, log download/zip, compression, pre-existing runs have no files.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Fleet Inventory — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences:
|
||||
|
||||
- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm).
|
||||
- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**.
|
||||
|
||||
Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Transport | New `ReportInventory` unary RPC. |
|
||||
| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). |
|
||||
| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. |
|
||||
| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. |
|
||||
| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`):
|
||||
|
||||
```go
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add `Inventory *Inventory` field to `Server`.
|
||||
|
||||
Server-side update rules:
|
||||
- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`.
|
||||
- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`.
|
||||
|
||||
---
|
||||
|
||||
## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
|
||||
|
||||
```protobuf
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
|
||||
message InventoryReport {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
bool include_static = 3; // true on the 15-min tick
|
||||
CPUReport cpu = 4;
|
||||
MemReport memory = 5;
|
||||
uint64 swap_total = 6;
|
||||
uint64 swap_used = 7;
|
||||
repeated PartitionReport partitions = 8; // only when include_static
|
||||
string kernel = 9; // only when include_static
|
||||
}
|
||||
message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; }
|
||||
message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; }
|
||||
message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; }
|
||||
message InventoryReportResponse {}
|
||||
```
|
||||
|
||||
Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent collection (`agent/internal/inventory/`)
|
||||
|
||||
- `Collect(includeStatic bool) *pb.InventoryReport` — reads:
|
||||
- CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static).
|
||||
- Memory/swap: `/proc/meminfo`.
|
||||
- Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static).
|
||||
- Kernel: `uname` / `/proc/version` (static).
|
||||
- Windows: best-effort via `wmic`/PS or leave empty.
|
||||
- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`.
|
||||
- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`.
|
||||
|
||||
Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored.
|
||||
|
||||
---
|
||||
|
||||
## 6. Server handler + service
|
||||
|
||||
- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`.
|
||||
- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend
|
||||
|
||||
Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel:
|
||||
- CPU usage gauge + model/cores, load.
|
||||
- RAM used/total bar, swap bar.
|
||||
- Partitions table: device, mount, fstype, used/total with a usage bar.
|
||||
- "Updated Xs ago" from `metrics_at`/`static_at`.
|
||||
|
||||
Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Time-series history / graphs (only latest snapshot stored).
|
||||
- Alerting thresholds on usage (settings/alerts is a separate concern).
|
||||
- Per-process / network / GPU inventory.
|
||||
- Tests (skipped, consistent with the Workflows iteration).
|
||||
@@ -0,0 +1,142 @@
|
||||
# SaaS: Auth + Organizations — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
|
||||
|
||||
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
|
||||
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
|
||||
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
|
||||
|
||||
No billing, no seat/server limits this iteration (schema leaves room).
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
|
||||
| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
|
||||
| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
|
||||
| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
|
||||
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
|
||||
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
|
||||
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### `orgs`
|
||||
```json
|
||||
{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
|
||||
```
|
||||
|
||||
### `users`
|
||||
```json
|
||||
{
|
||||
"_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
|
||||
"email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
|
||||
"auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
|
||||
}
|
||||
```
|
||||
Unique index on `email` (global — email identifies the account and its org).
|
||||
|
||||
### `org_oidc` (per-org provider config)
|
||||
```json
|
||||
{
|
||||
"_id":"ObjectId", "org_id":"uuid",
|
||||
"issuer":"https://id.acme.com", "client_id":"...",
|
||||
"client_secret_enc":"AES...", // encrypted with existing crypto.go
|
||||
"redirect_url":"https://vantage.../auth/oidc/callback",
|
||||
"enabled": true, "updated_at":"ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
### Existing collections — add `org_id`
|
||||
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 4. Auth flows
|
||||
|
||||
### Local
|
||||
- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
|
||||
- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
|
||||
- `POST /auth/logout` — destroy session.
|
||||
- `GET /auth/me` — returns current user + org.
|
||||
|
||||
### Org-admin user management
|
||||
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
|
||||
|
||||
### Per-org OIDC
|
||||
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
|
||||
- `GET /auth/oidc/start?org=<org_id or slug>` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize.
|
||||
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
|
||||
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
|
||||
|
||||
### First-run bootstrap
|
||||
- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty).
|
||||
- Setup page collects org name + owner email/password → creates org + owner → session.
|
||||
|
||||
---
|
||||
|
||||
## 5. Request scoping
|
||||
|
||||
- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
|
||||
- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
|
||||
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
|
||||
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Removing global Authentik
|
||||
|
||||
- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
|
||||
- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
|
||||
- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration
|
||||
|
||||
One-shot migration run at startup (idempotent):
|
||||
1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
|
||||
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
|
||||
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
|
||||
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
|
||||
- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
|
||||
- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security
|
||||
|
||||
- Passwords: bcrypt (cost ≥ 12). Never returned.
|
||||
- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
|
||||
- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
|
||||
- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
|
||||
- Role checks on all org-admin mutations.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Billing, plans, seat/server limits.
|
||||
- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
|
||||
- SCIM / directory sync, SAML.
|
||||
- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
|
||||
- Tests (skipped, consistent with prior iterations).
|
||||
@@ -0,0 +1,238 @@
|
||||
# Server Workflows — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Server Workflows only. Fleet Inventory and SaaS/local-auth are separate sub-projects with their own specs.
|
||||
|
||||
Approved UI mockup: three-pane builder (Step Library · Canvas · Inspector), env vars shown riding the wire between nodes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Let operators compose **reusable shell steps** (Bash or PowerShell) into **workflows** and run them across many managed servers in parallel. Steps pass data to later steps through a `$WORKFLOW_ENV` file (GitHub-Actions style). Every run is recorded with full per-step logs. Steps can reference org secrets, injected as environment variables at runtime.
|
||||
|
||||
Builds directly on the existing `CommandStream` gRPC infrastructure (`dispatch.go`, `ServerCommand` oneof, agent command loop).
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Data passing | Implicit. Every step's `$WORKFLOW_ENV` outputs merge into the run's env and are exposed to **all** later steps as `$KEY`. No explicit port wiring. |
|
||||
| Failure model | Per-step policy: `stop` (default), `continue`, `retry` (with max attempt count). |
|
||||
| Targets | Fan-out. Same step sequence runs on N target servers **in parallel**. Steps within one server run **sequentially**. |
|
||||
| History/logs | Every run persisted: status, timing, per-server per-step stdout/stderr/exit code, captured output env. |
|
||||
| Secrets | Steps declare needed secret keys; resolved from existing `secrets` store and injected as env vars at exec time. Never persisted into run logs. |
|
||||
| Testing | **Skipped** for this iteration per request. No test files written. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model (MongoDB)
|
||||
|
||||
### `workflow_steps` — reusable step library
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"step_id": "uuid",
|
||||
"name": "Restart service",
|
||||
"description": "Restart-Service by name, wait ready",
|
||||
"interpreter": "bash | powershell",
|
||||
"script": "Restart-Service vantage-api\n...",
|
||||
"declared_outputs": ["STARTED_AT"], // documentation/UI hints; not enforced
|
||||
"secret_refs": ["DEPLOY_TOKEN"], // secret keys this step needs injected
|
||||
"org_id": "uuid", // for future multi-tenant; single-org for now
|
||||
"created_at": "ISODate",
|
||||
"updated_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
### `workflows` — ordered composition
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"workflow_id": "uuid",
|
||||
"name": "Deploy & Restart API",
|
||||
"target_server_ids": ["uuid", "uuid"],
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "uuid", // reference to library step
|
||||
"order": 0,
|
||||
"on_failure": "stop | continue | retry",
|
||||
"max_retries": 0, // used when on_failure = retry
|
||||
"overrides": { // optional local fork of the library step
|
||||
"script": null,
|
||||
"secret_refs": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "ISODate",
|
||||
"updated_at": "ISODate"
|
||||
}
|
||||
```
|
||||
Editing a library step from the Inspector writes an `overrides` block on that workflow step (a local fork) rather than mutating the shared step.
|
||||
|
||||
### `workflow_runs` — execution records
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"run_id": "uuid",
|
||||
"workflow_id": "uuid",
|
||||
"workflow_snapshot": { }, // frozen copy of workflow + resolved steps at trigger time
|
||||
"status": "running | success | failed | cancelled",
|
||||
"triggered_by": "user-id",
|
||||
"started_at": "ISODate",
|
||||
"finished_at": "ISODate | null",
|
||||
"server_runs": [
|
||||
{
|
||||
"server_id": "uuid",
|
||||
"status": "queued | running | success | failed | skipped",
|
||||
"started_at": "ISODate | null",
|
||||
"finished_at": "ISODate | null",
|
||||
"run_env": { "VERSION": "a1b9f0" }, // accumulated non-secret output env
|
||||
"steps": [
|
||||
{
|
||||
"order": 0,
|
||||
"name": "Git pull & build",
|
||||
"status": "success | failed | running | queued | skipped",
|
||||
"attempts": 1,
|
||||
"exit_code": 0,
|
||||
"stdout": "…",
|
||||
"stderr": "…",
|
||||
"output_env": { "VERSION": "a1b9f0" },
|
||||
"started_at": "ISODate",
|
||||
"finished_at": "ISODate"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Secret values are never written to `stdout`/`stderr`/`run_env` by us; masking of known secret values in captured output is applied before persistence.
|
||||
|
||||
---
|
||||
|
||||
## 4. gRPC protocol changes (`proto/vantage/v1/vantage.proto`)
|
||||
|
||||
### New command in the `ServerCommand` oneof
|
||||
```protobuf
|
||||
message RunStepCmd {
|
||||
string interpreter = 1; // "bash" | "powershell"
|
||||
string script = 2;
|
||||
map<string, string> env = 3; // inputs = accumulated run env + injected secrets
|
||||
int32 timeout_seconds = 4;
|
||||
}
|
||||
```
|
||||
Add `RunStepCmd run_step = 6;` to the `ServerCommand` oneof.
|
||||
|
||||
### Richer result — new `AgentMessage` payload
|
||||
Current `CommandResult{command_id, success, message}` is too thin. Add a dedicated step result:
|
||||
```protobuf
|
||||
message StepResult {
|
||||
string command_id = 1;
|
||||
int32 exit_code = 2;
|
||||
string stdout = 3;
|
||||
string stderr = 4;
|
||||
map<string, string> output_env = 5; // parsed $WORKFLOW_ENV KEY=value lines
|
||||
}
|
||||
```
|
||||
Add `StepResult step_result = 5;` to the `AgentMessage` oneof (alongside existing `ready` / `result`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent execution (`agent/internal/...`)
|
||||
|
||||
New handler for `RunStepCmd` in the agent command loop:
|
||||
|
||||
1. Create a temp dir; create empty `WORKFLOW_ENV` file inside it.
|
||||
2. Write `script` to a temp script file.
|
||||
3. Build the process environment: inherited env + `cmd.env` (run env + secrets) + `WORKFLOW_ENV=<path to env file>`.
|
||||
4. Execute:
|
||||
- `bash` → `bash <script>`
|
||||
- `powershell` → `pwsh -NoProfile -File <script>` (fallback `powershell.exe` on Windows if `pwsh` absent).
|
||||
5. Capture stdout, stderr, exit code. Enforce `timeout_seconds` (kill on exceed → non-zero exit, stderr note).
|
||||
6. Parse the `WORKFLOW_ENV` file: each `KEY=value` line becomes an `output_env` entry (last write wins; supports multi-line via simple `KEY<<EOF` heredoc form, optional for v1 — start with single-line `KEY=value`).
|
||||
7. Reply with `StepResult`. Delete temp dir.
|
||||
|
||||
Agent runs as root (existing), so no privilege change. Script content is trusted operator input.
|
||||
|
||||
---
|
||||
|
||||
## 6. Server orchestration (`server/internal/services/workflows.go`)
|
||||
|
||||
Runner responsibilities:
|
||||
|
||||
1. On trigger: snapshot the workflow (resolve each library step + overrides), create a `workflow_runs` doc with one `server_run` per target, all `queued`.
|
||||
2. Spawn one goroutine **per target server** (parallel fan-out). Each goroutine:
|
||||
- Verifies the agent is connected (`Dispatcher.IsConnected`); if not → `server_run.status = skipped`, reason recorded.
|
||||
- Maintains a `run_env map[string]string`, seeded empty.
|
||||
- For each step in order:
|
||||
- Resolve `secret_refs` from the secrets service → merge into the command env (kept separate from persisted `run_env`).
|
||||
- Dispatch `RunStepCmd{env: run_env + secrets}` via a **correlated** send — needs a way to await the matching `StepResult` by `command_id` (see §7).
|
||||
- On result: persist step record (stdout/stderr/exit, masked); merge `output_env` into `run_env`.
|
||||
- Apply `on_failure` on non-zero exit: `stop` (fail server_run, break), `continue` (mark failed, proceed), `retry` (re-dispatch up to `max_retries`).
|
||||
3. Aggregate: run `status = success` if all server_runs succeeded, else `failed`. Set `finished_at`.
|
||||
|
||||
### Concurrency / queue
|
||||
- One workflow run per workflow at a time (reject or queue concurrent triggers — v1: reject with clear error).
|
||||
- Per-server step dispatch is serial; servers are parallel.
|
||||
|
||||
---
|
||||
|
||||
## 7. Correlated command results
|
||||
|
||||
The existing dispatcher is fire-and-forget; workflows need request/response by `command_id`. Add a small **pending-result registry** alongside `Dispatcher`:
|
||||
|
||||
- `AwaitResult(commandID) <-chan *pb.StepResult` — registers a channel before dispatch.
|
||||
- The `CommandStream` receive loop, on a `StepResult`, looks up the pending channel by `command_id` and delivers it (falls back to existing `CommandResult` handling for other command types).
|
||||
- Timeout guard on the server side (step `timeout_seconds` + grace) so a dead agent can't hang a run.
|
||||
|
||||
This is additive; existing `CommandResult` flow for key/update commands is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 8. REST API (`server/internal/api/workflows.go`)
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---------------|---------|
|
||||
| `GET /api/steps` / `POST` / `PUT /:id` / `DELETE /:id` | Reusable step library CRUD |
|
||||
| `GET /api/workflows` / `POST` / `PUT /:id` / `DELETE /:id` | Workflow CRUD (name, targets, ordered steps) |
|
||||
| `POST /api/workflows/:id/run` | Trigger a run; returns `run_id` |
|
||||
| `GET /api/workflows/:id/runs` | Run history (summary list) |
|
||||
| `GET /api/runs/:run_id` | Full run detail incl. per-server per-step logs |
|
||||
| `POST /api/runs/:run_id/cancel` | Best-effort cancel |
|
||||
|
||||
Secrets are referenced by key only through these APIs; values never returned.
|
||||
|
||||
---
|
||||
|
||||
## 9. Frontend (`web/app/workflows/`)
|
||||
|
||||
- `/workflows` — list workflows, last run status/time, Run button.
|
||||
- `/workflows/[id]` — the three-pane builder from the approved mockup:
|
||||
- **Library** (left): reusable steps, `bash`/`pwsh` badges, search, add.
|
||||
- **Canvas** (center): ordered nodes, env chips on wires, live status pills.
|
||||
- **Inspector** (right): name, command editor, declared inputs/outputs, `secret_refs` picker, `on_failure` + retry count.
|
||||
- `/workflows/[id]/runs/[runId]` — run detail: per-server columns, expandable per-step stdout/stderr, exit codes, timing. Live-updating while `running` (poll, consistent with existing 30s-poll ethos — or reuse whatever the console screen uses).
|
||||
|
||||
Reuse existing web components/styling patterns (there is already `servers`, `secrets`, `audit`, console UI to match).
|
||||
|
||||
---
|
||||
|
||||
## 10. Security notes
|
||||
|
||||
- Scripts are trusted operator input executed as root — same trust level as the existing console feature. No new sandbox in v1.
|
||||
- Secret values injected as env only; masked from all persisted logs (`stdout`/`stderr`/`run_env`) by literal replacement before write.
|
||||
- Run triggering and step/workflow CRUD gated behind existing auth (`server/internal/auth`).
|
||||
- Audit: emit audit-log entries (existing `audit` service) on workflow create/edit/delete and run trigger.
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (this iteration)
|
||||
|
||||
- Tests (explicitly skipped).
|
||||
- Branching/conditional steps, matrix per-server conditionals (fan-out only).
|
||||
- Scheduled/cron triggers (manual run only for v1).
|
||||
- Multi-org isolation enforcement (schema carries `org_id` for later; single-org behavior now).
|
||||
- Artifact upload/collection beyond env vars.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Workflow Builder v2 — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Overhaul the workflow builder UI to match the approved mockup, add drag-and-drop (library→canvas + reorder), base-step editing/deletion with cascade, step input parameters, an Edit-Workflow modal, runs navigation, and fix the save crash. Enhancement to the merged Server Workflows feature. Independent of the in-flight log-streaming work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The shipped builder diverges from the approved mockup and is missing interactions. This iteration:
|
||||
|
||||
1. **Restyle** the builder (`/workflows/[id]`) to the approved mockup: dotted-grid canvas, 340px node cards with index badge + shell badge + status, wire connectors with dashed-amber "passes" env chips, a kicker/field inspector, and a library of grabbable step cards with descriptions.
|
||||
2. **Drag-and-drop**: drag a library step onto the canvas to add it; drag nodes to reorder. Remove the up/down/remove buttons.
|
||||
3. **Base-step editing**: an "Edit base step" modal edits the shared library step (name/interpreter/script/outputs/inputs/secret refs) and **saves**; deleting a shared step **cascades** — it is pulled from every workflow that references it.
|
||||
4. **Input parameters**: a base step can declare inputs (`name` + `default` + `description`); when placed, each placement sets values; the runner injects them into the step's environment.
|
||||
5. **Env visibility**: show the output variables passed between steps as chips on the wires (already partially present — align to the mockup).
|
||||
6. **Edit-Workflow modal**: edit name, target servers, delete the workflow, and other workflow settings.
|
||||
7. **Runs navigation**: a runs list page per workflow, linked from the builder and the workflows list.
|
||||
8. **Bug fix**: `updateWorkflow` returns `{updated:true}`, which the builder stores as the workflow and then crashes on `[...wf.steps]` ("d.steps is not iterable"). Fix the endpoint to return the updated workflow and harden the client.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Visual target | The approved mockup (artifact `61ab5256`). Adopt its layout + **amber (`#f5a524`) as the builder signal/focus color**, plus bash-green (`#3fb950`) / pwsh-blue (`#5b9bff`) badge colors. Keep the app's existing `surface`/`border`/`text-*` tokens for panels so it integrates with the dark theme. |
|
||||
| Drag-and-drop | Native HTML5 DnD. Library cards are `draggable`; the canvas has drop targets (between nodes + end zone) to insert; nodes are `draggable` to reorder. Clicking a library card still appends (keyboard/fallback). |
|
||||
| Step removal | No per-node buttons. Remove a placed step from the **inspector** ("Remove from workflow"). |
|
||||
| Input params | `WorkflowStep.declared_inputs: [{name, default, description}]`. `WorkflowStepRef.inputs: map[name]value`. Runner resolves `value = ref.inputs[name] ?? default` and injects as env vars. |
|
||||
| Edit scope | Inspector script edit = **per-placement override** (existing `overrides`, "forks a local copy"). A separate **Edit base step** modal updates the shared library step for all workflows. |
|
||||
| Cascade delete | Deleting a library step pulls its `step_id` from every `workflow.steps` and re-sequences remaining `order`s. |
|
||||
| Env chips | Names passed between steps = union of prior steps' `declared_outputs`. Shown on the wire between nodes. |
|
||||
| Edit workflow | Modal launched from the topbar: name, target-servers multiselect, delete workflow. |
|
||||
| Runs nav | New page `/workflows/[id]/runs` (list); links from the builder topbar and the workflows list page. |
|
||||
| Save fix | `PUT /api/workflows/:id` returns the full updated `Workflow`. Client also guards against non-workflow responses. |
|
||||
| Proto | **No proto change** — input params travel through the existing `RunStepCmd.Env`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model (`server/internal/models/workflow.go`)
|
||||
|
||||
Add an input-parameter type and fields:
|
||||
|
||||
```go
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
- `WorkflowStep` gains: `DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"``.
|
||||
- `WorkflowStepRef` gains: `Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`` (per-placement values).
|
||||
- `ResolvedStep` gains: `Inputs map[string]string `bson:"inputs" json:"inputs"`` (frozen resolved input env for the run).
|
||||
|
||||
`declared_inputs` defaults to `[]` on create (like `declared_outputs`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Services
|
||||
|
||||
### 4.1 Step CRUD (`server/internal/services/workflows.go`)
|
||||
|
||||
- `CreateStep`: default `DeclaredInputs` to `[]InputParam{}` when nil; persist it.
|
||||
- `UpdateStep`: add `declared_inputs` to the `$set`.
|
||||
- `DeleteStep` → **cascade**. New behavior: within the delete, also update every workflow that references the step:
|
||||
1. `DeleteOne` on `workflow_steps` by `step_id` (as today).
|
||||
2. Load all workflows containing the step (`workflows` where `steps.step_id == stepID`); for each, remove the matching `WorkflowStepRef`(s), re-sequence remaining `order` values to `0..n-1`, and `UpdateWorkflow`.
|
||||
Keep it a single service call `DeleteStep(stepID)` so the handler is unchanged. Audit both the step deletion and each affected workflow via `LogEvent`.
|
||||
|
||||
### 4.2 Workflow update returns the workflow (`server/internal/services/workflows.go` + handler)
|
||||
|
||||
- `UpdateWorkflow(id, w)` stays, but the **handler** `updateWorkflow` re-fetches and returns the full workflow: after `services.UpdateWorkflow`, call `services.GetWorkflow(id)` and return it (200 with the `Workflow` JSON) instead of `{"updated": true}`. This is the crash fix's server half.
|
||||
|
||||
### 4.3 Runner input injection (`server/internal/services/workflow_runner.go`)
|
||||
|
||||
- `resolveSteps`: when freezing each `ResolvedStep`, compute `Inputs`: for each `InputParam` on the library step, `value = ref.Inputs[name]` if present else `param.Default`; store the resulting `map[string]string` on `ResolvedStep.Inputs`.
|
||||
- `runServer`: when building `cmdEnv`, merge `step.Inputs` **first** (base layer), then `runEnv` (upstream outputs), then `secretVals` (highest precedence). Input values are not secrets and are not masked (they are user-provided config, not secret material) — unless a value coincidentally equals a secret literal, existing masking still catches it in logs.
|
||||
|
||||
---
|
||||
|
||||
## 5. REST API
|
||||
|
||||
No new endpoints. Changes:
|
||||
- `updateWorkflow` handler returns the `Workflow` (see §4.2).
|
||||
- `deleteStep` handler unchanged (cascade lives in the service).
|
||||
- Existing `GET /api/workflows/:id/runs`, `GET /api/runs/:runId` power the runs pages.
|
||||
- `createStep`/`updateStep` accept `declared_inputs` via the existing `ShouldBindJSON(&models.WorkflowStep)` (no handler change once the model has the field).
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 6.1 Tokens + primitives
|
||||
|
||||
- `tailwind.config.ts`: add `bash: "#3fb950"`, `pwsh: "#5b9bff"`. Amber signal reuses a new `signal: "#f5a524"` token (add it) for the builder's focus ring / env chips / run button; `signal-ink: "#241800"` for text on amber chips.
|
||||
- Add a `Modal` primitive to `components/ui` (overlay + centered panel, `onClose`, ESC + backdrop click, `title`, children, exported from `index.ts`). Used by the Edit-base-step and Edit-workflow modals.
|
||||
|
||||
### 6.2 API client (`web/lib/api.ts`)
|
||||
|
||||
- `WorkflowStep`: add `declared_inputs: InputParam[]`.
|
||||
- New `InputParam { name: string; default: string; description: string }`.
|
||||
- `WorkflowStepRef`: add `inputs?: Record<string, string>`.
|
||||
- `updateWorkflow` return type stays `Workflow` (now actually returns one). Add a client guard: if a mutation is expected to return a workflow but the body lacks `steps`, treat it as an error / refetch.
|
||||
- No other method changes; `deleteStep`, `updateStep`, `createStep`, `listRuns`, `getRun` already exist.
|
||||
|
||||
### 6.3 Builder restyle + DnD (`web/app/workflows/[id]/page.tsx`)
|
||||
|
||||
Rebuild the three-pane builder to the mockup:
|
||||
|
||||
- **Topbar:** brand dot + `Workflows /` crumb + workflow name + `· draft · edited …`; right side a **Targets** chip (read-only summary, e.g. "3 servers"), a **Runs** link (→ `/workflows/[id]/runs`), an **Edit** button (opens Edit-workflow modal), **Save**, **Run workflow** (amber). Remove the inline name input and the in-canvas Target-servers card (both move into the Edit-workflow modal).
|
||||
- **Library (left):** section header with `+` (opens Edit-base-step modal in "new" mode), a search box that filters by name, grouped `Shared · Bash` / `Shared · PowerShell`, each a `.step-card` with shell badge, name, description, and a grip glyph; `draggable`. Clicking still appends. Each card has an edit affordance (pencil / context) that opens the Edit-base-step modal for that step.
|
||||
- **Canvas (center):** dotted-grid background. Render placed steps as 340px node cards: index badge, title, shell badge, (build-time) no status pill — status is a run concern; in the builder show the interpreter badge only. Node body shows a syntax-lite script preview. Between nodes render a wire + a dashed-amber `passes` chip row listing the union of prior `declared_outputs` (names only in the builder). Nodes are `draggable` to reorder; drop targets sit on the wires and at the end ("+ Drop a step here"). Selecting a node opens it in the inspector.
|
||||
- **Inspector (right):** kicker "Step N · Inspector", title with shell badge + name. Fields: **Step name** (edits the placement label? — placements don't have a name; show the library name read-only, edit happens in the base-step modal), **Command** (script `<textarea>` — this is the per-placement override; empty = inherit base), a hint "Write `KEY=value` to `$WORKFLOW_ENV`…", **Inputs** (one row per `declared_input` with an input to set the placement value, showing the default as placeholder), **Inputs · from upstream** (read-only list of upstream `declared_outputs` available to this step), **Outputs · to $WORKFLOW_ENV** (read-only list of this step's `declared_outputs`), **Secret refs** (existing group/KEY checklist), **On failure** (stop/continue/retry + max retries), and **Remove from workflow**.
|
||||
|
||||
DnD detail: use `dataTransfer` with a payload discriminating "library step" (carries `step_id`) vs "reorder" (carries the placement index). On drop at position `k`, insert/move and re-sequence `order`. Keep everything in React state; **Save** persists via `updateWorkflow` (which now returns the workflow → `setWf(returned)` no longer crashes).
|
||||
|
||||
### 6.4 Edit base step modal
|
||||
|
||||
A `Modal` with fields: name, interpreter (bash/powershell), script (`<textarea>` mono), declared **outputs** (chip/list editor — add/remove names), declared **inputs** (rows of name/default/description, add/remove), secret refs (optional). Actions: **Save** (`api.createStep` in new mode / `api.updateStep` in edit mode) then invalidate `["steps"]`; **Delete** (edit mode only) → confirm, `api.deleteStep` (cascades server-side), invalidate `["steps"]` and `["workflow", id]` (a deleted step vanishes from the canvas after refetch). Editing here changes the shared step for all workflows (surface the "shared across all workflows" hint).
|
||||
|
||||
### 6.5 Edit workflow modal
|
||||
|
||||
A `Modal` launched from the topbar **Edit** button: workflow **name** input, **target servers** multiselect (the chips currently in the canvas), and a **Delete workflow** action (confirm → `api.deleteWorkflow` → route to `/workflows`). Save applies name/targets to local `wf` state (persisted on the builder's Save) or immediately via `updateWorkflow` — immediate is simpler and avoids losing the change; use immediate save for the modal, then `setWf(returned)`.
|
||||
|
||||
### 6.6 Runs list page (`web/app/workflows/[id]/runs/page.tsx`)
|
||||
|
||||
New page: header "Runs · <workflow name>", a table of `api.listRuns(id)` rows — run id (short), status badge, started_at, triggered_by, server count — each linking to `/workflows/[id]/runs/[runId]`. A "Back to builder" link. Also add a **Runs** action/link on the workflows list page (`web/app/workflows/page.tsx`) per row and the **Runs** link in the builder topbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
- Input parameter values are user config, injected as env; not masked (not secret). Secret masking (existing) still applies to logs and to any value equal to a secret literal.
|
||||
- Cascade delete is an authenticated mutation; audited via `LogEvent` for the step and each affected workflow.
|
||||
- Modals perform the same session-authed API calls; no new trust boundary.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Live run status pills inside the builder canvas (status belongs to the run detail page).
|
||||
- Typed inputs (all inputs are strings), required/validation rules, secret-typed inputs.
|
||||
- Multi-select drag of several steps, copy/paste of steps, undo/redo.
|
||||
- Reworking the run-detail page (covered by the separate log-streaming iteration).
|
||||
- Reordering via keyboard.
|
||||
- Tests (skipped, consistent with prior iterations).
|
||||
```
|
||||
@@ -0,0 +1,193 @@
|
||||
# Workflow Log Streaming — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Stream step stdout/stderr live from agent to server-side log files, tail them live in the UI, and auto-expire them on a retention period. Enhancement to the already-merged Server Workflows feature. No auth/orgs, no inventory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today a workflow step buffers all stdout/stderr in agent RAM, ships it in one terminal `StepResult`, and the server persists the whole body into the `workflow_runs` Mongo document. Long/chatty steps risk: agent memory blow-up, the gRPC 4MB message ceiling, and the Mongo 16MB document cap.
|
||||
|
||||
Change to **live streaming**:
|
||||
|
||||
1. Agent streams output chunks over the existing `CommandStream` as the process runs.
|
||||
2. Server appends chunks (secret-masked) to a **per-server-run log file** on disk — not Mongo.
|
||||
3. UI tails the file live via **SSE** while a server-run is running; slices per-step by byte offset after completion.
|
||||
4. A **retention sweeper** deletes old run-log directories on a configurable period (default 30 days, set in Settings).
|
||||
|
||||
`workflow_runs` documents shrink: they no longer carry `stdout`/`stderr` bodies, only status/exit/attempts/output_env/timestamps plus a per-step `log_offset`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Transport | Reuse bidirectional `CommandStream`. New `AgentMessage.StepOutput` chunk message. |
|
||||
| Chunk shape | `{command_id, seq, data, eof}`. Interleaved stdout+stderr in execution order. |
|
||||
| Terminal result | `StepResult` still sent at step end, now carries only `exit_code` + `output_env` (no stdout/stderr). |
|
||||
| Log granularity | **One file per server-run**: `<logdir>/<run_id>/<server_id>.log`, with a marker line before each step. |
|
||||
| Streams | **Interleaved** — single synchronized writer on the agent, terminal-order output. |
|
||||
| Masking | **Server-side** (agent can't tell secret env from normal env). Per-stream carry buffer of `maxSecretLen-1` bytes so a secret split across a chunk boundary still masks; flushed on EOF. |
|
||||
| Live tail | **SSE** at per-server-run granularity: `GET /api/runs/:runId/servers/:serverId/logs/stream`. Post-run whole-file fetch + per-step offset slice. |
|
||||
| Retention | `settings.workflow_log_retention_days`, default **30**, editable in `/settings`. Hourly sweeper deletes `<logdir>/<run_id>/` dirs older than retention by run `finished_at`. |
|
||||
| Log dir | Env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`. Created `0700`. |
|
||||
| Mongo | No log bodies in `workflow_runs`. Disk is the source of truth for output. |
|
||||
|
||||
---
|
||||
|
||||
## 3. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
|
||||
|
||||
Add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;`
|
||||
|
||||
```protobuf
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2; // monotonic per command_id, 0-based
|
||||
bytes data = 3; // raw interleaved stdout+stderr bytes
|
||||
bool eof = 4; // true on the final (empty) chunk
|
||||
}
|
||||
```
|
||||
|
||||
`StepResult` is unchanged in shape but `stdout`/`stderr` are now left empty by the agent (kept in the message for backward-compat / error notes only — server ignores them for log content). The server still reads `exit_code` and `output_env` from `StepResult`.
|
||||
|
||||
Hand-written JSON-codec struct added to **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, identical. `AgentMessage` gains `StepOutput *StepOutputChunk` in both.
|
||||
|
||||
`data` is `[]byte` in the Go structs (JSON-codec base64-encodes it, which is fine).
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent (`agent/internal/exec/exec.go`)
|
||||
|
||||
`RunStep` signature gains a chunk sink:
|
||||
|
||||
```go
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult
|
||||
```
|
||||
|
||||
- Replace the two `bytes.Buffer`s with a single `streamWriter` set as **both** `c.Stdout` and `c.Stderr`. Its `Write` takes a mutex (so stdout+stderr interleave without interleaving *within* a write), assigns the next `seq`, and calls `emit(seq, copyOfBytes)`. Chunks are whatever the OS pipe delivers (typically ≤64KB); no extra buffering/line-assembly.
|
||||
- `StepResult` returns with `Stdout`/`Stderr` empty; `ExitCode` and `OutputEnv` populated as today (env parsing unchanged).
|
||||
- On timeout/exec error, put the short note in `StepResult.Stderr` (terminal, not streamed) so the runner can still surface a failure reason even if nothing streamed.
|
||||
|
||||
Agent loop (`agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine): pass an `emit` closure that sends `AgentMessage{ServerId, AgentToken, StepOutput: &pb.StepOutputChunk{CommandId, Seq, Data}}` through the existing mutex-guarded `send()`. After `RunStep` returns, send a final `StepOutput{eof:true, seq:last+1}` then the terminal `StepResult` (both via `send()`). Ordering: all chunks, then eof, then StepResult.
|
||||
|
||||
---
|
||||
|
||||
## 5. Server write path
|
||||
|
||||
### 5.1 Log writer registry (`server/internal/services/steplogs.go`)
|
||||
|
||||
Parallel to `StepResults`. Keyed by `command_id`:
|
||||
|
||||
```go
|
||||
type stepLogWriter struct {
|
||||
f *os.File
|
||||
mu sync.Mutex
|
||||
carry []byte // held-back tail for boundary-safe masking
|
||||
secrets []string // secret literals to mask
|
||||
maxSecret int
|
||||
}
|
||||
var StepLogs = &stepLogRegistry{ ... }
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) (*stepLogWriter, error)
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) // masked write
|
||||
func (r *stepLogRegistry) Close(commandID string) // flush carry, close file
|
||||
```
|
||||
|
||||
- `Append` masking: concatenate `carry+data`, mask all secret literals (`ReplaceAll(v,"***")`), then write everything except the last `maxSecret-1` bytes; keep those as the new `carry`. `Close` masks+writes the remaining carry. If `secrets` empty, write straight through (no carry).
|
||||
- The file handle is opened append-only (`O_APPEND|O_CREATE|O_WRONLY`, `0600`); dir `0700`.
|
||||
|
||||
### 5.2 Stream delivery (`server/internal/grpc/server.go`)
|
||||
|
||||
In the receive loop, after the `m.StepResult` block, add:
|
||||
|
||||
```go
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Runner changes (`server/internal/services/workflow_runner.go`)
|
||||
|
||||
- Resolve the log dir + run/server file path once per server-run; ensure `<logdir>/<run_id>/` exists.
|
||||
- Before dispatching each step: write the step marker line to the file (`\n===== step <order>: <name> =====\n`), record the current file byte offset as the step's `log_offset` (persisted on the `StepRun`), and `StepLogs.Open(commandID, path, secretVals)` **before** `DispatchRunStep` (same ordering rule as `StepResults.Await`).
|
||||
- `dispatchAndWait` no longer expects stdout/stderr in the result. On terminal `StepResult`, `StepLogs.Close(commandID)` is driven by the agent's eof; the runner also calls `Close` defensively on timeout/dispatch-failure (idempotent).
|
||||
- **Drop** `stdout`/`stderr` from `finishStep` persistence. Masking of the streamed body is done in `Append`; `run_env`/`output_env` masking (existing, from the merged fix) stays.
|
||||
- The shared server-run file is written by two writers that never overlap in time (steps are serial, and the runner writes each step marker *before* `StepLogs.Open`): (a) the runner writes markers directly to the path, serially between steps; (b) `StepLogs` writes chunks during a step. **Resolved approach:** `Open(commandID, path, secrets)` opens the path fresh with `O_APPEND|O_CREATE|O_WRONLY` for that step and `Close(commandID)` closes it on eof. One handle live at a time per server-run (serial steps guarantee this), so there is no shared-handle race and no ref-counting. The runner's marker write is a separate short `O_APPEND` open/write/close on the same path.
|
||||
|
||||
### 5.4 Data model (`server/internal/models/workflow.go`)
|
||||
|
||||
`StepRun`:
|
||||
- **Remove** `Stdout`, `Stderr` string fields.
|
||||
- **Add** `LogOffset int64 `bson:"log_offset" json:"log_offset"`` — byte offset in the server-run file where this step's marker begins.
|
||||
|
||||
`ServerRun` gains nothing structural (its file path is derivable: `<logdir>/<run_id>/<server_id>.log`).
|
||||
|
||||
---
|
||||
|
||||
## 6. REST API (`server/internal/api/workflows.go`)
|
||||
|
||||
- `GET /api/runs/:runId/servers/:serverId/logs` — returns the whole server-run log file (`text/plain`). 404 if absent. Used post-run and as SSE fallback.
|
||||
- `GET /api/runs/:runId/servers/:serverId/logs/stream` — **SSE**. Opens the file, streams existing content as `data:` events, then polls for appends (~500ms) emitting new bytes, until the server-run status is terminal (success/failed/skipped/cancelled) AND no more bytes, then sends a final `event: done` and closes. Sets `Content-Type: text/event-stream`, disables gin's buffering. Guards against path traversal (runId/serverId are used as literal path segments — validate they are UUIDs / contain no separators).
|
||||
|
||||
Log content served by these endpoints is already masked (masking happens at write time), so no masking needed on read.
|
||||
|
||||
---
|
||||
|
||||
## 7. Retention
|
||||
|
||||
### 7.1 Setting
|
||||
|
||||
`settings` collection gains `workflow_log_retention_days int` (default 30 when unset). Read/write via the existing settings service + surfaced in `/settings` UI as a number input. `0` or negative disables sweeping (keep forever) — document this.
|
||||
|
||||
### 7.2 Sweeper (`server/internal/services/steplogs.go` or `logsweeper.go`)
|
||||
|
||||
- `StartLogSweeper()` launched at server startup (next to index setup): hourly `time.Ticker`.
|
||||
- Each tick: read retention setting; if ≤0 skip. Compute cutoff = `now - retentionDays`. For each `<logdir>/<run_id>/` dir, look up the run's `finished_at` (query `workflow_runs` by run_id); if finished and older than cutoff, `os.RemoveAll` the dir. Fallback to dir mtime if the run doc is gone.
|
||||
- Also run once at startup.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
### 8.1 API client (`web/lib/api.ts`)
|
||||
|
||||
- `StepRun`: remove `stdout`/`stderr`; add `log_offset: number`.
|
||||
- Add `getServerRunLog(runId, serverId): Promise<string>` (GET .../logs).
|
||||
- SSE consumed directly via `EventSource` in the component (not through the `request` helper), URL built from the same base.
|
||||
- Settings type gains `workflow_log_retention_days`.
|
||||
|
||||
### 8.2 Run detail (`web/app/workflows/[id]/runs/[runId]/page.tsx`)
|
||||
|
||||
- Per-server card: while the server-run is `running`, open an `EventSource` to the stream endpoint and render a live `<pre>` terminal that appends incoming chunks (auto-scroll). Close the source on `event: done`, unmount, or terminal status.
|
||||
- After completion: fetch the whole file once and render it; step `<details>` still list status/exit/attempts pills. (Per-step slicing by `log_offset` is optional polish — v1 may show the whole server log under the card and keep step pills as the status summary.)
|
||||
- Remove reliance on `st.stdout`/`st.stderr` (fields gone).
|
||||
|
||||
### 8.3 Settings (`web/app/settings/page.tsx`)
|
||||
|
||||
- Add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved via the existing settings mutation. Note that `0` = keep forever.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security
|
||||
|
||||
- Secret masking moves to the streaming write path but remains server-side and boundary-safe (carry buffer). Same `***` replacement.
|
||||
- Log files `0600`, dirs `0700`, under a dedicated log dir.
|
||||
- SSE/read endpoints validate `runId`/`serverId` as UUID-shaped path segments to prevent traversal; they are session-authed (same `apiGroup`).
|
||||
- Terminal `StepResult.Stderr` (error notes only) is still masked before any persistence (it is no longer persisted as log body; if surfaced, mask against secretVals).
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Per-step (rather than per-server) live SSE channels.
|
||||
- Log compression / rotation within a run, remote log storage (S3), download-as-zip.
|
||||
- Full-text search over logs.
|
||||
- Backfilling/migrating already-existing `workflow_runs` stdout/stderr into files (pre-existing runs keep whatever they had; new field just won't be set — acceptable, feature is new).
|
||||
- Tests (skipped, consistent with the Workflows iteration).
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,140 @@
|
||||
param(
|
||||
[string]$ServerId,
|
||||
[string]$Token,
|
||||
[string]$ServerUrl,
|
||||
[string]$InstallDir,
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$logDir = Join-Path $env:ProgramData "vantage"
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir "install.log"
|
||||
|
||||
function Write-Log($msg) {
|
||||
$line = "{0} {1}" -f (Get-Date -Format "s"), $msg
|
||||
Add-Content -Path $log -Value $line
|
||||
}
|
||||
|
||||
# Fail native-exe (nssm) calls loudly: check $LASTEXITCODE after each call
|
||||
function Invoke-Native {
|
||||
param([string]$File, [string[]]$Arguments)
|
||||
Write-Log ("RUN: {0} {1}" -f $File, ($Arguments -join " "))
|
||||
$out = & $File @Arguments 2>&1
|
||||
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw ("{0} exited {1}" -f $File, $LASTEXITCODE)
|
||||
}
|
||||
}
|
||||
|
||||
# Like Invoke-Native but never throws — for teardown, where a missing/stopped
|
||||
# service must not abort the uninstall.
|
||||
function Invoke-NativeSoft {
|
||||
param([string]$File, [string[]]$Arguments)
|
||||
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
|
||||
# Native stderr merged via 2>&1 becomes terminating errors under
|
||||
# ErrorActionPreference=Stop; force Continue in this scope so a benign nssm
|
||||
# message (e.g. "service has not been started") never aborts setup.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$out = & $File @Arguments 2>&1
|
||||
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
|
||||
Write-Log ("EXIT: {0}" -f $LASTEXITCODE)
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
try {
|
||||
Write-Log "=== teardown start ==="
|
||||
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
|
||||
$nssm = Join-Path $InstallDir "nssm.exe"
|
||||
if (Test-Path $nssm) {
|
||||
Invoke-NativeSoft -File $nssm -Arguments @("stop", "VantageAgent")
|
||||
Invoke-NativeSoft -File $nssm -Arguments @("remove", "VantageAgent", "confirm")
|
||||
} else {
|
||||
Write-Log "nssm.exe not found at $nssm - using sc.exe fallback"
|
||||
Invoke-NativeSoft -File "sc.exe" -Arguments @("stop", "VantageAgent")
|
||||
Invoke-NativeSoft -File "sc.exe" -Arguments @("delete", "VantageAgent")
|
||||
}
|
||||
Write-Log "=== teardown ok ==="
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log ("TEARDOWN ERROR: {0}" -f $_.Exception.Message)
|
||||
# Never block uninstall
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "=== setup start ==="
|
||||
Write-Log ("ServerId={0} ServerUrl={1} InstallDir={2}" -f $ServerId, $ServerUrl, $InstallDir)
|
||||
|
||||
$cfgDir = Join-Path $env:ProgramData "vantage"
|
||||
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
|
||||
$cfgPath = Join-Path $cfgDir "config.yaml"
|
||||
|
||||
# Preserve existing config on upgrade. A MajorUpgrade re-runs this script with
|
||||
# no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent
|
||||
# persisted after Register(). Only (re)write when a ServerId is supplied
|
||||
# (fresh install / explicit re-register).
|
||||
if ((Test-Path $cfgPath) -and (-not $ServerId)) {
|
||||
Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)"
|
||||
}
|
||||
else {
|
||||
$cfg = @"
|
||||
server_url: "$ServerUrl"
|
||||
server_id: "$ServerId"
|
||||
pre_reg_token: "$Token"
|
||||
agent_token: ""
|
||||
poll_interval: 30s
|
||||
tls: true
|
||||
"@
|
||||
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
|
||||
Write-Log "wrote $cfgPath"
|
||||
|
||||
# Lock down ACL: SYSTEM + Administrators only
|
||||
Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F")
|
||||
}
|
||||
|
||||
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
|
||||
$nssm = Join-Path $InstallDir "nssm.exe"
|
||||
$exe = Join-Path $InstallDir "vantage-agent.exe"
|
||||
|
||||
if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" }
|
||||
if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" }
|
||||
|
||||
# Install only if the service isn't already registered (an upgrade may leave
|
||||
# it in place). "nssm install" on an existing service errors otherwise.
|
||||
$exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue
|
||||
if (-not $exists) {
|
||||
Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe)
|
||||
} else {
|
||||
Write-Log "VantageAgent service already exists - updating binary path"
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe)
|
||||
}
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START")
|
||||
|
||||
# Redirect service stdout/stderr to log files (nssm discards them otherwise)
|
||||
# with online rotation at ~1MB.
|
||||
$outLog = Join-Path $logDir "agent-stdout.log"
|
||||
$errLog = Join-Path $logDir "agent-stderr.log"
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog)
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog)
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4")
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4")
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1")
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1")
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576")
|
||||
|
||||
# Service is freshly (re)installed and stopped here (teardown removed the old
|
||||
# one on upgrade), so start it. "restart" would try to stop a not-running
|
||||
# service and emit a stderr error.
|
||||
Invoke-NativeSoft -File $nssm -Arguments @("start", "VantageAgent")
|
||||
|
||||
Write-Log "=== setup ok ==="
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log ("ERROR: {0}" -f $_.Exception.Message)
|
||||
Write-Log ($_.ScriptStackTrace)
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?ifndef Version ?>
|
||||
<?define Version = "0.0.0.0" ?>
|
||||
<?endif?>
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Vantage Agent" Manufacturer="Vantage"
|
||||
Version="$(var.Version)" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
|
||||
Scope="perMachine">
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed."
|
||||
Schedule="afterInstallInitialize" />
|
||||
<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]"' />
|
||||
|
||||
<CustomAction Id="WriteConfig" Directory="INSTALLDIR" ExeCommand="[WriteConfig]"
|
||||
Execute="deferred" Impersonate="no" Return="check" />
|
||||
|
||||
<!-- Teardown on uninstall: stop + remove the service BEFORE RemoveFiles
|
||||
deletes nssm.exe/setup.ps1. Same CustomActionData marshaling pattern
|
||||
as WriteConfig. REMOVE="ALL" = full uninstall (not a component-level
|
||||
repair/modify). -->
|
||||
<SetProperty Id="RemoveService"
|
||||
Before="RemoveService" Sequence="execute" Condition="REMOVE="ALL""
|
||||
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -Uninstall' />
|
||||
|
||||
<CustomAction Id="RemoveService" Directory="INSTALLDIR" ExeCommand="[RemoveService]"
|
||||
Execute="deferred" Impersonate="no" Return="ignore" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed" />
|
||||
<Custom Action="RemoveService" Before="RemoveFiles" Condition="REMOVE="ALL"" />
|
||||
</InstallExecuteSequence>
|
||||
</Package>
|
||||
</Wix>
|
||||
@@ -1,43 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package keymanager.v1;
|
||||
|
||||
option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb";
|
||||
|
||||
service KeyManager {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
string server_id = 1;
|
||||
string pre_reg_token = 2;
|
||||
string hostname = 3;
|
||||
string ip_address = 4;
|
||||
string os_info = 5;
|
||||
}
|
||||
|
||||
message RegisterResponse {
|
||||
string agent_token = 1;
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
repeated string public_keys = 1;
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
}
|
||||
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package vantage.v1;
|
||||
|
||||
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
|
||||
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
string server_id = 1;
|
||||
string pre_reg_token = 2;
|
||||
string hostname = 3;
|
||||
string ip_address = 4;
|
||||
string os_info = 5;
|
||||
}
|
||||
|
||||
message RegisterResponse {
|
||||
string agent_token = 1;
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string agent_version = 3;
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
repeated string public_keys = 1;
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
string private_key = 5;
|
||||
}
|
||||
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
}
|
||||
|
||||
// CommandStream messages
|
||||
|
||||
message AgentMessage {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
oneof payload {
|
||||
AgentReady ready = 3;
|
||||
CommandResult result = 4;
|
||||
StepResult step_result = 5;
|
||||
StepOutputChunk step_output = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message AgentReady {}
|
||||
|
||||
message CommandResult {
|
||||
string command_id = 1;
|
||||
bool success = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message PackageUpdate {
|
||||
string name = 1;
|
||||
string current_version = 2;
|
||||
string new_version = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated PackageUpdate updates = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesResponse {}
|
||||
|
||||
message ApplyUpdatesCmd {}
|
||||
|
||||
message ServerCommand {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
|
||||
message UpdateAgentCmd {
|
||||
string version = 1; // e.g. "1.2.3"
|
||||
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
|
||||
}
|
||||
|
||||
message GenerateKeyCmd {
|
||||
string label = 1;
|
||||
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
|
||||
int32 key_size = 3; // bits; used for rsa and ecdsa
|
||||
string passphrase = 4; // empty = no passphrase
|
||||
string comment = 5; // embedded in public key
|
||||
}
|
||||
|
||||
message RunStepCmd {
|
||||
string interpreter = 1; // "bash" | "powershell"
|
||||
string script = 2;
|
||||
map<string, string> env = 3;
|
||||
int32 timeout_seconds = 4;
|
||||
}
|
||||
|
||||
message StepResult {
|
||||
string command_id = 1;
|
||||
int32 exit_code = 2;
|
||||
string stdout = 3;
|
||||
string stderr = 4;
|
||||
map<string, string> output_env = 5;
|
||||
}
|
||||
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2;
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
+3
-3
@@ -11,14 +11,14 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /keymanager-server ./cmd
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /keymanager-server /keymanager-server
|
||||
COPY --from=builder /vantage-server /vantage-server
|
||||
|
||||
EXPOSE 8080 9090
|
||||
|
||||
ENTRYPOINT ["/keymanager-server"]
|
||||
ENTRYPOINT ["/vantage-server"]
|
||||
|
||||
+29
-7
@@ -1,32 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/keymanager/server/internal/api"
|
||||
"github.com/mrhid6/keymanager/server/internal/db"
|
||||
grpcserver "github.com/mrhid6/keymanager/server/internal/grpc"
|
||||
"github.com/mrhid6/keymanager/server/internal/services"
|
||||
"github.com/mrhid6/vantage/server/internal/api"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
dbName := getEnv("MONGO_DB", "keymanager")
|
||||
dbName := getEnv("MONGO_DB", "vantage")
|
||||
|
||||
if err := db.Connect(mongoURI, dbName); err != nil {
|
||||
log.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
log.Println("connected to MongoDB")
|
||||
|
||||
if err := services.EnsureSecretIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure secret indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureWorkflowIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure workflow 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)
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
|
||||
if err := auth.InitOIDC(context.Background()); err != nil {
|
||||
log.Fatalf("failed to initialise OIDC: %v", err)
|
||||
}
|
||||
|
||||
// Background goroutine to mark offline servers
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +60,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)
|
||||
|
||||
|
||||
+12
-2
@@ -1,40 +1,50 @@
|
||||
module github.com/mrhid6/keymanager/server
|
||||
module github.com/mrhid6/vantage/server
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
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.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
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
|
||||
+32
-3
@@ -1,11 +1,19 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -15,6 +23,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -30,16 +40,21 @@ 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=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
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=
|
||||
@@ -53,10 +68,16 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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=
|
||||
@@ -69,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=
|
||||
@@ -78,8 +101,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
@@ -93,16 +120,18 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"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",
|
||||
})
|
||||
}
|
||||
|
||||
// queryIntDefault reads a positive integer query param, falling back to def
|
||||
// when absent, unparseable, or non-positive.
|
||||
func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
v, err := strconv.Atoi(r.URL.Query().Get(key))
|
||||
if err != nil || v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// 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 = queryIntDefault(r, "width", 1024)
|
||||
config.OptimalScreenHeight = queryIntDefault(r, "height", 768)
|
||||
config.OptimalResolution = queryIntDefault(r, "dpi", 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)
|
||||
}
|
||||
+329
-52
@@ -4,30 +4,82 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/keymanager/server/internal/models"
|
||||
"github.com/mrhid6/keymanager/server/internal/services"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"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)
|
||||
r.GET("/update.ps1", handleUpdateScriptWindows)
|
||||
|
||||
api := r.Group("/api")
|
||||
// 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)
|
||||
r.GET("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/me", auth.HandleMe)
|
||||
|
||||
// API endpoints protected by session middleware
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
{
|
||||
api.GET("/servers", listServers)
|
||||
api.POST("/servers", createServer)
|
||||
api.GET("/servers/new", newServer)
|
||||
api.POST("/servers/new", newServer)
|
||||
api.GET("/servers/:id", getServer)
|
||||
api.DELETE("/servers/:id", deleteServer)
|
||||
api.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.GET("/servers", listServers)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
apiGroup.POST("/servers/new", newServer)
|
||||
apiGroup.GET("/servers/:id", getServer)
|
||||
apiGroup.DELETE("/servers/:id", deleteServer)
|
||||
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.POST("/servers/:id/update-agent", updateAgent)
|
||||
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
|
||||
|
||||
api.GET("/keys", listKeys)
|
||||
api.POST("/keys", createKey)
|
||||
api.GET("/keys/:id", getKey)
|
||||
api.POST("/keys/:id/assign", assignKey)
|
||||
api.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||
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)
|
||||
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
|
||||
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)
|
||||
|
||||
registerWorkflowRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +111,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 == "" {
|
||||
@@ -66,18 +119,24 @@ func newServer(c *gin.Context) {
|
||||
}
|
||||
host := os.Getenv("PUBLIC_HOST")
|
||||
if host == "" {
|
||||
host = "keymanager.example.com"
|
||||
host = "https://vantage.example.com"
|
||||
}
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL "https://%s/install?server_id=%s&token=%s" | bash`,
|
||||
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
installCmdPS := fmt.Sprintf(
|
||||
`irm "%s/install.ps1?server_id=%s&token=%s" | iex`,
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"server_id": s.ServerID,
|
||||
"pre_reg_token": token,
|
||||
"install_command": installCmd,
|
||||
"server_id": s.ServerID,
|
||||
"pre_reg_token": token,
|
||||
"install_command": installCmd,
|
||||
"install_command_ps": installCmdPS,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,26 +163,57 @@ 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})
|
||||
}
|
||||
|
||||
func generateKey(c *gin.Context) {
|
||||
// The agent triggers key generation itself; this endpoint signals
|
||||
// the intent by returning the server so the caller knows to wait
|
||||
// for the agent to upload via gRPC UploadGeneratedKey.
|
||||
id := c.Param("id")
|
||||
|
||||
var body struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type"`
|
||||
KeySize int `json:"key_size"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Label == "" {
|
||||
body.Label = "generated"
|
||||
}
|
||||
|
||||
s, err := services.GetServer(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "agent will generate and upload key on next poll",
|
||||
"server_id": s.ServerID,
|
||||
|
||||
cmdID, err := services.DispatchGenerateKey(s.ServerID, services.KeyGenParams{
|
||||
Label: body.Label,
|
||||
KeyType: body.KeyType,
|
||||
KeySize: body.KeySize,
|
||||
Passphrase: body.Passphrase,
|
||||
Comment: body.Comment,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
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,
|
||||
"server_id": s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,22 +228,35 @@ func listKeys(c *gin.Context) {
|
||||
|
||||
func createKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
PublicKey string `json:"public_key" binding:"required"`
|
||||
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", "")
|
||||
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)
|
||||
}
|
||||
|
||||
func getPrivateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
plaintext, err := services.GetPrivateKey(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
|
||||
}
|
||||
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(id)
|
||||
@@ -163,12 +266,32 @@ func getKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(id)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key": key,
|
||||
"assignments": assignments,
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
c.JSON(http.StatusOK, keyResponse{
|
||||
Key: key,
|
||||
Assignments: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
func assignKey(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
var body struct {
|
||||
@@ -184,6 +307,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)
|
||||
}
|
||||
|
||||
@@ -195,9 +319,152 @@ 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})
|
||||
}
|
||||
|
||||
func getLatestAgentVersion(c *gin.Context) {
|
||||
version, err := services.GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"version": version})
|
||||
}
|
||||
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
version, err := services.DispatchUpdateAgent(s.ServerID)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
|
||||
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"})
|
||||
}
|
||||
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_HOST="%s"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Get latest agent release tag
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
|
||||
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo "Could not determine latest agent version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
|
||||
echo "Updating vantage-agent to ${VERSION} (${ARCH})..."
|
||||
|
||||
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
|
||||
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
|
||||
|
||||
cd /tmp
|
||||
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "Checksum mismatch!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl stop vantage-agent || true
|
||||
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
|
||||
systemctl start vantage-agent
|
||||
|
||||
echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
`, giteaHost)
|
||||
|
||||
c.Header("Content-Type", "text/x-shellscript")
|
||||
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")
|
||||
@@ -208,7 +475,11 @@ func handleInstallScript(c *gin.Context) {
|
||||
}
|
||||
publicHost := os.Getenv("PUBLIC_HOST")
|
||||
if publicHost == "" {
|
||||
publicHost = "keymanager.example.com"
|
||||
publicHost = "vantage.example.com"
|
||||
}
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
if grpcHost == "" {
|
||||
grpcHost = publicHost
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
@@ -218,6 +489,11 @@ SERVER_ID="%s"
|
||||
TOKEN="%s"
|
||||
GITEA_HOST="%s"
|
||||
KM_HOST="%s"
|
||||
KM_HOST="${KM_HOST#https://}"
|
||||
KM_HOST="${KM_HOST#http://}"
|
||||
GRPC_HOST="%s"
|
||||
GRPC_HOST="${GRPC_HOST#https://}"
|
||||
GRPC_HOST="${GRPC_HOST#http://}"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
@@ -227,7 +503,7 @@ case "$ARCH" in
|
||||
esac
|
||||
|
||||
# Get latest agent release tag
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
|
||||
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
|
||||
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
@@ -236,44 +512,45 @@ if [ -z "$LATEST" ]; then
|
||||
fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/keymanager-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/checksums.txt"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
|
||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||
|
||||
echo "Installing keymanager-agent ${VERSION} (${ARCH})..."
|
||||
echo "Installing vantage-agent ${VERSION} (${ARCH})..."
|
||||
|
||||
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
|
||||
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
|
||||
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
|
||||
|
||||
cd /tmp
|
||||
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
|
||||
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "Checksum mismatch!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
|
||||
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
|
||||
|
||||
mkdir -p /etc/keymanager
|
||||
chmod 0700 /etc/keymanager
|
||||
mkdir -p /etc/vantage
|
||||
chmod 0700 /etc/vantage
|
||||
|
||||
cat > /etc/keymanager/config.yaml <<EOF
|
||||
server_url: "${KM_HOST}:9090"
|
||||
cat > /etc/vantage/config.yaml <<EOF
|
||||
server_url: "${GRPC_HOST}"
|
||||
server_id: "${SERVER_ID}"
|
||||
pre_reg_token: "${TOKEN}"
|
||||
agent_token: ""
|
||||
poll_interval: 30s
|
||||
tls: true
|
||||
EOF
|
||||
chmod 0600 /etc/keymanager/config.yaml
|
||||
chmod 0600 /etc/vantage/config.yaml
|
||||
|
||||
cat > /etc/systemd/system/keymanager-agent.service <<EOF
|
||||
cat > /etc/systemd/system/vantage-agent.service <<EOF
|
||||
[Unit]
|
||||
Description=KeyManager Agent
|
||||
Description=Vantage Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/keymanager-agent
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
@@ -283,10 +560,10 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now keymanager-agent
|
||||
systemctl enable --now vantage-agent
|
||||
|
||||
echo "keymanager-agent installed and started."
|
||||
`, serverID, token, giteaHost, publicHost)
|
||||
echo "vantage-agent installed and started."
|
||||
`, serverID, token, giteaHost, publicHost, grpcHost)
|
||||
|
||||
c.Header("Content-Type", "text/x-shellscript")
|
||||
c.String(http.StatusOK, script)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an
|
||||
// already-installed Windows agent. No server_id/token needed: the MSI is a
|
||||
// MajorUpgrade and setup.ps1 preserves the existing config on upgrade.
|
||||
func handleUpdateScriptWindows(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(
|
||||
"#Requires -RunAsAdministrator\n"+
|
||||
"$ErrorActionPreference = \"Stop\"\n"+
|
||||
"\n"+
|
||||
"$GiteaHost = \"%s\"\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 /norestart\"\n"+
|
||||
"Write-Host \"Vantage agent updated to $tag.\"\n",
|
||||
giteaHost)
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/steps", listSteps)
|
||||
g.POST("/steps", createStep)
|
||||
g.PUT("/steps/:id", updateStep)
|
||||
g.DELETE("/steps/:id", deleteStep)
|
||||
|
||||
g.GET("/workflows", listWorkflows)
|
||||
g.POST("/workflows", createWorkflow)
|
||||
g.GET("/workflows/:id", getWorkflow)
|
||||
g.PUT("/workflows/:id", updateWorkflow)
|
||||
g.DELETE("/workflows/:id", deleteWorkflow)
|
||||
g.POST("/workflows/:id/run", runWorkflow)
|
||||
g.GET("/workflows/:id/runs", listWorkflowRuns)
|
||||
|
||||
g.GET("/runs/:runId", getRun)
|
||||
g.POST("/runs/:runId/cancel", cancelRun)
|
||||
}
|
||||
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
func createStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateStep(s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func updateStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateStep(c.Param("id"), s); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func deleteStep(c *gin.Context) {
|
||||
if err := services.DeleteStep(c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, wfs)
|
||||
}
|
||||
|
||||
func createWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateWorkflow(w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func getWorkflow(c *gin.Context) {
|
||||
w, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateWorkflow(c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
if err := services.DeleteWorkflow(c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
|
||||
}
|
||||
|
||||
func listWorkflowRuns(c *gin.Context) {
|
||||
limit := int64(50)
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
runs, err := services.ListRuns(c.Param("id"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func getRun(c *gin.Context) {
|
||||
r, err := services.GetRun(c.Param("runId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
func cancelRun(c *gin.Context) {
|
||||
if err := services.CancelRun(c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxSessionKey = "km_session"
|
||||
|
||||
func GetSessionFromContext(c *gin.Context) *Session {
|
||||
v, _ := c.Get(ctxSessionKey)
|
||||
sess, _ := v.(*Session)
|
||||
return sess
|
||||
}
|
||||
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !authEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxSessionKey, sess)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
oidcProvider *oidc.Provider
|
||||
oauth2Cfg *oauth2.Config
|
||||
authEnabled bool
|
||||
)
|
||||
|
||||
func InitOIDC(ctx context.Context) error {
|
||||
issuer := os.Getenv("OIDC_ISSUER")
|
||||
if issuer == "" {
|
||||
log.Println("OIDC_ISSUER not set; authentication disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
p, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oidcProvider = p
|
||||
oauth2Cfg = &oauth2.Config{
|
||||
ClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||
Endpoint: p.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
authEnabled = true
|
||||
log.Println("OIDC authentication enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
func Enabled() bool { return authEnabled }
|
||||
|
||||
func HandleLogin(c *gin.Context) {
|
||||
state, err := randomHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
|
||||
return
|
||||
}
|
||||
if err := SaveState(c.Request.Context(), state); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
|
||||
}
|
||||
|
||||
func HandleCallback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !ConsumeState(ctx, c.Query("state")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
|
||||
return
|
||||
}
|
||||
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := SaveSession(ctx, &Session{
|
||||
UserID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
|
||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
|
||||
frontendURL := os.Getenv("PUBLIC_HOST")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "/"
|
||||
}
|
||||
c.Redirect(http.StatusFound, frontendURL)
|
||||
}
|
||||
|
||||
func HandleLogout(c *gin.Context) {
|
||||
if cookie, err := c.Request.Cookie(sessionCookieName); err == nil {
|
||||
_ = DeleteSession(c.Request.Context(), cookie.Value)
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func HandleMe(c *gin.Context) {
|
||||
if !authEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"auth_enabled": false})
|
||||
return
|
||||
}
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sess)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const sessionTTL = 24 * time.Hour
|
||||
const sessionCookieName = "km_session"
|
||||
const sessionPrefix = "km:session:"
|
||||
const statePrefix = "km:state:"
|
||||
|
||||
type Session struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
|
||||
func InitRedis(addr string) error {
|
||||
rdb = redis.NewClient(&redis.Options{Addr: addr})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func SaveSession(ctx context.Context, sess *Session) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(sess)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, sessionPrefix+id, data, sessionTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func GetSession(ctx context.Context, id string) (*Session, error) {
|
||||
data, err := rdb.Get(ctx, sessionPrefix+id).Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func DeleteSession(ctx context.Context, id string) error {
|
||||
return rdb.Del(ctx, sessionPrefix+id).Err()
|
||||
}
|
||||
|
||||
func SaveState(ctx context.Context, state string) error {
|
||||
return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err()
|
||||
}
|
||||
|
||||
func ConsumeState(ctx context.Context, state string) bool {
|
||||
n, err := rdb.Del(ctx, statePrefix+state).Result()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// Hand-written gRPC bindings for keymanager.proto using JSON codec.
|
||||
// To use: register the JSON codec before creating gRPC servers/clients.
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Message types
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// Server interface
|
||||
|
||||
type KeyManagerServer interface {
|
||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedKeyManagerServer struct{}
|
||||
|
||||
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
|
||||
}
|
||||
|
||||
// Client interface
|
||||
|
||||
type KeyManagerClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Server registration
|
||||
|
||||
func RegisterKeyManagerServer(s grpc.ServiceRegistrar, srv KeyManagerServer) {
|
||||
s.RegisterService(&KeyManager_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
var KeyManager_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "keymanager.v1.KeyManager",
|
||||
HandlerType: (*KeyManagerServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "Register", Handler: _KeyManager_Register_Handler},
|
||||
{MethodName: "SyncKeys", Handler: _KeyManager_SyncKeys_Handler},
|
||||
{MethodName: "UploadGeneratedKey", Handler: _KeyManager_UploadGeneratedKey_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "keymanager/v1/keymanager.proto",
|
||||
}
|
||||
|
||||
func _KeyManager_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RegisterRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KeyManagerServer).Register(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/Register"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KeyManagerServer).Register(ctx, req.(*RegisterRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _KeyManager_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SyncRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KeyManagerServer).SyncKeys(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/SyncKeys"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KeyManagerServer).SyncKeys(ctx, req.(*SyncRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _KeyManager_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UploadKeyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/UploadGeneratedKey"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Hand-written gRPC bindings for vantage.proto using JSON codec.
|
||||
// To use: register the JSON codec before creating gRPC servers/clients.
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Message types
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
OutputEnv map[string]string `json:"output_env,omitempty"`
|
||||
}
|
||||
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
// CommandStream server-side interface
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Server interface
|
||||
|
||||
type VantageServer interface {
|
||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||
}
|
||||
|
||||
// Client interface
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
// Server registration
|
||||
|
||||
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
|
||||
s.RegisterService(&Vantage_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "vantage.v1.Vantage",
|
||||
HandlerType: (*VantageServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "Register", Handler: _Vantage_Register_Handler},
|
||||
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
|
||||
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
|
||||
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "CommandStream",
|
||||
Handler: _Vantage_CommandStream_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "vantage/v1/vantage.proto",
|
||||
}
|
||||
|
||||
func _Vantage_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RegisterRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).Register(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/Register"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).Register(ctx, req.(*RegisterRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SyncRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).SyncKeys(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncKeys"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).SyncKeys(ctx, req.(*SyncRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UploadKeyRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).UploadGeneratedKey(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/UploadGeneratedKey"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportUpdatesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportUpdates(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportUpdates"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportUpdates(ctx, req.(*ReportUpdatesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
|
||||
}
|
||||
+108
-11
@@ -5,12 +5,15 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/keymanager/server/internal/grpc/pb"
|
||||
"github.com/mrhid6/keymanager/server/internal/services"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/encoding"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
@@ -18,11 +21,11 @@ func init() {
|
||||
encoding.RegisterCodec(JSONCodec{})
|
||||
}
|
||||
|
||||
type keyManagerServer struct {
|
||||
pb.UnimplementedKeyManagerServer
|
||||
type vantageServer struct {
|
||||
pb.UnimplementedVantageServer
|
||||
}
|
||||
|
||||
func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
|
||||
func (s *vantageServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
|
||||
agentToken, err := services.RegisterServer(req.ServerId, req.PreRegToken, req.Hostname, req.IpAddress, req.OsInfo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "registration failed: %v", err)
|
||||
@@ -30,16 +33,20 @@ func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest
|
||||
return &pb.RegisterResponse{AgentToken: agentToken}, nil
|
||||
}
|
||||
|
||||
func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
|
||||
func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID); err != nil {
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID, req.AgentVersion); err != nil {
|
||||
log.Printf("failed to update last seen for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
if err := services.BackfillConsoleConfig(srv); err != nil {
|
||||
log.Printf("failed to backfill console config for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
keys, err := services.BuildAuthorizedKeys(req.ServerId)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to build authorized keys: %v", err)
|
||||
@@ -48,13 +55,14 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*
|
||||
return &pb.SyncResponse{PublicKeys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
|
||||
func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID)
|
||||
// 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)
|
||||
}
|
||||
@@ -67,14 +75,103 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
|
||||
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdatesRequest) (*pb.ReportUpdatesResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
pkgs := make([]models.PackageUpdate, len(req.Updates))
|
||||
for i, u := range req.Updates {
|
||||
pkgs[i] = models.PackageUpdate{
|
||||
Name: u.Name,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
NewVersion: u.NewVersion,
|
||||
}
|
||||
}
|
||||
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
|
||||
log.Printf("failed to store updates for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.ReportUpdatesResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
|
||||
// First message authenticates the agent and signals readiness.
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
|
||||
}
|
||||
|
||||
srv, err := services.ValidateAgentToken(msg.ServerId, msg.AgentToken)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
if err := services.UpdateServerLastSeen(srv.ServerID, ""); err != nil {
|
||||
log.Printf("update last seen %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
ch := services.Dispatcher.Connect(srv.ServerID)
|
||||
defer services.Dispatcher.Disconnect(srv.ServerID)
|
||||
|
||||
log.Printf("agent %s connected command stream", srv.ServerID)
|
||||
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
|
||||
|
||||
// Drain inbound results in the background so client Send calls never block.
|
||||
// UploadGeneratedKey handles the real storage; these are just confirmation logs.
|
||||
go func() {
|
||||
for {
|
||||
m, err := stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if m.Result != nil {
|
||||
r := m.Result
|
||||
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
|
||||
}
|
||||
if m.StepResult != nil {
|
||||
services.StepResults.Deliver(m.StepResult)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := stream.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case cmd, ok := <-ch:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := stream.Send(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StartGRPC(port int) error {
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
s := grpc.NewServer()
|
||||
pb.RegisterKeyManagerServer(s, &keyManagerServer{})
|
||||
s := grpc.NewServer(
|
||||
// Accept client keepalive pings as fast as every 20s so the 30s agent
|
||||
// ping interval is always within the allowed window.
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
// Server also pings the client after 45s of inactivity so both
|
||||
// sides can detect a dead connection without waiting for a timeout.
|
||||
Time: 45 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
)
|
||||
pb.RegisterVantageServer(s, &vantageServer{})
|
||||
|
||||
log.Printf("gRPC server listening on :%d", port)
|
||||
return s.Serve(lis)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import (
|
||||
|
||||
type Key struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
Label string `bson:"label" json:"label"`
|
||||
PublicKey string `bson:"public_key" json:"public_key"`
|
||||
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
|
||||
Source string `bson:"source" json:"source"` // uploaded | generated
|
||||
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
Label string `bson:"label" json:"label"`
|
||||
PublicKey string `bson:"public_key" json:"public_key"`
|
||||
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
|
||||
Source string `bson:"source" json:"source"` // uploaded | generated
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -6,16 +6,29 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
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:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
type PackageUpdate struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
|
||||
NewVersion string `bson:"new_version" json:"new_version"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
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:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
|
||||
type WorkflowStep struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"` // "bash" | "powershell"
|
||||
Script string `bson:"script" json:"script"`
|
||||
DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"`
|
||||
DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkflowStepRef struct {
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
type StepOverride struct {
|
||||
Script *string `bson:"script,omitempty" json:"script,omitempty"`
|
||||
SecretRefs []string `bson:"secret_refs,omitempty" json:"secret_refs,omitempty"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
|
||||
Steps []WorkflowStepRef `bson:"steps" json:"steps"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied).
|
||||
type ResolvedStep struct {
|
||||
Order int `bson:"order" json:"order"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"`
|
||||
Script string `bson:"script" json:"script"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Inputs map[string]string `bson:"inputs" json:"inputs"`
|
||||
}
|
||||
|
||||
type StepRun struct {
|
||||
Order int `bson:"order" json:"order"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
|
||||
Attempts int `bson:"attempts" json:"attempts"`
|
||||
ExitCode int `bson:"exit_code" json:"exit_code"`
|
||||
Stdout string `bson:"stdout" json:"stdout"`
|
||||
Stderr string `bson:"stderr" json:"stderr"`
|
||||
OutputEnv map[string]string `bson:"output_env" json:"output_env"`
|
||||
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type ServerRun struct {
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
|
||||
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
RunEnv map[string]string `bson:"run_env" json:"run_env"`
|
||||
Steps []StepRun `bson:"steps" json:"steps"`
|
||||
}
|
||||
|
||||
type WorkflowRun struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
RunID string `bson:"run_id" json:"run_id"`
|
||||
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"`
|
||||
Status string `bson:"status" json:"status"` // running|success|failed|cancelled
|
||||
TriggeredBy string `bson:"triggered_by" json:"triggered_by"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
ServerRuns []ServerRun `bson:"server_runs" json:"server_runs"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func encryptionKey() ([]byte, error) {
|
||||
raw := os.Getenv("KEY_ENCRYPTION_KEY")
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set")
|
||||
}
|
||||
key, err := hex.DecodeString(raw)
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return hex.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// decryptString reverses encryptString.
|
||||
func decryptString(ciphertextHex string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid ciphertext encoding")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decryption failed")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
|
||||
|
||||
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
|
||||
@@ -0,0 +1,192 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
type commandDispatcher struct {
|
||||
mu sync.RWMutex
|
||||
channels map[string]chan *pb.ServerCommand
|
||||
}
|
||||
|
||||
// Dispatcher is the singleton command dispatcher used by both the gRPC server
|
||||
// and the REST API to push commands to connected agents.
|
||||
var Dispatcher = &commandDispatcher{
|
||||
channels: make(map[string]chan *pb.ServerCommand),
|
||||
}
|
||||
|
||||
// Connect registers an agent's command channel. Returns the channel to drain.
|
||||
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||
ch := make(chan *pb.ServerCommand, 16)
|
||||
d.mu.Lock()
|
||||
d.channels[serverID] = ch
|
||||
d.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Disconnect removes the agent's channel on stream close.
|
||||
func (d *commandDispatcher) Disconnect(serverID string) {
|
||||
d.mu.Lock()
|
||||
delete(d.channels, serverID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// IsConnected reports whether an agent is currently holding a CommandStream.
|
||||
func (d *commandDispatcher) IsConnected(serverID string) bool {
|
||||
d.mu.RLock()
|
||||
_, ok := d.channels[serverID]
|
||||
d.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) error {
|
||||
d.mu.RLock()
|
||||
ch, ok := d.channels[serverID]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("agent for server %s is not connected", serverID)
|
||||
}
|
||||
select {
|
||||
case ch <- cmd:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("command queue full for server %s", serverID)
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchRunStep pushes a RunStepCmd to a server's agent. Caller must have
|
||||
// registered StepResults.Await(commandID) first.
|
||||
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
}
|
||||
|
||||
// KeyGenParams carries all options for a generate-key command.
|
||||
type KeyGenParams struct {
|
||||
Label string
|
||||
KeyType string
|
||||
KeySize int
|
||||
Passphrase string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag
|
||||
// and returns just the version number (e.g. "1.2.3").
|
||||
func GetLatestAgentVersion() (string, error) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch releases: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Gitea API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return "", fmt.Errorf("decode releases: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if strings.HasPrefix(r.TagName, "agent/v") {
|
||||
return strings.TrimPrefix(r.TagName, "agent/v"), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no agent release found")
|
||||
}
|
||||
|
||||
// DispatchUpdateAgent sends an update command to the named server's agent.
|
||||
// It fetches the latest version from Gitea and includes the download base URL.
|
||||
func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||
}
|
||||
|
||||
version, err := GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get latest version: %w", err)
|
||||
}
|
||||
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
cmdID := uuid.New().String()
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: cmdID,
|
||||
UpdateAgent: &pb.UpdateAgentCmd{
|
||||
Version: version,
|
||||
GiteaBaseURL: "https://" + giteaHost,
|
||||
},
|
||||
}
|
||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// DispatchApplyUpdates sends an apply-updates command to the named server's agent.
|
||||
func DispatchApplyUpdates(serverID string) error {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return fmt.Errorf("agent is not connected to the command stream")
|
||||
}
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
ApplyUpdates: &pb.ApplyUpdatesCmd{},
|
||||
}
|
||||
return Dispatcher.dispatch(serverID, cmd)
|
||||
}
|
||||
|
||||
// DispatchDeleteKey sends a delete-key command to the named server's agent.
|
||||
// It is best-effort: if the agent is offline the local files will remain until next connection.
|
||||
func DispatchDeleteKey(serverID, label string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
}
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
DeleteKey: &pb.DeleteKeyCmd{Label: label},
|
||||
}
|
||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||
// Non-fatal: agent will clean up files on next manual intervention or reinstall.
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchGenerateKey sends a generate-key command to the named server's agent.
|
||||
// Returns the command ID that can be used to correlate the agent's result.
|
||||
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||
}
|
||||
cmdID := uuid.New().String()
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: cmdID,
|
||||
GenerateKey: &pb.GenerateKeyCmd{
|
||||
Label: p.Label,
|
||||
KeyType: p.KeyType,
|
||||
KeySize: p.KeySize,
|
||||
Passphrase: p.Passphrase,
|
||||
Comment: p.Comment,
|
||||
},
|
||||
}
|
||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cmdID, nil
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/keymanager/server/internal/db"
|
||||
"github.com/mrhid6/keymanager/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -31,7 +31,12 @@ func computeFingerprint(pubKey string) string {
|
||||
return "MD5:" + strings.Join(pairs, ":")
|
||||
}
|
||||
|
||||
func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Key, error) {
|
||||
func setKeyMeta(k *models.Key) {
|
||||
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
|
||||
k.HasPassphrase = k.PassphraseEncrypted != ""
|
||||
}
|
||||
|
||||
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
|
||||
key := &models.Key{
|
||||
KeyID: uuid.NewString(),
|
||||
Label: label,
|
||||
@@ -41,6 +46,20 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
|
||||
GeneratedByServerID: generatedByServerID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if privateKey != "" {
|
||||
enc, err := encryptPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt private key: %w", err)
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -48,6 +67,7 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setKeyMeta(key)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
@@ -60,10 +80,46 @@ func GetKey(keyID string) (*models.Key, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setKeyMeta(&key)
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func ListKeys() ([]models.Key, error) {
|
||||
func GetPrivateKey(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.PrivateKeyEncrypted == "" {
|
||||
return "", fmt.Errorf("no private key stored for this key")
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
func ListKeys() ([]KeyWithCount, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -77,15 +133,39 @@ func ListKeys() ([]models.Key, error) {
|
||||
if err := cursor.All(ctx, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
|
||||
result := make([]KeyWithCount, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
setKeyMeta(&k)
|
||||
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
||||
"key_id": k.KeyID,
|
||||
"revoked_at": nil,
|
||||
})
|
||||
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteKey(keyID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID})
|
||||
return err
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if key.Source == "generated" && key.GeneratedByServerID != "" {
|
||||
DispatchDeleteKey(key.GeneratedByServerID, key.Label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
|
||||
@@ -198,12 +278,12 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
|
||||
|
||||
result := make([]AssignmentWithKey, 0, len(assignments))
|
||||
for _, a := range assignments {
|
||||
item := AssignmentWithKey{Assignment: a}
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err == nil {
|
||||
item.Key = &key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
setKeyMeta(&key)
|
||||
result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/keymanager/server/internal/db"
|
||||
"github.com/mrhid6/keymanager/server/internal/models"
|
||||
"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"
|
||||
)
|
||||
@@ -78,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()
|
||||
@@ -99,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
|
||||
@@ -134,14 +167,49 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateServerLastSeen(serverID string) error {
|
||||
// BackfillConsoleConfig sets default console_protocols/ports for a server that
|
||||
// predates the console feature (or was updated without re-registering). Servers
|
||||
// register only once via a single-use pre_reg_token, so Register() never runs
|
||||
// again to populate these fields — this runs on every sync as a cheap no-op
|
||||
// once the fields are present.
|
||||
func BackfillConsoleConfig(srv *models.Server) error {
|
||||
if srv == nil || len(srv.ConsoleProtocols) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
osType := srv.OSType
|
||||
if osType == "" {
|
||||
osType = OSTypeFromInfo(srv.OSInfo)
|
||||
}
|
||||
protocols, sshPort, rdpPort := defaultConsoleFields(osType)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": srv.ServerID, "console_protocols": bson.M{"$in": []interface{}{nil, bson.A{}}}},
|
||||
bson.M{"$set": bson.M{
|
||||
"os_type": osType,
|
||||
"console_protocols": protocols,
|
||||
"ssh_port": sshPort,
|
||||
"rdp_port": rdpPort,
|
||||
}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateServerLastSeen(serverID, agentVersion string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
fields := bson.M{"last_seen": now, "status": "active"}
|
||||
if agentVersion != "" {
|
||||
fields["agent_version"] = strings.TrimPrefix(agentVersion, "v")
|
||||
}
|
||||
_, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": serverID},
|
||||
bson.M{"$set": bson.M{"last_seen": now, "status": "active"}},
|
||||
bson.M{"$set": fields},
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -177,12 +245,64 @@ func DeleteServer(serverID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkOfflineServers(threshold time.Duration) error {
|
||||
func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
_, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": serverID},
|
||||
bson.M{"$set": bson.M{
|
||||
"available_updates": pkgs,
|
||||
"updates_checked_at": now,
|
||||
}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
type stepResultRegistry struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]chan *pb.StepResult
|
||||
}
|
||||
|
||||
// StepResults correlates agent StepResult replies back to the workflow runner
|
||||
// goroutine that dispatched the matching RunStepCmd, keyed by command_id.
|
||||
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
|
||||
|
||||
// Await registers interest in a command's result BEFORE the command is
|
||||
// dispatched, and returns a buffered channel that receives the single result.
|
||||
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
|
||||
ch := make(chan *pb.StepResult, 1)
|
||||
r.mu.Lock()
|
||||
r.pending[commandID] = ch
|
||||
r.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Cancel removes a pending waiter (call on timeout to avoid leaks).
|
||||
func (r *stepResultRegistry) Cancel(commandID string) {
|
||||
r.mu.Lock()
|
||||
delete(r.pending, commandID)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// Deliver routes an incoming StepResult to its waiter, if any.
|
||||
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
ch, ok := r.pending[res.CommandId]
|
||||
if ok {
|
||||
delete(r.pending, res.CommandId)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
ch <- res
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/keymanager/server/internal/db"
|
||||
"github.com/mrhid6/keymanager/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"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"
|
||||
)
|
||||
|
||||
const stepDispatchGrace = 15 * time.Second
|
||||
|
||||
// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a
|
||||
// background goroutine per target server (parallel fan-out). Returns run_id.
|
||||
func TriggerWorkflow(workflowID, actor string) (string, error) {
|
||||
wf, err := GetWorkflow(workflowID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(wf.TargetServerIDs) == 0 {
|
||||
return "", fmt.Errorf("workflow has no target servers")
|
||||
}
|
||||
if len(wf.Steps) == 0 {
|
||||
return "", fmt.Errorf("workflow has no steps")
|
||||
}
|
||||
|
||||
// Reject a concurrent run of the same workflow.
|
||||
ctx, cancel := wfCtx()
|
||||
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"})
|
||||
cancel()
|
||||
if running.Err() == nil {
|
||||
return "", fmt.Errorf("workflow already has a run in progress")
|
||||
}
|
||||
|
||||
resolved, err := resolveSteps(wf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
run := models.WorkflowRun{
|
||||
RunID: uuid.New().String(),
|
||||
WorkflowID: workflowID,
|
||||
Name: wf.Name,
|
||||
Steps: resolved,
|
||||
Status: "running",
|
||||
TriggeredBy: actor,
|
||||
StartedAt: time.Now(),
|
||||
ServerRuns: make([]models.ServerRun, 0, len(wf.TargetServerIDs)),
|
||||
}
|
||||
for _, sid := range wf.TargetServerIDs {
|
||||
hostname := sid
|
||||
if s, e := GetServer(sid); e == nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}}
|
||||
for _, rs := range resolved {
|
||||
sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}})
|
||||
}
|
||||
run.ServerRuns = append(run.ServerRuns, sr)
|
||||
}
|
||||
|
||||
ictx, icancel := wfCtx()
|
||||
defer icancel()
|
||||
if _, err := db.Col("workflow_runs").InsertOne(ictx, run); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
go executeRun(run.RunID)
|
||||
return run.RunID, nil
|
||||
}
|
||||
|
||||
// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the
|
||||
// library step and applying overrides.
|
||||
func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
out := make([]models.ResolvedStep, 0, len(wf.Steps))
|
||||
for _, ref := range wf.Steps {
|
||||
lib, err := getStep(ctx, ref.StepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]string{}
|
||||
for _, p := range lib.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
rs := models.ResolvedStep{
|
||||
Order: ref.Order,
|
||||
Name: lib.Name,
|
||||
Interpreter: lib.Interpreter,
|
||||
Script: lib.Script,
|
||||
SecretRefs: lib.SecretRefs,
|
||||
OnFailure: ref.OnFailure,
|
||||
MaxRetries: ref.MaxRetries,
|
||||
Inputs: inputs,
|
||||
}
|
||||
if ref.Overrides != nil {
|
||||
if ref.Overrides.Script != nil {
|
||||
rs.Script = *ref.Overrides.Script
|
||||
}
|
||||
if ref.Overrides.SecretRefs != nil {
|
||||
rs.SecretRefs = ref.Overrides.SecretRefs
|
||||
}
|
||||
}
|
||||
if rs.OnFailure == "" {
|
||||
rs.OnFailure = "stop"
|
||||
}
|
||||
out = append(out, rs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// executeRun fans out one goroutine per server run and waits for all to finish.
|
||||
func executeRun(runID string) {
|
||||
run, err := GetRun(runID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan int, len(run.ServerRuns))
|
||||
for i := range run.ServerRuns {
|
||||
go func(idx int) {
|
||||
runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
|
||||
done <- idx
|
||||
}(i)
|
||||
}
|
||||
for range run.ServerRuns {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Aggregate status.
|
||||
final, _ := GetRun(runID)
|
||||
status := "success"
|
||||
for _, sr := range final.ServerRuns {
|
||||
if sr.Status == "failed" {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, _ = db.Col("workflow_runs").UpdateOne(ctx, bson.M{"run_id": runID, "status": "running"},
|
||||
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
|
||||
}
|
||||
|
||||
// runServer executes the resolved steps sequentially on one server, threading
|
||||
// output env forward and applying per-step failure policy.
|
||||
func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
|
||||
now := time.Now()
|
||||
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now})
|
||||
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
fin := time.Now()
|
||||
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin})
|
||||
return
|
||||
}
|
||||
|
||||
runEnv := map[string]string{}
|
||||
allSecrets := map[string]string{}
|
||||
serverFailed := false
|
||||
|
||||
for i, step := range steps {
|
||||
startStep(runID, serverID, i, "running")
|
||||
var res *pb.StepResult
|
||||
attempts := 0
|
||||
maxAttempts := 1
|
||||
if step.OnFailure == "retry" {
|
||||
maxAttempts = step.MaxRetries + 1
|
||||
}
|
||||
|
||||
// Merge secrets into command env (kept out of persisted logs).
|
||||
secretVals := resolveSecrets(step.SecretRefs)
|
||||
for k, v := range secretVals {
|
||||
allSecrets[k] = v
|
||||
}
|
||||
cmdEnv := map[string]string{}
|
||||
for k, v := range step.Inputs {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range runEnv {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
for k, v := range secretVals {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
|
||||
for attempts < maxAttempts {
|
||||
attempts++
|
||||
res = dispatchAndWait(serverID, &pb.RunStepCmd{
|
||||
Interpreter: step.Interpreter,
|
||||
Script: step.Script,
|
||||
Env: cmdEnv,
|
||||
TimeoutSeconds: 0,
|
||||
})
|
||||
if res != nil && res.ExitCode == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mask secret values before persisting.
|
||||
stdout, stderr := "", ""
|
||||
exit := 1
|
||||
outEnv := map[string]string{} // masked copy, safe to persist
|
||||
if res != nil {
|
||||
stdout = maskSecrets(res.Stdout, allSecrets)
|
||||
stderr = maskSecrets(res.Stderr, allSecrets)
|
||||
exit = res.ExitCode
|
||||
for k, v := range res.OutputEnv {
|
||||
runEnv[k] = v // real, unmasked value threads forward to later steps
|
||||
outEnv[k] = maskSecrets(v, allSecrets)
|
||||
}
|
||||
} else {
|
||||
stderr = "[vantage] agent did not return a result"
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if exit != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
finishStep(runID, serverID, i, status, attempts, exit, stdout, stderr, outEnv)
|
||||
|
||||
if exit != 0 {
|
||||
switch step.OnFailure {
|
||||
case "continue":
|
||||
// keep going
|
||||
default: // "stop" or exhausted "retry"
|
||||
serverFailed = true
|
||||
}
|
||||
if serverFailed {
|
||||
markRemainingSkipped(runID, serverID, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fin := time.Now()
|
||||
status := "success"
|
||||
if serverFailed {
|
||||
status = "failed"
|
||||
}
|
||||
// Persist only a masked copy of runEnv; the real (unmasked) runEnv was already
|
||||
// used above to build cmdEnv for each step and must never be written to the DB.
|
||||
maskedRunEnv := make(map[string]string, len(runEnv))
|
||||
for k, v := range runEnv {
|
||||
maskedRunEnv[k] = maskSecrets(v, allSecrets)
|
||||
}
|
||||
setServerRun(runID, srvIdx, bson.M{
|
||||
"server_runs.$.status": status,
|
||||
"server_runs.$.finished_at": fin,
|
||||
"server_runs.$.run_env": maskedRunEnv,
|
||||
})
|
||||
}
|
||||
|
||||
// dispatchAndWait registers a waiter, dispatches the step, and blocks for the
|
||||
// result or a timeout.
|
||||
func dispatchAndWait(serverID string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
commandID := uuid.New().String()
|
||||
ch := StepResults.Await(commandID)
|
||||
if err := DispatchRunStep(serverID, commandID, cmd); err != nil {
|
||||
StepResults.Cancel(commandID)
|
||||
return &pb.StepResult{ExitCode: 1, Stderr: "[vantage] dispatch failed: " + err.Error()}
|
||||
}
|
||||
wait := time.Duration(cmd.TimeoutSeconds)*time.Second + stepDispatchGrace
|
||||
if cmd.TimeoutSeconds == 0 {
|
||||
wait = 30*time.Minute + stepDispatchGrace
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res
|
||||
case <-time.After(wait):
|
||||
StepResults.Cancel(commandID)
|
||||
return &pb.StepResult{ExitCode: 124, Stderr: "[vantage] timed out waiting for agent result"}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSecrets(refs []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, ref := range refs {
|
||||
// ref format "group/KEY"; resolve via RevealSecret.
|
||||
parts := strings.SplitN(ref, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if v, err := RevealSecret(parts[0], parts[1]); err == nil {
|
||||
out[parts[1]] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maskSecrets(s string, secrets map[string]string) string {
|
||||
for _, v := range secrets {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
s = strings.ReplaceAll(s, v, "***")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- run doc mutation helpers ----
|
||||
|
||||
func setServerRun(runID string, srvIdx int, set bson.M) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, _ = db.Col("workflow_runs").UpdateOne(ctx,
|
||||
bson.M{"run_id": runID, "server_runs.server_id": serverIDAt(runID, srvIdx)},
|
||||
bson.M{"$set": set})
|
||||
}
|
||||
|
||||
// serverIDAt returns the server_id at an index (positional operator needs a match).
|
||||
func serverIDAt(runID string, srvIdx int) string {
|
||||
r, err := GetRun(runID)
|
||||
if err != nil || srvIdx >= len(r.ServerRuns) {
|
||||
return ""
|
||||
}
|
||||
return r.ServerRuns[srvIdx].ServerID
|
||||
}
|
||||
|
||||
func startStep(runID, serverID string, order int, status string) {
|
||||
now := time.Now()
|
||||
updateStep(runID, serverID, order, bson.M{
|
||||
"server_runs.$[s].steps.$[t].status": status,
|
||||
"server_runs.$[s].steps.$[t].started_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func finishStep(runID, serverID string, order int, status string, attempts, exit int, stdout, stderr string, outEnv map[string]string) {
|
||||
now := time.Now()
|
||||
updateStep(runID, serverID, order, bson.M{
|
||||
"server_runs.$[s].steps.$[t].status": status,
|
||||
"server_runs.$[s].steps.$[t].attempts": attempts,
|
||||
"server_runs.$[s].steps.$[t].exit_code": exit,
|
||||
"server_runs.$[s].steps.$[t].stdout": stdout,
|
||||
"server_runs.$[s].steps.$[t].stderr": stderr,
|
||||
"server_runs.$[s].steps.$[t].output_env": outEnv,
|
||||
"server_runs.$[s].steps.$[t].finished_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func markRemainingSkipped(runID, serverID string, fromOrder int) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, _ = db.Col("workflow_runs").UpdateMany(ctx,
|
||||
bson.M{"run_id": runID},
|
||||
bson.M{"$set": bson.M{"server_runs.$[s].steps.$[t].status": "skipped"}},
|
||||
options.UpdateMany().SetArrayFilters([]interface{}{
|
||||
bson.M{"s.server_id": serverID},
|
||||
bson.M{"t.order": bson.M{"$gte": fromOrder}, "t.status": "queued"},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func updateStep(runID, serverID string, order int, set bson.M) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, _ = db.Col("workflow_runs").UpdateOne(ctx,
|
||||
bson.M{"run_id": runID},
|
||||
bson.M{"$set": set},
|
||||
options.UpdateOne().SetArrayFilters([]interface{}{
|
||||
bson.M{"s.server_id": serverID},
|
||||
bson.M{"t.order": order},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- reads ----
|
||||
|
||||
func GetRun(runID string) (*models.WorkflowRun, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var r models.WorkflowRun
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&r)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("run not found")
|
||||
}
|
||||
return &r, err
|
||||
}
|
||||
|
||||
func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID},
|
||||
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
runs := []models.WorkflowRun{}
|
||||
if err := cur.All(ctx, &runs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func CancelRun(runID string) error {
|
||||
now := time.Now()
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_runs").UpdateOne(ctx,
|
||||
bson.M{"run_id": runID, "status": "running"},
|
||||
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func wfCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
func EnsureWorkflowIndexes() error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "step_id", Value: 1}}, Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "workflow_id", Value: 1}}, Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Col("workflow_runs").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Steps ----
|
||||
|
||||
func ListSteps() ([]models.WorkflowStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{},
|
||||
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
steps := []models.WorkflowStep{}
|
||||
if err := cur.All(ctx, &steps); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
s.StepID = uuid.New().String()
|
||||
s.CreatedAt = time.Now()
|
||||
s.UpdatedAt = s.CreatedAt
|
||||
if s.DeclaredOutputs == nil {
|
||||
s.DeclaredOutputs = []string{}
|
||||
}
|
||||
if s.SecretRefs == nil {
|
||||
s.SecretRefs = []string{}
|
||||
}
|
||||
if s.DeclaredInputs == nil {
|
||||
s.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
if _, err := db.Col("workflow_steps").InsertOne(ctx, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateStep(stepID string, s models.WorkflowStep) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
"interpreter": s.Interpreter,
|
||||
"script": s.Script,
|
||||
"declared_outputs": s.DeclaredOutputs,
|
||||
"declared_inputs": s.DeclaredInputs,
|
||||
"secret_refs": s.SecretRefs,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteStep(stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range wfs {
|
||||
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == stepID {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ref)
|
||||
}
|
||||
for i := range kept {
|
||||
kept[i].Order = i
|
||||
}
|
||||
if _, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": w.WorkflowID},
|
||||
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
|
||||
var s models.WorkflowStep
|
||||
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("step %s not found", stepID)
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
// ---- Workflows ----
|
||||
|
||||
func ListWorkflows() ([]models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{},
|
||||
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
wfs := []models.Workflow{}
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func GetWorkflow(id string) (*models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var w models.Workflow
|
||||
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("workflow not found")
|
||||
}
|
||||
return &w, err
|
||||
}
|
||||
|
||||
func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
w.WorkflowID = uuid.New().String()
|
||||
w.CreatedAt = time.Now()
|
||||
w.UpdatedAt = w.CreatedAt
|
||||
if w.TargetServerIDs == nil {
|
||||
w.TargetServerIDs = []string{}
|
||||
}
|
||||
if w.Steps == nil {
|
||||
w.Steps = []models.WorkflowStepRef{}
|
||||
}
|
||||
if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func UpdateWorkflow(id string, w models.Workflow) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{
|
||||
"name": w.Name,
|
||||
"target_server_ids": w.TargetServerIDs,
|
||||
"steps": w.Steps,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteWorkflow(id string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id})
|
||||
return err
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,97 @@ function AssignModal({
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateKeyCard({ keyId }: { keyId: string }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function reveal() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getPrivateKey(keyId);
|
||||
setPrivateKey(res.private_key);
|
||||
setRevealed(true);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!privateKey) return;
|
||||
const blob = new Blob([privateKey], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${keyId}.pem`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!privateKey) return;
|
||||
await navigator.clipboard.writeText(privateKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Private Key</CardTitle>
|
||||
{revealed && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copy}
|
||||
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copied ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={download}
|
||||
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
Download .pem
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!revealed ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<p className="text-center text-xs text-text-tertiary">
|
||||
Stored encrypted (AES-256-GCM). Click to decrypt and display.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Reveal Private Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
|
||||
{privateKey}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeyDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -252,6 +343,8 @@ export default function KeyDetailPage() {
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
|
||||
+30
-2
@@ -11,9 +11,11 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
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()),
|
||||
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
onClose();
|
||||
@@ -52,10 +54,36 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
rows={4}
|
||||
rows={3}
|
||||
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">
|
||||
Private Key{" "}
|
||||
<span className="text-text-tertiary font-normal">(optional — stored AES-256-GCM encrypted)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
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">
|
||||
|
||||
+10
-7
@@ -1,10 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KeyManager",
|
||||
title: "Vantage",
|
||||
description: "Self-hosted SSH key management",
|
||||
};
|
||||
|
||||
@@ -17,12 +18,14 @@ export default function RootLayout({
|
||||
<html lang="en" className="dark">
|
||||
<body className="bg-background text-text-primary">
|
||||
<Providers>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AuthProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"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;
|
||||
setScale: (scale: number) => void;
|
||||
resize: (width: number, height: number) => 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);
|
||||
const [pending, setPending] = useState<{ token: string; wsPath: string } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dprRef = useRef(1);
|
||||
|
||||
// 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);
|
||||
// Defer the actual openConsole until after the form is unmounted so the
|
||||
// container measures at full height (see effect below).
|
||||
setPending({ token, wsPath: ws_path });
|
||||
setConnected(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to connect");
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after `connected` flips and the connection form is gone, so the
|
||||
// container now occupies its full flex height.
|
||||
useEffect(() => {
|
||||
if (!connected || !pending || !containerRef.current) return;
|
||||
|
||||
const wsProto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsUrl = `${wsProto}://${location.host}${pending.wsPath}`;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
dprRef.current = dpr;
|
||||
// Request the remote at device-pixel resolution with a fixed 96 dpi, then
|
||||
// scale the display back down by dpr. Folding dpr into `dpi` instead makes
|
||||
// the remote enlarge everything, which reads as a zoomed-in view.
|
||||
const connectData =
|
||||
`token=${encodeURIComponent(pending.token)}` +
|
||||
`&width=${Math.floor(rect.width * dpr)}` +
|
||||
`&height=${Math.floor(rect.height * dpr)}` +
|
||||
`&dpi=96`;
|
||||
|
||||
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData);
|
||||
connectionRef.current.setScale(zoom / dpr);
|
||||
setPending(null);
|
||||
}, [connected, pending]);
|
||||
|
||||
// Apply zoom live without reconnecting: resize the remote to a resolution
|
||||
// that, once scaled to fit the container, yields the requested zoom. Higher
|
||||
// zoom = fewer remote pixels rendered larger. Display always fits the
|
||||
// container exactly, so no scrollbars appear.
|
||||
useEffect(() => {
|
||||
if (!connectionRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const dpr = dprRef.current;
|
||||
const remoteW = Math.floor((rect.width * dpr) / zoom);
|
||||
const remoteH = Math.floor((rect.height * dpr) / zoom);
|
||||
connectionRef.current.resize(remoteW, remoteH);
|
||||
connectionRef.current.setScale(zoom / dpr);
|
||||
}, [zoom]);
|
||||
|
||||
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>
|
||||
<label className="text-sm text-text-secondary">Scale</label>
|
||||
<select
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="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={0.5}>50%</option>
|
||||
<option value={0.75}>75%</option>
|
||||
<option value={1}>100%</option>
|
||||
<option value={1.25}>125%</option>
|
||||
<option value={1.5}>150%</option>
|
||||
<option value={2}>200%</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="min-h-[500px] flex-1 overflow-hidden rounded-lg border border-border bg-black"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+500
-183
@@ -4,216 +4,533 @@ 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, ServerStatus } from "@/lib/api";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } 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";
|
||||
}
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "offline":
|
||||
return "danger";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
};
|
||||
|
||||
const { data: server, isLoading, error } = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const DEFAULT_SIZE: Record<string, number> = {
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
};
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: () => api.generateKeyForServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
if (error || !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.
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
|
||||
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve — shorter keys, good compatibility.</p>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={(e) => setKeySize(Number(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"
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
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-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function UpdatesModal({
|
||||
updates,
|
||||
onClose,
|
||||
onApply,
|
||||
isApplying,
|
||||
applySuccess,
|
||||
}: {
|
||||
updates: PackageUpdate[];
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
isApplying: boolean;
|
||||
applySuccess: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">{updates.length} package{updates.length !== 1 ? "s" : ""} available</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={isGenerating}
|
||||
onClick={() => generateKey()}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={isDeleting}
|
||||
onClick={() => deleteServer()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td><span className="font-medium font-mono text-sm">{u.name}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-text-secondary">{u.current_version || "—"}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-success">{u.new_version}</span></Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
|
||||
{server.keys?.filter(k => !k.revoked_at).length ?? 0} active
|
||||
</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">Manage Keys →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{assignment.key.fingerprint}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>
|
||||
{assignment.key.source}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
|
||||
{assignment.revoked_at ? "revoked" : "active"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">
|
||||
{formatDate(assignment.assigned_at)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
|
||||
const {
|
||||
data: server,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
setShowGenerateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
|
||||
mutationFn: () => api.applyUpdates(serverId),
|
||||
onSuccess: () => {
|
||||
setApplySuccess(true);
|
||||
setTimeout(() => {
|
||||
setApplySuccess(false);
|
||||
setShowUpdatesModal(false);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !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="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>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteServer()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "—"}</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && <Badge variant="warning">update available</Badge>}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && <Badge variant="success">up to date</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span> <span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand(server.os_info));
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed SSH Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{server.keys?.filter((k) => !k.revoked_at).length ?? 0} active</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage Keys →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys
|
||||
.filter((a) => a.key)
|
||||
.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
export default function NewServerPage() {
|
||||
const [result, setResult] = useState<NewServerResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [os, setOs] = useState<"linux" | "windows">("linux");
|
||||
|
||||
const { mutate: createServer, isPending, error } = useMutation({
|
||||
mutationFn: api.createServer,
|
||||
onSuccess: (data) => setResult(data),
|
||||
});
|
||||
|
||||
const command = os === "windows" ? result?.install_command_ps : result?.install_command;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!result?.install_command) return;
|
||||
await navigator.clipboard.writeText(result.install_command);
|
||||
if (!command) return;
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
@@ -26,7 +29,7 @@ export default function NewServerPage() {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Generate an install command to register a new server with the KeyManager agent.
|
||||
Generate an install command to register a new server with the Vantage agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +42,7 @@ export default function NewServerPage() {
|
||||
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
|
||||
Click the button below to generate a one-time install command. The command
|
||||
contains a short-lived token (valid for 1 hour) that registers your server
|
||||
and installs the KeyManager agent automatically.
|
||||
and installs the Vantage agent automatically.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -65,14 +68,34 @@ export default function NewServerPage() {
|
||||
Valid for 1 hour
|
||||
</span>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["linux", "windows"] as const).map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => { setOs(o); setCopied(false); }}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
os === o
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:
|
||||
{os === "windows" ? (
|
||||
<>Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):</>
|
||||
) : (
|
||||
<>Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
|
||||
<span className="text-accent">$</span>{" "}
|
||||
<span className="text-text-primary">{result.install_command}</span>
|
||||
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span>{" "}
|
||||
<span className="text-text-primary">{command}</span>
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -111,8 +134,8 @@ export default function NewServerPage() {
|
||||
{[
|
||||
"The install script detects your CPU architecture (amd64 / arm64)",
|
||||
"Downloads and verifies the latest agent binary from the Gitea release",
|
||||
"Writes /etc/keymanager/config.yaml with the server ID and token",
|
||||
"Installs and starts the keymanager-agent systemd service",
|
||||
"Writes /etc/vantage/config.yaml with the server ID and token",
|
||||
"Installs and starts the vantage-agent systemd service",
|
||||
"The agent calls Register() to obtain a persistent auth token",
|
||||
"The server status changes to active on the first successful sync",
|
||||
].map((step, i) => (
|
||||
|
||||
+40
-14
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import { EditStepModal } from "@/components/workflows/EditStepModal";
|
||||
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
|
||||
|
||||
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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
|
||||
|
||||
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
|
||||
const isBash = interpreter === "bash";
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${
|
||||
isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"
|
||||
}`}
|
||||
>
|
||||
{isBash ? "bash" : "pwsh"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowBuilder() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params.id;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [wf, setWf] = useState<Workflow | null>(null);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
|
||||
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
|
||||
const [editStepOpen, setEditStepOpen] = useState(false);
|
||||
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
|
||||
|
||||
const { data: loaded } = useQuery({
|
||||
queryKey: ["workflow", id],
|
||||
queryFn: () => api.getWorkflow(id),
|
||||
});
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
const { data: secretGroups } = useQuery({
|
||||
queryKey: ["secret-groups"],
|
||||
queryFn: api.listSecretGroups,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && !wf) setWf(loaded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loaded]);
|
||||
|
||||
// Lazily fetch the keys for every secret group so the inspector's
|
||||
// secret-ref checklist can offer "group/KEY" options.
|
||||
useEffect(() => {
|
||||
if (!secretGroups) return;
|
||||
secretGroups.forEach((g: SecretGroupSummary) => {
|
||||
if (groupKeys[g.group] !== undefined) return;
|
||||
api.getSecretGroup(g.group)
|
||||
.then((res) =>
|
||||
setGroupKeys((prev) => ({
|
||||
...prev,
|
||||
[g.group]: res.secrets.map((s) => s.key),
|
||||
})),
|
||||
)
|
||||
.catch(() => {
|
||||
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [secretGroups]);
|
||||
|
||||
if (!wf) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
|
||||
|
||||
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
const selectedRef = selected !== null ? sortedSteps[selected] : null;
|
||||
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
|
||||
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(id, wf);
|
||||
if (!updated || !Array.isArray(updated.steps)) {
|
||||
setError("Save failed: server returned an unexpected response.");
|
||||
return;
|
||||
}
|
||||
setWf(updated);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { run_id } = await api.runWorkflow(id);
|
||||
router.push(`/workflows/${id}/runs/${run_id}`);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resequence = (steps: WorkflowStepRef[]) => steps.map((r, i) => ({ ...r, order: i }));
|
||||
|
||||
const insertLibStep = (stepId: string, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
next.splice(pos, 0, { step_id: stepId, order: 0, on_failure: "stop", max_retries: 0 });
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
};
|
||||
|
||||
const moveStep = (from: number, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
const [item] = next.splice(from, 1);
|
||||
const target = from < pos ? pos - 1 : pos;
|
||||
next.splice(target, 0, item);
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
if (selected === from) setSelected(target);
|
||||
else if (selected !== null) {
|
||||
if (from < selected && target >= selected) setSelected(selected - 1);
|
||||
else if (from > selected && target <= selected) setSelected(selected + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, pos: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(null);
|
||||
const raw = e.dataTransfer.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let payload: DragPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "lib") {
|
||||
insertLibStep(payload.stepId, pos);
|
||||
} else if (payload.kind === "move") {
|
||||
moveStep(payload.from, pos);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
|
||||
});
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
|
||||
setWf({ ...wf, steps: remaining });
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const toggleSecretRef = (ref: string) => {
|
||||
if (selectedIdxInWf === -1 || !selectedRef) return;
|
||||
const current = selectedRef.overrides?.secret_refs ?? [];
|
||||
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
|
||||
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
|
||||
};
|
||||
|
||||
const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash");
|
||||
const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell");
|
||||
|
||||
const upstreamOutputsFor = (i: number) =>
|
||||
Array.from(new Set(sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? [])));
|
||||
|
||||
const DropZone = ({ pos }: { pos: number }) => (
|
||||
<div
|
||||
className={`h-3 w-full transition-all ${dragOverZone === pos ? "h-8 rounded bg-signal/15 border border-dashed border-signal/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(pos);
|
||||
}}
|
||||
onDragLeave={() => setDragOverZone((z) => (z === pos ? null : z))}
|
||||
onDrop={(e) => handleDrop(e, pos)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-text-secondary">Workflows /</span>
|
||||
<span className="font-medium text-text-primary">{wf.name}</span>
|
||||
<span className="text-text-secondary">· draft</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">
|
||||
{wf.target_server_ids.length} servers
|
||||
</span>
|
||||
<Link
|
||||
href={`/workflows/${id}/runs`}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Runs
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" loading={saving} onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={running}
|
||||
onClick={run}
|
||||
className="bg-signal text-signal-ink border-transparent hover:bg-signal/90"
|
||||
>
|
||||
Run workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid h-[calc(100vh-53px)] grid-cols-[264px_1fr_320px]">
|
||||
{/* LEFT: library */}
|
||||
<aside className="overflow-auto border-r border-border bg-surface p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wide text-text-secondary">Step Library</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingStep(null);
|
||||
setEditStepOpen(true);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
className={`${inputClass} mb-3`}
|
||||
placeholder="Search steps…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{bashSteps.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-1 mt-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
|
||||
Shared · Bash
|
||||
</h3>
|
||||
{bashSteps.map((s) => (
|
||||
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
|
||||
setEditingStep(s);
|
||||
setEditStepOpen(true);
|
||||
}} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pwshSteps.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-1 mt-3 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
|
||||
Shared · PowerShell
|
||||
</h3>
|
||||
{pwshSteps.map((s) => (
|
||||
<LibraryCard key={s.step_id} step={s} onAdd={() => insertLibStep(s.step_id, sortedSteps.length)} onEdit={() => {
|
||||
setEditingStep(s);
|
||||
setEditStepOpen(true);
|
||||
}} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredLibrary.length === 0 && <p className="mt-2 text-xs text-text-secondary">No steps found.</p>}
|
||||
</aside>
|
||||
|
||||
{/* CENTER: canvas */}
|
||||
<main
|
||||
className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8"
|
||||
>
|
||||
<div className="mx-auto flex w-[340px] flex-col items-center">
|
||||
<DropZone pos={0} />
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
const outs = upstreamOutputsFor(i);
|
||||
const script = ref.overrides?.script ?? lib?.script ?? "";
|
||||
const wfIdx = wf.steps.indexOf(ref);
|
||||
const isSelected = selected === i;
|
||||
return (
|
||||
<div key={wfIdx} className="w-full">
|
||||
{i > 0 && (
|
||||
<div className="flex flex-col items-center py-1">
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
{outs.length > 0 && (
|
||||
<div className="flex w-fit max-w-[300px] flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-signal/55 bg-surface px-3 py-1">
|
||||
<span className="text-[10px] uppercase text-text-secondary">passes</span>
|
||||
{outs.map((o) => (
|
||||
<span
|
||||
key={o}
|
||||
className="rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink"
|
||||
>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i }));
|
||||
}}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${
|
||||
isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
|
||||
{lib && <ShellBadge interpreter={lib.interpreter} />}
|
||||
</div>
|
||||
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
|
||||
{script.slice(0, 200)}
|
||||
</pre>
|
||||
</div>
|
||||
<DropZone pos={i + 1} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedSteps.length === 0 && (
|
||||
<button
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(e, 0)}
|
||||
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
|
||||
>
|
||||
+ Drop a step here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* RIGHT: inspector */}
|
||||
<aside className="overflow-auto border-l border-border bg-surface p-4">
|
||||
{selected === null || !selectedRef ? (
|
||||
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
|
||||
Step {selected + 1} · Inspector
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLib && <ShellBadge interpreter={selectedLib.interpreter} />}
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose
|
||||
it to later steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(selectedLib?.declared_inputs ?? []).length > 0 && (
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{selectedLib?.declared_inputs.map((param) => (
|
||||
<div key={param.name}>
|
||||
<div className="mb-1 font-mono text-xs text-text-primary">{param.name}</div>
|
||||
{param.description && (
|
||||
<div className="mb-1 text-[11px] text-text-secondary">{param.description}</div>
|
||||
)}
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={param.default}
|
||||
value={selectedRef.inputs?.[param.name] ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
inputs: { ...selectedRef.inputs, [param.name]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs · from upstream</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{upstreamOutputsFor(selected).length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No upstream outputs.</p>
|
||||
)}
|
||||
{upstreamOutputsFor(selected).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-surface-2 border border-border px-2 py-0.5 font-mono text-[11px] text-text-primary">
|
||||
<span className="text-[9px] uppercase text-text-secondary">in</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Outputs · to $WORKFLOW_ENV</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(selectedLib?.declared_outputs ?? []).length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No declared outputs.</p>
|
||||
)}
|
||||
{(selectedLib?.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
<span className="text-[9px] uppercase">out</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Secret refs</label>
|
||||
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
|
||||
{secretGroups?.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
|
||||
{(groupKeys[g.group] ?? []).map((key) => {
|
||||
const ref = `${g.group}/${key}`;
|
||||
const checked = (selectedRef.overrides?.secret_refs ?? []).includes(ref);
|
||||
return (
|
||||
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-signal"
|
||||
checked={checked}
|
||||
onChange={() => toggleSecretRef(ref)}
|
||||
/>
|
||||
<span className="font-mono">{key}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
|
||||
</div>
|
||||
))}
|
||||
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">On failure</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={selectedRef.on_failure}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
on_failure: e.target.value as WorkflowStepRef["on_failure"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="stop">Stop workflow</option>
|
||||
<option value="continue">Continue</option>
|
||||
<option value="retry">Retry</option>
|
||||
</select>
|
||||
{selectedRef.on_failure === "retry" && (
|
||||
<div className="mt-2">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={selectedRef.max_retries}
|
||||
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
|
||||
Remove from workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<EditWorkflowModal open={editWorkflowOpen} workflow={wf} onSaved={(w) => setWf(w)} onClose={() => setEditWorkflowOpen(false)} />
|
||||
<EditStepModal
|
||||
key={editingStep?.step_id ?? "new"}
|
||||
open={editStepOpen}
|
||||
step={editingStep}
|
||||
onClose={() => {
|
||||
setEditStepOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["steps"] });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () => void; onEdit: () => void }) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "lib", stepId: step.step_id }));
|
||||
}}
|
||||
onClick={onAdd}
|
||||
className="group relative mb-2 cursor-grab rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-signal/50"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-text-secondary">⠿</span>
|
||||
<ShellBadge interpreter={step.interpreter} />
|
||||
<span className="text-sm font-medium text-text-primary">{step.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
|
||||
title="Edit step"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ServerRun, StepRun } from "@/lib/api";
|
||||
import { Button, Badge, Card } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "accent",
|
||||
queued: "neutral",
|
||||
skipped: "neutral",
|
||||
cancelled: "warning",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export default function RunDetail() {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: run, isLoading } = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
const cancel = async () => {
|
||||
await api.cancelRun(runId);
|
||||
queryClient.invalidateQueries({ queryKey: ["run", runId] });
|
||||
};
|
||||
|
||||
if (isLoading || !run) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{run.name}</h1>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Run {run.run_id.slice(0, 8)}</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</p>
|
||||
</div>
|
||||
{run.status === "running" && (
|
||||
<Button variant="danger" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{run.server_runs.map((sr: ServerRun) => (
|
||||
<Card key={sr.server_id}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">{sr.hostname}</span>
|
||||
<StatusBadge status={sr.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sr.steps.map((st: StepRun) => (
|
||||
<details
|
||||
key={st.order}
|
||||
className="rounded-lg border border-border bg-surface-2 p-2"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-2">
|
||||
<span className="text-sm text-text-primary">{st.name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">
|
||||
attempts: {st.attempts}
|
||||
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
|
||||
</span>
|
||||
<StatusBadge status={st.status} />
|
||||
</span>
|
||||
</summary>
|
||||
{(st.stdout || st.stderr) && (
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary">
|
||||
{st.stdout}
|
||||
{st.stderr ? `\n${st.stderr}` : ""}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
{sr.steps.length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No steps yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{run.server_runs.length === 0 && (
|
||||
<p className="text-text-secondary">No servers targeted by this run.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowRun } from "@/lib/api";
|
||||
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "warning",
|
||||
cancelled: "neutral",
|
||||
queued: "neutral",
|
||||
};
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
|
||||
const { data: runs, isLoading, error } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Back to builder
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
|
||||
</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 runs. Is the backend running?</div>
|
||||
) : runs && runs.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Run</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Started</Th>
|
||||
<Th>By</Th>
|
||||
<Th>Servers</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{runs.map((r: WorkflowRun) => (
|
||||
<Tr key={r.run_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/workflows/${id}/runs/${r.run_id}`}
|
||||
className="font-mono text-text-primary hover:text-signal"
|
||||
>
|
||||
{r.run_id.slice(0, 8)}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusVariant[r.status] ?? "neutral"}>{r.status}</Badge>
|
||||
</Td>
|
||||
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
|
||||
<Td className="text-text-secondary">{r.triggered_by}</Td>
|
||||
<Td className="text-text-secondary">{r.server_runs.length}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const qc = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workflows, isLoading, error: loadError } = useQuery({
|
||||
queryKey: ["workflows"],
|
||||
queryFn: api.listWorkflows,
|
||||
});
|
||||
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => api.createWorkflow({ name: "Untitled workflow", target_server_ids: [], steps: [] }),
|
||||
onSuccess: (workflow) => {
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
router.push(`/workflows/${workflow.workflow_id}`);
|
||||
},
|
||||
onError: (err) => setError((err as Error).message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workflows</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{workflows?.length ?? 0} workflow{workflows?.length !== 1 ? "s" : ""} · run reusable steps across servers
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" loading={isPending} onClick={() => create()}>
|
||||
<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 Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{error}
|
||||
</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>
|
||||
) : loadError ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load workflows. Is the backend running?</div>
|
||||
) : workflows && workflows.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Targets</Th>
|
||||
<Th>Steps</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{workflows.map((w: Workflow) => (
|
||||
<Tr key={w.workflow_id}>
|
||||
<Td>
|
||||
<span className="font-medium text-text-primary">{w.name}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary">
|
||||
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary">{w.steps.length}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">Runs</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">No workflows yet.</p>
|
||||
<Button variant="primary" size="sm" className="mt-4" loading={isPending} onClick={() => create()}>
|
||||
Create your first workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
|
||||
|
||||
export interface User {
|
||||
user_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
authEnabled: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({ user: null, authEnabled: false });
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/auth/me", { credentials: "include" })
|
||||
.then(async (res) => {
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.auth_enabled === false) {
|
||||
setAuthEnabled(false);
|
||||
} else {
|
||||
setAuthEnabled(true);
|
||||
setUser(data as User);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend unreachable — don't block the UI
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, authEnabled }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -26,13 +27,51 @@ 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 WorkflowIcon() {
|
||||
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 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.012 1.244h3.22a2.25 2.25 0 002.012-1.244l.256-.512a2.25 2.25 0 012.012-1.244h3.86M12 3v8.25m0 0l-3-3m3 3l3-3" />
|
||||
</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: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, authEnabled } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
|
||||
@@ -42,7 +81,7 @@ export function Sidebar() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-text-primary">KeyManager</span>
|
||||
<span className="text-base font-semibold text-text-primary">Vantage</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
@@ -71,7 +110,23 @@ export function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<p className="text-xs text-text-secondary">KeyManager v1.0</p>
|
||||
{authEnabled && user && (
|
||||
<div className="mb-3">
|
||||
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
|
||||
<p className="truncate text-xs text-text-secondary">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-text-secondary">Vantage v1.0</p>
|
||||
{authEnabled && user && (
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="text-xs text-text-secondary transition-colors hover:text-danger"
|
||||
>
|
||||
Logout
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { Button } from "./Button";
|
||||
export { Badge } from "./Badge";
|
||||
export { Card, CardHeader, CardTitle } from "./Card";
|
||||
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
|
||||
export { Modal } from "./Modal";
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } 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-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(step?.name ?? "");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
|
||||
const [script, setScript] = useState(step?.script ?? "");
|
||||
const [outputs, setOutputs] = useState<string[]>(step?.declared_outputs ?? []);
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [newOut, setNewOut] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NOTE: because state is seeded from props, render the modal conditionally
|
||||
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const payload: Partial<WorkflowStep> = {
|
||||
name: name.trim(), description: step?.description ?? "", interpreter, script,
|
||||
declared_outputs: outputs, declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
|
||||
secret_refs: step?.secret_refs ?? [],
|
||||
};
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{outputs.map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}<button onClick={() => setOutputs(outputs.filter((x) => x !== o))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className={inputClass} placeholder="OUTPUT_NAME" value={newOut} onChange={(e) => setNewOut(e.target.value)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { if (newOut.trim()) { setOutputs([...outputs, newOut.trim()]); setNewOut(""); } }}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{inputs.map((inp, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setTargets(workflow.target_server_ids);
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.includes(s.server_id);
|
||||
return (
|
||||
<label key={s.server_id} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+330
-4
@@ -1,6 +1,12 @@
|
||||
export type ServerStatus = "pending" | "active" | "offline";
|
||||
export type KeySource = "uploaded" | "generated";
|
||||
|
||||
export interface PackageUpdate {
|
||||
name: string;
|
||||
current_version?: string;
|
||||
new_version: string;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
server_id: string;
|
||||
@@ -8,8 +14,27 @@ export interface Server {
|
||||
ip_address: string;
|
||||
os_info: string;
|
||||
status: ServerStatus;
|
||||
agent_version?: string;
|
||||
last_seen: string;
|
||||
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 {
|
||||
@@ -20,6 +45,8 @@ export interface Key {
|
||||
fingerprint: string;
|
||||
source: KeySource;
|
||||
generated_by_server_id?: string;
|
||||
has_private_key: boolean;
|
||||
has_passphrase?: boolean;
|
||||
created_at: string;
|
||||
assigned_count?: number;
|
||||
}
|
||||
@@ -32,10 +59,69 @@ 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;
|
||||
install_command: string;
|
||||
install_command_ps: string;
|
||||
}
|
||||
|
||||
export interface GenerateKeyOptions {
|
||||
label: string;
|
||||
key_type: "ed25519" | "rsa" | "ecdsa";
|
||||
key_size?: number;
|
||||
passphrase?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface KeyWithAssignments extends Key {
|
||||
@@ -46,6 +132,73 @@ export interface ServerWithKeys extends Server {
|
||||
keys: (Assignment & { key: Key })[];
|
||||
}
|
||||
|
||||
export interface InputParam {
|
||||
name: string;
|
||||
default: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
step_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
interpreter: "bash" | "powershell";
|
||||
script: string;
|
||||
declared_outputs: string[];
|
||||
declared_inputs: InputParam[];
|
||||
secret_refs: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowStepRef {
|
||||
step_id: string;
|
||||
order: number;
|
||||
on_failure: "stop" | "continue" | "retry";
|
||||
max_retries: number;
|
||||
overrides?: { script?: string; secret_refs?: string[] };
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
workflow_id: string;
|
||||
name: string;
|
||||
target_server_ids: string[];
|
||||
steps: WorkflowStepRef[];
|
||||
}
|
||||
|
||||
export interface StepRun {
|
||||
order: number;
|
||||
name: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
exit_code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
output_env: Record<string, string>;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
}
|
||||
|
||||
export interface ServerRun {
|
||||
server_id: string;
|
||||
hostname: string;
|
||||
status: string;
|
||||
run_env: Record<string, string>;
|
||||
steps: StepRun[];
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
run_id: string;
|
||||
workflow_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
triggered_by: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
server_runs: ServerRun[];
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -58,6 +211,7 @@ class ApiError extends Error {
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
@@ -95,12 +249,98 @@ export const api = {
|
||||
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
generateKeyForServer(serverId: string): Promise<{ key_id: string }> {
|
||||
return request<{ key_id: string }>(`/servers/${serverId}/generate-key`, {
|
||||
generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
|
||||
return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(opts),
|
||||
});
|
||||
},
|
||||
|
||||
getUpdateCommand(osInfo?: string): string {
|
||||
if (osInfo && osInfo.toLowerCase().includes("windows")) {
|
||||
return `irm "${window.location.origin}/update.ps1" | iex`;
|
||||
}
|
||||
return `curl -fsSL "${window.location.origin}/update" | bash`;
|
||||
},
|
||||
|
||||
getLatestAgentVersion(): Promise<{ version: string }> {
|
||||
return request<{ version: string }>("/agent/latest-version");
|
||||
},
|
||||
|
||||
updateAgent(serverId: string): Promise<{ message: string; version: string }> {
|
||||
return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
applyUpdates(serverId: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/servers/${serverId}/apply-updates`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
// 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");
|
||||
@@ -110,13 +350,22 @@ export const api = {
|
||||
return request<KeyWithAssignments>(`/keys/${keyId}`);
|
||||
},
|
||||
|
||||
uploadKey(label: string, public_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 }),
|
||||
body: JSON.stringify({
|
||||
label,
|
||||
public_key,
|
||||
private_key: private_key || undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
|
||||
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
|
||||
},
|
||||
|
||||
deleteKey(keyId: string): Promise<void> {
|
||||
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
|
||||
},
|
||||
@@ -134,4 +383,81 @@ export const api = {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
|
||||
// Console
|
||||
connectConsole(body: ConsoleConnectRequest): Promise<ConsoleConnectResponse> {
|
||||
return request<ConsoleConnectResponse>("/console/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
// Steps
|
||||
listSteps(): Promise<WorkflowStep[]> {
|
||||
return request<WorkflowStep[]>("/steps");
|
||||
},
|
||||
|
||||
createStep(s: Partial<WorkflowStep>): Promise<WorkflowStep> {
|
||||
return request<WorkflowStep>("/steps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(s),
|
||||
});
|
||||
},
|
||||
|
||||
updateStep(stepId: string, s: Partial<WorkflowStep>): Promise<WorkflowStep> {
|
||||
return request<WorkflowStep>(`/steps/${stepId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(s),
|
||||
});
|
||||
},
|
||||
|
||||
deleteStep(stepId: string): Promise<void> {
|
||||
return request<void>(`/steps/${stepId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
// Workflows
|
||||
listWorkflows(): Promise<Workflow[]> {
|
||||
return request<Workflow[]>("/workflows");
|
||||
},
|
||||
|
||||
getWorkflow(workflowId: string): Promise<Workflow> {
|
||||
return request<Workflow>(`/workflows/${workflowId}`);
|
||||
},
|
||||
|
||||
createWorkflow(w: Partial<Workflow>): Promise<Workflow> {
|
||||
return request<Workflow>("/workflows", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(w),
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkflow(workflowId: string, w: Partial<Workflow>): Promise<Workflow> {
|
||||
return request<Workflow>(`/workflows/${workflowId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(w),
|
||||
});
|
||||
},
|
||||
|
||||
deleteWorkflow(workflowId: string): Promise<void> {
|
||||
return request<void>(`/workflows/${workflowId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
runWorkflow(workflowId: string): Promise<{ run_id: string }> {
|
||||
return request<{ run_id: string }>(`/workflows/${workflowId}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
listRuns(workflowId: string): Promise<WorkflowRun[]> {
|
||||
return request<WorkflowRun[]>(`/workflows/${workflowId}/runs`);
|
||||
},
|
||||
|
||||
// Runs
|
||||
getRun(runId: string): Promise<WorkflowRun> {
|
||||
return request<WorkflowRun>(`/runs/${runId}`);
|
||||
},
|
||||
|
||||
cancelRun(runId: string): Promise<void> {
|
||||
return request<void>(`/runs/${runId}/cancel`, { method: "POST" });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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,
|
||||
connectData = ""
|
||||
): { disconnect: () => void; setScale: (scale: number) => void; resize: (width: number, height: number) => void } {
|
||||
// Guacamole's WebSocketTunnel builds the socket URL as `wsUrl + "?" + data`,
|
||||
// so wsUrl must NOT already contain a query string — pass params via connectData.
|
||||
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
|
||||
container.innerHTML = "";
|
||||
container.appendChild(client.getDisplay().getElement());
|
||||
// Make the console focusable so keyboard capture is scoped to it (see below).
|
||||
container.tabIndex = 0;
|
||||
|
||||
client.connect(connectData);
|
||||
|
||||
const display = client.getDisplay();
|
||||
let scale = 1;
|
||||
|
||||
// Wire keyboard + mouse. The display element is rendered at `scale` of the
|
||||
// remote's native resolution, but Guacamole.Mouse reports coordinates in
|
||||
// element (on-screen) pixels. Divide by scale to map back to remote
|
||||
// coordinates, otherwise the cursor is offset.
|
||||
const mouse = new Guacamole.Mouse(display.getElement());
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) => {
|
||||
const s = new Guacamole.Mouse.State(
|
||||
state.x / scale,
|
||||
state.y / scale,
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down
|
||||
);
|
||||
client.sendMouseState(s);
|
||||
};
|
||||
// Scope keyboard capture to the container rather than `document`, so it only
|
||||
// grabs keys while the console is focused and stops entirely once the element
|
||||
// is removed (navigating away / disconnect). Attaching to `document` leaks the
|
||||
// capture and swallows keystrokes in unrelated inputs.
|
||||
const keyboard = new Guacamole.Keyboard(container);
|
||||
keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k);
|
||||
keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k);
|
||||
// Guacamole.Mouse consumes the native mousedown, so clicking the console never
|
||||
// moves DOM focus back to it. Refocus explicitly so keyboard capture resumes.
|
||||
const refocus = () => container.focus();
|
||||
container.addEventListener("mousedown", refocus);
|
||||
container.focus();
|
||||
|
||||
return {
|
||||
disconnect() {
|
||||
container.removeEventListener("mousedown", refocus);
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
if (typeof keyboard.reset === "function") keyboard.reset();
|
||||
client.disconnect();
|
||||
},
|
||||
setScale(s: number) {
|
||||
scale = s;
|
||||
display.scale(s);
|
||||
},
|
||||
resize(width: number, height: number) {
|
||||
client.sendSize(width, height);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
+29
-13
@@ -3,19 +3,35 @@ import type { NextConfig } from "next";
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${apiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/install",
|
||||
destination: `${apiUrl}/install`,
|
||||
},
|
||||
];
|
||||
},
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${apiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
destination: `${apiUrl}/auth/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/install",
|
||||
destination: `${apiUrl}/install`,
|
||||
},
|
||||
{
|
||||
source: "/install.ps1",
|
||||
destination: `${apiUrl}/install.ps1`,
|
||||
},
|
||||
{
|
||||
source: "/update",
|
||||
destination: `${apiUrl}/update`,
|
||||
},
|
||||
{
|
||||
source: "/update.ps1",
|
||||
destination: `${apiUrl}/update.ps1`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "keymanager-web",
|
||||
"name": "vantage-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "keymanager-web",
|
||||
"name": "vantage-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "keymanager-web",
|
||||
"name": "vantage-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user