Compare commits
224
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f1684304 | ||
|
|
8ed3bec511 | ||
|
|
58faf2e57f | ||
|
|
8e5f35f40c | ||
|
|
eb8b85ccfe | ||
|
|
09a39090c6 | ||
|
|
8626898e5e | ||
|
|
ee4dff09c9 | ||
|
|
8d88b16f20 | ||
|
|
120c9c6735 | ||
|
|
855537c535 | ||
|
|
1d3fcebb28 | ||
|
|
f9f382049d | ||
|
|
e4d7569a1c | ||
|
|
c3e363eccc | ||
|
|
6fde319b2f | ||
|
|
4f1fce32c1 | ||
|
|
968408b955 | ||
|
|
95c5d531ae | ||
|
|
b0d8edf9b6 | ||
|
|
a50219c0d9 | ||
|
|
f10fe61916 | ||
|
|
bc1cda26f8 | ||
|
|
f646ce5c47 | ||
|
|
1eb8c79623 | ||
|
|
539403cccf | ||
|
|
50a06dfdc0 | ||
|
|
b70ccc97d4 | ||
|
|
3f0f12b111 | ||
|
|
3891a2c239 | ||
|
|
4f041d2f4b | ||
|
|
43a2fdb3a0 | ||
|
|
de0e7b7cae | ||
|
|
0226936758 | ||
|
|
42cd73c5b8 | ||
|
|
51622a922b | ||
|
|
76d3111a72 | ||
|
|
b335dc77e3 | ||
|
|
51db5ab2e9 | ||
|
|
d715d2150c | ||
|
|
98687043f7 | ||
|
|
bdc26e4799 | ||
|
|
b027ad3f7f | ||
|
|
fd32c96d86 | ||
|
|
b74cd10dfc | ||
|
|
460b4afb22 | ||
|
|
9cc9e68d90 | ||
|
|
accf7493e9 | ||
|
|
f0547317f7 | ||
|
|
60f0ddecd6 | ||
|
|
8f5aebdd63 | ||
|
|
a6030ef62f | ||
|
|
7df79d05f1 | ||
|
|
f002eab1f6 | ||
|
|
e798365be2 | ||
|
|
1a6cf03c03 | ||
|
|
3b52bcbeb8 | ||
|
|
693d59a3e2 | ||
|
|
7a3b8cb700 | ||
|
|
97bc766afb | ||
|
|
9ffae221ac | ||
|
|
cbb66c63f6 | ||
|
|
0d4fb896bb | ||
|
|
0b7e55d301 | ||
|
|
77a92787fb | ||
|
|
aa31cd8a10 | ||
|
|
e70b2f0e67 | ||
|
|
156c5354de | ||
|
|
e2f5f1fa8c | ||
|
|
4f512d01f1 | ||
|
|
e5363a64ee | ||
|
|
5a701acc82 | ||
|
|
dff3668a25 | ||
|
|
850aa0ed05 | ||
|
|
d0ed9885e7 | ||
|
|
ff22340561 | ||
|
|
2038e86b53 | ||
|
|
404214d82e | ||
|
|
01fd2e201e | ||
|
|
066095ffca | ||
|
|
022b1ef8ec | ||
|
|
a8771a6e4d | ||
|
|
502045d3af | ||
|
|
ea73fc3a18 | ||
|
|
75b86e3843 | ||
|
|
9767e18123 | ||
|
|
19b4aef95b | ||
|
|
0464c540b2 | ||
|
|
45178d455e | ||
|
|
f28ab1a741 | ||
|
|
df6f8b6f62 | ||
|
|
8f3a27100f | ||
|
|
69c7a352f6 | ||
|
|
a2bfa98a2d | ||
|
|
57151826ae | ||
|
|
e413009faa | ||
|
|
3ec9f1b35f | ||
|
|
e019493087 | ||
|
|
ca2c05db14 | ||
|
|
1850f352a2 | ||
|
|
850ffbafe1 | ||
|
|
a28157dcf8 | ||
|
|
03e2c3c50d | ||
|
|
fb1a1292ec | ||
|
|
ec201a23a2 | ||
|
|
d9a33b0672 | ||
|
|
2b7ef98dff | ||
|
|
b5f30bc7c8 | ||
|
|
3a0116248e | ||
|
|
6af0a88841 | ||
|
|
e4c3fc24d3 | ||
|
|
e46d0edbf2 | ||
|
|
a4c4a72dbc | ||
|
|
67d729b360 | ||
|
|
da6d825f45 | ||
|
|
93423e32e6 | ||
|
|
d0442291f5 | ||
|
|
6c5472760b | ||
|
|
7c4a676742 | ||
|
|
fbda26a188 | ||
|
|
90ce7af769 | ||
|
|
b543cd1b3d | ||
|
|
15c9da1b01 | ||
|
|
baa7bb239d | ||
|
|
7342c46d99 | ||
|
|
813f9e6fef | ||
|
|
434f14ae3a | ||
|
|
8398fd2279 | ||
|
|
56f06b9eaf | ||
|
|
d9d241f83b | ||
|
|
aee910c1f8 | ||
|
|
bea545e873 | ||
|
|
82d7dde5f8 | ||
|
|
397016ad68 | ||
|
|
39348c9491 | ||
|
|
63dadf6239 | ||
|
|
d905c99d32 | ||
|
|
85e1baf59a | ||
|
|
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 |
@@ -36,10 +36,13 @@ jobs:
|
||||
GOOS=linux GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-arm64 ./cmd
|
||||
GOOS=windows GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- name: Checksums
|
||||
working-directory: agent/dist
|
||||
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 > checksums.txt
|
||||
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
@@ -48,4 +51,67 @@ jobs:
|
||||
files: |
|
||||
agent/dist/vantage-agent-linux-amd64
|
||||
agent/dist/vantage-agent-linux-arm64
|
||||
agent/dist/vantage-agent-windows-amd64.exe
|
||||
agent/dist/checksums.txt
|
||||
|
||||
msi:
|
||||
needs: build
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: agent/go.sum
|
||||
|
||||
- name: 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"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ jobs:
|
||||
- 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/
|
||||
# Root context: server depends on the shared module.
|
||||
docker build -t "$IMAGE" -f server/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
@@ -35,3 +36,20 @@ jobs:
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push site image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
|
||||
-t "$IMAGE" \
|
||||
-f site/Dockerfile site/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push sitesvc image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
|
||||
# Root context: sitesvc depends on the shared module.
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
+10
-1
@@ -1,4 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
.env
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,222 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
Method string
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
Message string
|
||||
CertExpiry *time.Time
|
||||
}
|
||||
|
||||
func (s Spec) timeout() time.Duration {
|
||||
t := s.TimeoutSec
|
||||
if t <= 0 || t > 10 {
|
||||
t = 10
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
return runHTTP(ctx, s)
|
||||
case TypeTCP:
|
||||
return runTCP(ctx, s)
|
||||
case TypeICMP:
|
||||
return runICMP(ctx, s)
|
||||
case TypeTLS:
|
||||
return runTLS(ctx, s)
|
||||
default:
|
||||
return Result{Message: "unknown check type: " + s.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func runHTTP(ctx context.Context, s Spec) Result {
|
||||
method := s.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
expect := s.ExpectedStatus
|
||||
if expect == 0 {
|
||||
expect = 200
|
||||
}
|
||||
client := &http.Client{Timeout: s.timeout()}
|
||||
if s.Insecure {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res := Result{LatencyMs: msSince(start), Up: true}
|
||||
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
exp := resp.TLS.PeerCertificates[0].NotAfter
|
||||
res.CertExpiry = &exp
|
||||
}
|
||||
if resp.StatusCode != expect {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
|
||||
}
|
||||
if s.Keyword != "" {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if !strings.Contains(string(body), s.Keyword) {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func runTCP(ctx context.Context, s Spec) Result {
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
conn.Close()
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
|
||||
func runTLS(ctx context.Context, s Spec) Result {
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
|
||||
}
|
||||
exp := certs[0].NotAfter
|
||||
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
|
||||
warn := s.TLSWarnDays
|
||||
if warn <= 0 {
|
||||
warn = 14
|
||||
}
|
||||
remaining := time.Until(exp)
|
||||
if remaining <= 0 {
|
||||
res.Message = "certificate expired"
|
||||
return res
|
||||
}
|
||||
if remaining <= time.Duration(warn)*24*time.Hour {
|
||||
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
|
||||
return res
|
||||
}
|
||||
res.Up = true
|
||||
return res
|
||||
}
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
|
||||
|
||||
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return Result{Message: "icmp socket: " + err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := os.Getpid() & 0xffff
|
||||
pkt := icmpEcho(id, 1)
|
||||
deadline := time.Now().Add(s.timeout())
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(pkt, dst); err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
reply := make([]byte, 1500)
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(reply)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 {
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
b[3] = byte(cs)
|
||||
return b
|
||||
}
|
||||
|
||||
func icmpChecksum(b []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i < len(b)-1; i += 2 {
|
||||
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
||||
}
|
||||
if len(b)%2 == 1 {
|
||||
sum += uint32(b[len(b)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
@@ -2,12 +2,26 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigPath = "/etc/vantage/config.yaml"
|
||||
|
||||
func ConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
base := os.Getenv("ProgramData")
|
||||
if base == "" {
|
||||
base = `C:\ProgramData`
|
||||
}
|
||||
return filepath.Join(base, "vantage")
|
||||
}
|
||||
return "/etc/vantage"
|
||||
}
|
||||
|
||||
func configPath() string { return filepath.Join(ConfigDir(), "config.yaml") }
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `yaml:"server_url"`
|
||||
@@ -19,7 +33,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
data, err := os.ReadFile(ConfigPath)
|
||||
data, err := os.ReadFile(configPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -38,8 +52,8 @@ func Save(cfg *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll("/etc/vantage", 0700); err != nil {
|
||||
if err := os.MkdirAll(ConfigDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ConfigPath, data, 0600)
|
||||
return os.WriteFile(configPath(), data, 0600)
|
||||
}
|
||||
|
||||
@@ -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,164 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
func WorkspacePath(workspaceID string) string {
|
||||
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
|
||||
workDir := ""
|
||||
if cmd.WorkspaceId != "" {
|
||||
workDir = WorkspacePath(cmd.WorkspaceId)
|
||||
if err := os.MkdirAll(workDir, 0700); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "create workspace: " + err.Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
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)
|
||||
}
|
||||
|
||||
if workDir != "" {
|
||||
c.Dir = workDir
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/encoding"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -26,7 +27,15 @@ func New(serverURL string, useTLS bool) (*Client, error) {
|
||||
serverURL = strings.TrimPrefix(serverURL, "https://")
|
||||
serverURL = strings.TrimPrefix(serverURL, "http://")
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
|
||||
|
||||
dialOpts := []grpc.DialOption{
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
}
|
||||
|
||||
if useTLS {
|
||||
tlsCfg := &tls.Config{
|
||||
@@ -105,8 +114,44 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey,
|
||||
return resp.KeyId, nil
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) SyncMonitors(serverID, agentToken string) ([]pb.MonitorSpec, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
resp, err := c.client.SyncMonitors(ctx, &pb.SyncMonitorsRequest{ServerId: serverID, AgentToken: agentToken})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Monitors, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckResult) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportChecks(ctx, &pb.ReportChecksRequest{ServerId: serverID, AgentToken: agentToken, Results: results})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
|
||||
|
||||
|
||||
package pb
|
||||
|
||||
@@ -44,13 +44,107 @@ 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 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{}
|
||||
|
||||
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse 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"`
|
||||
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"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -71,10 +165,12 @@ type GenerateKeyCmd struct {
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
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{}
|
||||
@@ -85,7 +181,32 @@ type CommandResult struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
|
||||
WorkspaceId string `json:"workspace_id,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"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
@@ -109,7 +230,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CommandStream server-side interface (included for completeness)
|
||||
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
@@ -137,6 +258,10 @@ 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)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -184,6 +309,38 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
||||
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) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", 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...)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
|
||||
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())
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
// Inventory collection is Linux-only. This no-op stands in everywhere else.
|
||||
//
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
|
||||
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
return r
|
||||
}
|
||||
+17
-17
@@ -96,16 +96,16 @@ func fingerprint(pubKey string) string {
|
||||
return "MD5:" + strings.Join(pairs, ":")
|
||||
}
|
||||
|
||||
// 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
|
||||
KeyType string
|
||||
KeySize int
|
||||
Passphrase string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// 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 string, opts KeyGenOptions) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
||||
return "", err
|
||||
@@ -139,8 +139,8 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (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)
|
||||
@@ -150,7 +150,7 @@ func AddSSHIdentity(keyPath string) error {
|
||||
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) {
|
||||
@@ -161,7 +161,7 @@ func AddSSHIdentity(keyPath string) error {
|
||||
line := "IdentityFile " + keyPath
|
||||
for _, l := range strings.Split(existing, "\n") {
|
||||
if strings.TrimSpace(l) == line {
|
||||
return nil // already present
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ func AddSSHIdentity(keyPath string) error {
|
||||
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) {
|
||||
@@ -204,9 +204,9 @@ func RemoveSSHIdentity(keyPath string) error {
|
||||
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) {
|
||||
@@ -215,11 +215,11 @@ func ensureIncludeDirective() error {
|
||||
|
||||
for _, l := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(l) == includeDirective {
|
||||
return nil // already present
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
|
||||
package monitors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/checker"
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
type runner struct {
|
||||
intervalSec int
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config) {
|
||||
active := map[string]*runner{}
|
||||
var mu sync.Mutex
|
||||
|
||||
|
||||
|
||||
results := make(chan pb.CheckResult, 64)
|
||||
go reporter(ctx, cfg, results)
|
||||
|
||||
syncOnce := func() {
|
||||
specs, err := fetchSpecs(cfg)
|
||||
if err != nil {
|
||||
log.Printf("monitors: sync: %v", err)
|
||||
return
|
||||
}
|
||||
want := map[string]pb.MonitorSpec{}
|
||||
for _, s := range specs {
|
||||
want[s.MonitorId] = s
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for id, r := range active {
|
||||
s, ok := want[id]
|
||||
if !ok || s.IntervalSec != r.intervalSec {
|
||||
r.cancel()
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
for id, s := range want {
|
||||
if _, ok := active[id]; ok {
|
||||
continue
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
active[id] = &runner{intervalSec: s.IntervalSec, cancel: cancel}
|
||||
go runSpec(rctx, s, results)
|
||||
}
|
||||
}
|
||||
|
||||
syncOnce()
|
||||
t := time.NewTicker(syncInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
syncOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSpecs(cfg *config.Config) ([]pb.MonitorSpec, error) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
return client.SyncMonitors(cfg.ServerID, cfg.AgentToken)
|
||||
}
|
||||
|
||||
func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
|
||||
interval := time.Duration(s.IntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
spec := checker.Spec{
|
||||
Type: s.Type,
|
||||
URL: s.URL,
|
||||
Host: s.Host,
|
||||
Port: s.Port,
|
||||
Method: s.Method,
|
||||
ExpectedStatus: s.ExpectedStatus,
|
||||
Keyword: s.Keyword,
|
||||
TLSWarnDays: s.TLSWarnDays,
|
||||
Insecure: s.Insecure,
|
||||
TimeoutSec: s.IntervalSec,
|
||||
}
|
||||
|
||||
run := func() {
|
||||
res := checker.Run(ctx, spec)
|
||||
cr := pb.CheckResult{MonitorId: s.MonitorId, Up: res.Up, LatencyMs: res.LatencyMs, Message: res.Message}
|
||||
if res.CertExpiry != nil {
|
||||
cr.CertExpiryUnix = res.CertExpiry.Unix()
|
||||
}
|
||||
select {
|
||||
case out <- cr:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
var batch []pb.CheckResult
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("monitors: report dial: %v", err)
|
||||
batch = nil
|
||||
return
|
||||
}
|
||||
if err := client.ReportChecks(cfg.ServerID, cfg.AgentToken, batch); err != nil {
|
||||
log.Printf("monitors: report: %v", err)
|
||||
}
|
||||
client.Close()
|
||||
batch = nil
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flush()
|
||||
return
|
||||
case r := <-in:
|
||||
batch = append(batch, r)
|
||||
if len(batch) >= 32 {
|
||||
flush()
|
||||
}
|
||||
case <-t.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
-12
@@ -11,14 +11,20 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/inventory"
|
||||
"github.com/mrhid6/vantage/agent/internal/keys"
|
||||
"github.com/mrhid6/vantage/agent/internal/monitors"
|
||||
"github.com/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
@@ -28,7 +34,7 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Register if we have a pre-reg token
|
||||
|
||||
if cfg.PreRegToken != "" {
|
||||
log.Println("registering with server...")
|
||||
hostname, _ := os.Hostname()
|
||||
@@ -55,16 +61,25 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
}
|
||||
|
||||
if cfg.AgentToken == "" {
|
||||
return fmt.Errorf("no agent token available — registration required")
|
||||
return fmt.Errorf("no agent token available registration required")
|
||||
}
|
||||
|
||||
// Start the command stream alongside the poll loop.
|
||||
|
||||
go runCommandStream(ctx, cfg)
|
||||
|
||||
|
||||
go runUpdateCheck(ctx, cfg)
|
||||
|
||||
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
|
||||
go monitors.Run(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
|
||||
if err := poll(client, cfg, version); err != nil {
|
||||
log.Printf("poll error: %v", err)
|
||||
}
|
||||
@@ -87,6 +102,10 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
|
||||
current, err := keys.ReadAuthorizedKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read authorized_keys: %w", err)
|
||||
@@ -104,8 +123,8 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) 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
|
||||
@@ -158,6 +177,16 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
|
||||
log.Println("command stream connected")
|
||||
|
||||
|
||||
|
||||
|
||||
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 {
|
||||
@@ -173,9 +202,144 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.UpdateAgent != nil {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
}
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(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
|
||||
|
||||
_ = 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 runInventory(ctx context.Context, cfg *config.Config) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("inventory dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
report := func(static bool) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
report(true)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tick := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tick++
|
||||
report(tick%30 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
id := cmd.CleanupWorkspace.WorkspaceId
|
||||
dir := agentexec.WorkspacePath(id)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
@@ -193,22 +357,27 @@ func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
}
|
||||
|
||||
func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
if runtime.GOOS == "windows" {
|
||||
handleUpdateAgentWindows(cmd)
|
||||
return
|
||||
}
|
||||
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH // "amd64" or "arm64"
|
||||
arch := runtime.GOARCH
|
||||
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)
|
||||
@@ -233,8 +402,49 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
exec.Command("systemctl", "restart", "vantage-agent").Run()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,7 +462,7 @@ func downloadFile(url, dest string) error {
|
||||
}
|
||||
|
||||
func httpGetBytes(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -344,7 +554,7 @@ func localIP() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GenerateAndUpload generates an SSH keypair and uploads the public key to the server.
|
||||
|
||||
func GenerateAndUpload(cfg *config.Config, label string) error {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
|
||||
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()
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
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 := 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())
|
||||
|
||||
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()
|
||||
|
||||
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,34 +1,38 @@
|
||||
# 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.
|
||||
A self-hosted, multi-tenant infrastructure control plane. It started as SSH key management and has grown into fleet management: SSH key assignment, workflow/script execution, service monitoring, a secrets vault, a browser console (SSH/RDP/VNC), and OS update management.
|
||||
|
||||
A central server (Go + Next.js + MongoDB + Redis) drives a lightweight Go agent installed on each managed server. Agents poll over gRPC and also hold a bidirectional command stream for push-style commands.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Next.js Frontend │
|
||||
│ - Upload/manage keys │
|
||||
│ - Add servers (install script) │
|
||||
│ - Assign/revoke per server │
|
||||
└────────────┬────────────────────┘
|
||||
│ REST
|
||||
┌────────────▼────────────────────┐
|
||||
│ Go Backend │
|
||||
│ - REST API for frontend │
|
||||
│ - gRPC server for agents │
|
||||
│ - MongoDB │
|
||||
└────────────┬────────────────────┘
|
||||
│ gRPC (TLS)
|
||||
┌────────────▼────────────────────┐
|
||||
│ Go Agent (per server) │
|
||||
│ - Polls every 30s │
|
||||
│ - Rewrites authorized_keys │
|
||||
│ - Can generate SSH keypairs │
|
||||
└─────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Next.js 16 Frontend (web, :3000) │
|
||||
│ servers · keys · workflows · monitors │
|
||||
│ secrets · audit · console · settings │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ REST + cookie session
|
||||
┌───────────────▼──────────────────────────────┐
|
||||
│ Go Backend (server) │
|
||||
│ :8080 REST (gin) :9090 gRPC (agents) │
|
||||
│ MongoDB (state) · Redis (sessions) │
|
||||
│ monitor scheduler · workflow runner │
|
||||
│ guacd tunnel proxy for browser console │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ gRPC (TLS) — outbound from agent only
|
||||
┌───────────────▼──────────────────────────────┐
|
||||
│ Go Agent (per server, Linux + Windows) │
|
||||
│ polls SyncKeys · CommandStream │
|
||||
│ rewrites authorized_keys (Linux only) │
|
||||
│ runs workflow steps · monitors · inventory │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Multi-tenancy: every domain document carries `org_id`, and every service query is scoped by it. Org is resolved from the session, and optionally cross-checked against the request host (`<slug>.vantage.<tld>`).
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
@@ -36,143 +40,245 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
|
||||
```
|
||||
vantage/
|
||||
├── agent/
|
||||
│ ├── cmd/main.go
|
||||
│ ├── cmd/main.go # flags: -generate-key
|
||||
│ └── internal/
|
||||
│ ├── config/
|
||||
│ ├── grpc/
|
||||
│ ├── keys/
|
||||
│ └── sync/
|
||||
│ ├── checker/ # monitor check execution
|
||||
│ ├── config/ # config.yaml load/save
|
||||
│ ├── exec/ # workflow step execution
|
||||
│ ├── grpc/ # client + generated pb
|
||||
│ ├── inventory/ # CPU/mem/disk collection (linux/other)
|
||||
│ ├── keys/ # authorized_keys read/diff/write
|
||||
│ ├── monitors/ # agent-run monitor loop
|
||||
│ ├── sync/ # poll loop + command stream
|
||||
│ └── updates/ # OS package update check/apply
|
||||
├── server/
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── api/ # REST handlers for Next.js
|
||||
│ ├── grpc/ # gRPC server implementation
|
||||
│ ├── models/ # MongoDB models
|
||||
│ └── services/
|
||||
│ ├── keys.go
|
||||
│ ├── servers.go
|
||||
│ └── sync.go # builds desired state per server
|
||||
├── web/
|
||||
│ ├── app/
|
||||
│ └── components/
|
||||
├── proto/
|
||||
│ └── vantage/v1/vantage.proto
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── agent.service
|
||||
└── .gitea/
|
||||
└── workflows/
|
||||
├── agent-release.yml
|
||||
└── server-deploy.yml
|
||||
│ ├── api/ # REST handlers
|
||||
│ ├── auth/ # local, OIDC, session, middleware, orghost
|
||||
│ ├── checker/ # server-run monitor checks
|
||||
│ ├── db/ # mongo connect + Col()
|
||||
│ ├── grpc/ # gRPC server + generated pb
|
||||
│ ├── models/ # MongoDB documents
|
||||
│ ├── monitorsched/ # server-side monitor scheduler
|
||||
│ ├── notify/ # smtp, http, templating, dispatch
|
||||
│ └── services/ # business logic + migrations
|
||||
├── web/ # the application UI (authenticated)
|
||||
│ ├── app/(app)/ # authed routes
|
||||
│ ├── app/login, app/setup # unauthed routes
|
||||
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
|
||||
│ └── lib/ # api client, guac console, query client
|
||||
├── site/ # public marketing site
|
||||
│ ├── app/ # one directory per route
|
||||
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
|
||||
│ ├── assets/ # image sources, not served
|
||||
│ └── Dockerfile # same shape as web/: standalone, node, 3000
|
||||
├── sitesvc/ # public forms: contact mail + signup
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── api/ # contact, signup, verify
|
||||
│ ├── mail/ # SMTP
|
||||
│ ├── models/ # mirrors server org/user + pending signup
|
||||
│ ├── provision/ # slug rules mirrored from the control plane
|
||||
│ └── store/ # mongo: pending signups, org/user creation
|
||||
├── proto/vantage/v1/vantage.proto
|
||||
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
|
||||
├── deploy/ # docker-compose.yml, agent.service
|
||||
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subsystems
|
||||
|
||||
### SSH keys
|
||||
|
||||
Upload a public key, assign it per server, revoke softly. The agent diffs desired vs on-disk state and rewrites `/root/.ssh/authorized_keys` atomically. Keys can also be generated _on_ a server by the agent; the private half can optionally be uploaded and is stored AES-256-GCM encrypted.
|
||||
|
||||
### Workflows
|
||||
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
|
||||
### Monitors
|
||||
|
||||
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
|
||||
|
||||
### Notification channels
|
||||
|
||||
Per-org outbound destinations: `webhook`, `smtp`, `discord`, `slack`, `telegram`. Monitors reference channels by ID. Channels are testable from the UI.
|
||||
|
||||
### Secrets vault
|
||||
|
||||
Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed two ways: referenced by workflow steps via `secret_refs` (injected as env at execution), and read by Kubernetes External Secrets Operator via `GET /api/secrets/:group/values` using a bearer token whose SHA-256 hash is stored in settings.
|
||||
|
||||
### Browser console
|
||||
|
||||
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
|
||||
### Agent self-update
|
||||
|
||||
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
|
||||
|
||||
### Marketing site and sitesvc
|
||||
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
|
||||
|
||||
`sitesvc/` (port `8082`) owns both flows end to end:
|
||||
|
||||
| Form | Endpoint | Effect |
|
||||
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. |
|
||||
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to the org's sign-in page (`APP_LOGIN_URL` with `{slug}` filled in). |
|
||||
|
||||
All three are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
|
||||
```bash
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
docker compose up -d
|
||||
|
||||
# vantage.hostxtra.co.uk — control plane plus the public site
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
|
||||
```
|
||||
|
||||
### Signup and verification
|
||||
|
||||
**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified".
|
||||
|
||||
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
|
||||
- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing.
|
||||
- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger.
|
||||
- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works.
|
||||
- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users.
|
||||
- Rate limited to 3 signups per client IP per hour, plus a honeypot field.
|
||||
|
||||
### The one piece of duplicated logic
|
||||
|
||||
`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server.
|
||||
|
||||
**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with.
|
||||
|
||||
sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op.
|
||||
|
||||
---
|
||||
|
||||
## Auth and Orgs
|
||||
|
||||
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
|
||||
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
|
||||
- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start` → `/auth/oidc/callback`.
|
||||
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
|
||||
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
|
||||
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
|
||||
Unique indexes on user email and org slug are a **security property**, not an optimisation: `GetUserByEmail` does an unscoped `FindOne`, so duplicates would let the OIDC cross-org guard compare against an arbitrary user. Same for duplicate settings docs and duplicate ESO token hashes.
|
||||
|
||||
---
|
||||
|
||||
## gRPC API
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package vantage.v1;
|
||||
|
||||
service Vantage {
|
||||
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; // full authorized_keys lines
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
}
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
```
|
||||
|
||||
No streaming — polling only. Poll interval: **30 seconds**.
|
||||
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
|
||||
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
|
||||
|
||||
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
Unauthenticated:
|
||||
|
||||
```
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
POST /auth/bootstrap /auth/login /auth/logout
|
||||
GET /auth/me /auth/oidc/start /auth/oidc/callback
|
||||
GET /api/secrets/:group/values # bearer token (ESO)
|
||||
```
|
||||
|
||||
Session-authed under `/api`:
|
||||
|
||||
```
|
||||
servers GET,POST /servers · GET,POST /servers/new · GET,DELETE /servers/:id
|
||||
POST /servers/:id/{generate-key,update-agent,apply-updates}
|
||||
keys GET,POST /keys · GET,DELETE /keys/:id · GET /keys/:id/private-key
|
||||
POST /keys/:id/assign · DELETE /keys/:id/assign/:serverId
|
||||
workflows GET,POST /steps · PUT,DELETE /steps/:id · GET /steps/:id/export
|
||||
POST /steps/{import,seed-defaults,parse} · GET /steps/usage
|
||||
GET,POST /workflows · GET,PUT,DELETE /workflows/:id
|
||||
POST /workflows/:id/run · GET /workflows/:id/runs
|
||||
GET /runs/:runId · POST /runs/:runId/cancel
|
||||
GET /runs/:runId/servers/:serverId/logs[/stream]
|
||||
monitors GET,POST /monitors · GET,PUT,DELETE /monitors/:id
|
||||
GET /monitors/:id/{incidents,uptime}
|
||||
channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:id/test
|
||||
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
|
||||
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
|
||||
console POST /console/connect · GET /console/tunnel (websocket)
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
|
||||
GET,PUT /org/oidc (owner|admin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
### `servers`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"server_id": "uuid",
|
||||
"hostname": "proxmox-node-1",
|
||||
"ip_address": "10.10.10.5",
|
||||
"os_info": "Ubuntu 24.04",
|
||||
"pre_reg_token": "abc123",
|
||||
"pre_reg_expires": "ISODate",
|
||||
"agent_token_hash": "sha256...",
|
||||
"status": "pending|active|offline",
|
||||
"last_seen": "ISODate",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
- `pre_reg_token` is cleared after the agent successfully calls `Register()`
|
||||
- `agent_token_hash` stores SHA-256 of the token — never plaintext
|
||||
- `status` transitions: `pending` → `active` on first `Register()`, `offline` if last_seen exceeds threshold
|
||||
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
|
||||
|
||||
### `keys`
|
||||
Notes that are not obvious from the structs:
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"key_id": "uuid",
|
||||
"label": "dom-macbook",
|
||||
"public_key": "ssh-ed25519 AAAA...",
|
||||
"fingerprint": "SHA256:...",
|
||||
"source": "uploaded|generated",
|
||||
"generated_by_server_id": "uuid",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending` → `active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min).
|
||||
- `servers.inventory` holds the latest metrics snapshot with separate `metrics_at` / `static_at` timestamps.
|
||||
- `keys.private_key_enc` and `passphrase_enc` are AES-256-GCM; the JSON form exposes only `has_private_key` / `has_passphrase`.
|
||||
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
|
||||
### `assignments`
|
||||
### Migrations
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"key_id": "uuid",
|
||||
"server_id": "uuid",
|
||||
"assigned_at": "ISODate",
|
||||
"revoked_at": "ISODate | null"
|
||||
}
|
||||
```
|
||||
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
|
||||
|
||||
- `revoked_at: null` = key is active on that server
|
||||
- Revocation is soft — set `revoked_at`, agent picks it up on next poll
|
||||
- `0001_default_org_backfill`
|
||||
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
|
||||
- `0003_missed_org_scopes`
|
||||
|
||||
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
|
||||
|
||||
---
|
||||
|
||||
## Agent Lifecycle
|
||||
|
||||
### Config file — `/etc/vantage/config.yaml`
|
||||
### Config file
|
||||
|
||||
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`.
|
||||
|
||||
```yaml
|
||||
server_url: "vantage.yourdomain.com:9090"
|
||||
@@ -183,117 +289,119 @@ poll_interval: 30s
|
||||
tls: true
|
||||
```
|
||||
|
||||
Config file permissions: `0600`. Config directory: `0700`.
|
||||
|
||||
### Startup flow
|
||||
### Startup
|
||||
|
||||
```
|
||||
1. Load config
|
||||
2. If pre_reg_token present:
|
||||
→ call Register(server_id, pre_reg_token, hostname, ip, os_info)
|
||||
→ save returned agent_token to config
|
||||
→ delete pre_reg_token from config
|
||||
3. Enter poll loop
|
||||
2. If pre_reg_token present → Register() → save agent_token, clear pre_reg_token, reconnect
|
||||
3. Start goroutines: command stream · update check (hourly) · inventory · monitors
|
||||
4. Enter SyncKeys poll loop (default 30s)
|
||||
```
|
||||
|
||||
### Poll loop (every 30s)
|
||||
### Poll loop
|
||||
|
||||
```
|
||||
1. Call SyncKeys(server_id, agent_token)
|
||||
2. Receive []public_keys
|
||||
3. Compute fingerprints of current /root/.ssh/authorized_keys
|
||||
4. If state unchanged → skip write
|
||||
5. If changed:
|
||||
→ write to /root/.ssh/authorized_keys.tmp
|
||||
→ os.Rename() to /root/.ssh/authorized_keys (atomic)
|
||||
→ chmod 0600
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
|
||||
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
|
||||
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
|
||||
```
|
||||
|
||||
### Key generation (on demand)
|
||||
### Install
|
||||
|
||||
- Triggered by a flag or API call from the server
|
||||
- Runs `ssh-keygen` via `exec.Command`
|
||||
- Uploads public key via `UploadGeneratedKey()`
|
||||
- Private key stays local on the machine
|
||||
|
||||
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, runs as root.
|
||||
Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent as a service via NSSM.
|
||||
|
||||
---
|
||||
|
||||
## Server Registration Flow
|
||||
|
||||
1. Click **Add Server** in the UI
|
||||
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:
|
||||
1. **Add Server** in the UI calls `POST /api/servers/new`, which generates a `server_id` and a pre-registration token (TTL 1 hour, single-use).
|
||||
2. The UI shows a one-liner:
|
||||
```bash
|
||||
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/vantage/config.yaml`
|
||||
- Installs and starts systemd unit
|
||||
5. On first `SyncKeys` call, server marks status as `active`
|
||||
Windows gets the `/install.ps1` equivalent.
|
||||
3. The script detects arch, downloads the agent from the Gitea release, verifies the SHA-256 checksum, writes the config, installs and starts the service.
|
||||
4. The server flips to `active` on first sync.
|
||||
|
||||
The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag.
|
||||
`/install` is served dynamically, injecting the latest agent version from the Gitea API.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (server)
|
||||
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it |
|
||||
| `APP_LOGIN_URL` | no | template for the org sign-in URL a verified owner is redirected to. `{slug}` is replaced with the new org's slug (each org has its own subdomain), e.g. `https://{slug}.vantage.hostxtra.co.uk/login`. A value without `{slug}` is used verbatim; empty means a plain confirmation page. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
|
||||
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
|
||||
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.hostxtra.co.uk.
|
||||
|
||||
---
|
||||
|
||||
## 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/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
|
||||
- Pre-registration tokens are short-lived (1 hour) and single-use
|
||||
- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes)
|
||||
- gRPC over TLS; agents connect outbound only, no inbound firewall holes on managed servers.
|
||||
- Per-server agent token stored as SHA-256 on the server, plaintext only in the agent's `0600` config.
|
||||
- Pre-registration tokens are short-lived (1 hour) and single-use.
|
||||
- AES-256-GCM at rest for private keys, key passphrases, vault secrets, OIDC client secrets, RDP/VNC credentials.
|
||||
- Console session tokens are one-time; RDP credentials are consumed on tunnel open.
|
||||
- ESO read token stored as a SHA-256 hash and rotatable.
|
||||
- Unique indexes on user email, org slug, settings org, and ESO token hash are load-bearing for tenant isolation.
|
||||
- `authorized_keys` written `0600`, owned by root. The agent runs as root because it must.
|
||||
- Every mutating API path writes an audit event.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
## Frontend
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| `/servers` | List all servers, online/offline status badge, last seen timestamp |
|
||||
| `/servers/new` | Displays the one-liner install script with copy button |
|
||||
| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server |
|
||||
| `/keys` | All keys — label, fingerprint, source, assigned count |
|
||||
| `/keys/[id]` | Assign key to servers, revoke per server |
|
||||
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `/setup` | First-run bootstrap: create the first org and owner |
|
||||
| `/login` | Local or OIDC sign-in |
|
||||
| `/` | Fleet dashboard |
|
||||
| `/servers`, `/servers/new`, `/servers/[id]` | Fleet list, install one-liner, server detail (keys, inventory, updates) |
|
||||
| `/servers/[id]/console` | Browser SSH/RDP/VNC session |
|
||||
| `/keys`, `/keys/[id]` | Key library; assign and revoke per server |
|
||||
| `/workflows`, `/workflows/[id]`, `/workflows/[id]/runs[/runId]` | Compose, run, and follow live logs |
|
||||
| `/steps` | Reusable step library |
|
||||
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
|
||||
| `/secrets`, `/secrets/[group]` | Vault |
|
||||
| `/audit` | Audit log |
|
||||
| `/settings`, `/settings/org`, `/settings/notifications` | Alerts, members, OIDC, channels |
|
||||
|
||||
---
|
||||
|
||||
## CI/CD — Gitea Actions
|
||||
|
||||
### Agent release — `.gitea/workflows/agent-release.yml`
|
||||
### `agent-release.yml` — triggered by `agent/v*` tags
|
||||
|
||||
Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "agent/v*"
|
||||
```
|
||||
|
||||
Build command:
|
||||
Builds `linux/amd64`, `linux/arm64`, `windows/amd64`, writes `checksums.txt`, creates a Gitea release. A second `msi` job on `windows-2022` packages the WiX installer.
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
@@ -301,51 +409,51 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
```
|
||||
|
||||
Release assets:
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
- `vantage-agent-linux-amd64`
|
||||
- `vantage-agent-linux-arm64`
|
||||
- `checksums.txt`
|
||||
Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`.
|
||||
|
||||
### Server deploy — `.gitea/workflows/server-deploy.yml`
|
||||
|
||||
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
|
||||
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
|
||||
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
### Tagging convention
|
||||
### Tagging
|
||||
|
||||
```bash
|
||||
# Release a new agent version
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0
|
||||
|
||||
# Server + web deploy automatically on push to main
|
||||
git push origin main
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
|
||||
git push origin main # server + web deploy
|
||||
```
|
||||
|
||||
### Required Gitea secrets / variables
|
||||
### Secrets / variables
|
||||
|
||||
| Name | Type | Value |
|
||||
| ------------------- | -------- | ------------------------------------------ |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope |
|
||||
| `DEPLOY_HOST` | Secret | IP/hostname of the server VM |
|
||||
| `DEPLOY_USER` | Secret | SSH user for deploy |
|
||||
| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| Name | Type | Value |
|
||||
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | Variable | baked into the `web` image at build time |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed
|
||||
- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation
|
||||
- **Outbound-only agent connections** — no inbound firewall holes required on managed servers
|
||||
- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes
|
||||
- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state
|
||||
- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history
|
||||
- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management
|
||||
- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time
|
||||
- **gRPC for agent traffic** — strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
|
||||
- **Outbound-only agents** — no inbound ports on managed servers, works behind NAT.
|
||||
- **Poll for keys, push for commands** — a 30s key poll is fine, but running a workflow step should not wait up to 30s.
|
||||
- **Atomic `authorized_keys` rewrite** — temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file.
|
||||
- **Fingerprint diffing before write** — no disk churn on unchanged state.
|
||||
- **Soft revocation** — `revoked_at` rather than deletes; preserves audit history.
|
||||
- **Run snapshots** — workflow runs freeze their resolved steps so editing a step never rewrites past runs.
|
||||
- **Monitors run in two places** — server-side for external endpoints, agent-side for anything only reachable from inside the target network.
|
||||
- **Redis for sessions only** — all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
|
||||
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
migrate:
|
||||
image: mongo:8
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrate/server-migrate.sh:/migrate.sh:ro
|
||||
command: bash /migrate.sh
|
||||
environment:
|
||||
MONGO_HOST: mongo
|
||||
MONGO_PORT: "27017"
|
||||
SRC_DB: keymanager
|
||||
DST_DB: vantage
|
||||
# Set DROP_SRC=true to automatically drop the keymanager database after migration
|
||||
DROP_SRC: "false"
|
||||
restart: "no"
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
site:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3001:3000
|
||||
depends_on:
|
||||
- sitesvc
|
||||
|
||||
sitesvc:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8082:8082
|
||||
environment:
|
||||
PORT: "8082"
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
PUBLIC_URL: ${SITE_PUBLIC_URL:-}
|
||||
APP_LOGIN_URL: ${SITE_APP_LOGIN_URL:-}
|
||||
SITE_ORIGIN: ${SITE_ORIGIN:-}
|
||||
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
|
||||
SMTP_HOST: ${SITE_SMTP_HOST:-}
|
||||
SMTP_PORT: ${SITE_SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SITE_SMTP_FROM:-}
|
||||
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
|
||||
+24
-38
@@ -1,65 +1,51 @@
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ../server
|
||||
dockerfile: Dockerfile
|
||||
guacd:
|
||||
image: docker.io/guacamole/guacd:1.6.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "9090:9090"
|
||||
- 4822:4822
|
||||
server:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 9090:9090
|
||||
environment:
|
||||
MONGO_URI: mongodb://mongo:27017/vantage
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
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:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
- ./data:/data
|
||||
web:
|
||||
build:
|
||||
context: ../web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: http://server:8080
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
mongo_data: null
|
||||
redis_data: null
|
||||
networks: {}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Migrates an existing keymanager-agent installation to vantage-agent.
|
||||
# Run as root on each managed server.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
|
||||
|
||||
GITEA_HOST="${GITEA_HOST:-}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Detect old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
OLD_BINARY="/usr/local/bin/keymanager-agent"
|
||||
OLD_CONFIG_DIR="/etc/keymanager"
|
||||
OLD_CONFIG="$OLD_CONFIG_DIR/config.yaml"
|
||||
OLD_SERVICE="keymanager-agent"
|
||||
OLD_SERVICE_FILE="/etc/systemd/system/${OLD_SERVICE}.service"
|
||||
OLD_SSH_CONF="/root/.ssh/keymanager.conf"
|
||||
OLD_SSH_CONFIG="/root/.ssh/config"
|
||||
|
||||
NEW_BINARY="/usr/local/bin/vantage-agent"
|
||||
NEW_CONFIG_DIR="/etc/vantage"
|
||||
NEW_CONFIG="$NEW_CONFIG_DIR/config.yaml"
|
||||
NEW_SERVICE="vantage-agent"
|
||||
NEW_SERVICE_FILE="/etc/systemd/system/${NEW_SERVICE}.service"
|
||||
NEW_SSH_CONF="/root/.ssh/vantage.conf"
|
||||
|
||||
if [ ! -f "$OLD_CONFIG" ] && [ ! -f "$OLD_BINARY" ]; then
|
||||
warn "No keymanager-agent installation found — nothing to migrate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Found keymanager-agent installation. Starting migration to vantage-agent..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Stop and disable old service
|
||||
# ---------------------------------------------------------------------------
|
||||
if systemctl is-active --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
info "Stopping $OLD_SERVICE..."
|
||||
systemctl stop "$OLD_SERVICE"
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
systemctl disable "$OLD_SERVICE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Migrate config directory
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_CONFIG" ] && [ ! -f "$NEW_CONFIG" ]; then
|
||||
info "Migrating config: $OLD_CONFIG -> $NEW_CONFIG"
|
||||
mkdir -p "$NEW_CONFIG_DIR"
|
||||
chmod 0700 "$NEW_CONFIG_DIR"
|
||||
cp "$OLD_CONFIG" "$NEW_CONFIG"
|
||||
chmod 0600 "$NEW_CONFIG"
|
||||
elif [ -f "$NEW_CONFIG" ]; then
|
||||
warn "$NEW_CONFIG already exists — skipping config copy."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Migrate SSH managed conf file
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_SSH_CONF" ]; then
|
||||
info "Migrating SSH conf: $OLD_SSH_CONF -> $NEW_SSH_CONF"
|
||||
|
||||
# Rewrite IdentityFile paths: /root/.ssh/keymanager_* -> /root/.ssh/vantage_*
|
||||
sed 's|/root/\.ssh/keymanager_|/root/.ssh/vantage_|g' "$OLD_SSH_CONF" > "$NEW_SSH_CONF"
|
||||
chmod 0600 "$NEW_SSH_CONF"
|
||||
fi
|
||||
|
||||
# Update Include directive in /root/.ssh/config
|
||||
if [ -f "$OLD_SSH_CONFIG" ]; then
|
||||
if grep -q "Include /root/.ssh/keymanager.conf" "$OLD_SSH_CONFIG"; then
|
||||
info "Updating Include directive in $OLD_SSH_CONFIG"
|
||||
sed -i 's|Include /root/\.ssh/keymanager\.conf|Include /root/.ssh/vantage.conf|g' "$OLD_SSH_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Rename generated key files
|
||||
# ---------------------------------------------------------------------------
|
||||
shopt -s nullglob
|
||||
OLD_KEYS=(/root/.ssh/keymanager_*)
|
||||
if [ ${#OLD_KEYS[@]} -gt 0 ]; then
|
||||
info "Renaming ${#OLD_KEYS[@]} key file(s)..."
|
||||
for old_path in "${OLD_KEYS[@]}"; do
|
||||
filename=$(basename "$old_path")
|
||||
new_filename="${filename/keymanager_/vantage_}"
|
||||
new_path="/root/.ssh/$new_filename"
|
||||
if [ ! -e "$new_path" ]; then
|
||||
cp "$old_path" "$new_path"
|
||||
chmod "$(stat -c '%a' "$old_path")" "$new_path"
|
||||
info " $old_path -> $new_path"
|
||||
else
|
||||
warn " $new_path already exists — skipping"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
shopt -u nullglob
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Download new vantage-agent binary
|
||||
# ---------------------------------------------------------------------------
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) die "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$GITEA_HOST" ] && [ -n "$GITEA_OWNER" ]; then
|
||||
info "Fetching latest vantage-agent release from $GITEA_HOST..."
|
||||
|
||||
RELEASE_JSON=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/vantage/releases?limit=1&type=tag" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$RELEASE_JSON" ]; then
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*vantage-agent-linux-${ARCH}\"" | head -1 | cut -d'"' -f4)
|
||||
CHECKSUM_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*checksums\.txt\"" | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$DOWNLOAD_URL" ]; then
|
||||
info "Downloading $DOWNLOAD_URL..."
|
||||
TMP_BIN="/tmp/vantage-agent-new"
|
||||
curl -fsSL -o "$TMP_BIN" "$DOWNLOAD_URL"
|
||||
|
||||
if [ -n "$CHECKSUM_URL" ]; then
|
||||
TMP_SUMS="/tmp/vantage-checksums.txt"
|
||||
curl -fsSL -o "$TMP_SUMS" "$CHECKSUM_URL"
|
||||
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" "$TMP_SUMS" | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum "$TMP_BIN" | awk '{print $1}')
|
||||
[ "$EXPECTED" = "$ACTUAL" ] || die "Checksum mismatch! Expected $EXPECTED, got $ACTUAL"
|
||||
rm -f "$TMP_SUMS"
|
||||
info "Checksum verified."
|
||||
fi
|
||||
|
||||
chmod 0755 "$TMP_BIN"
|
||||
mv "$TMP_BIN" "$NEW_BINARY"
|
||||
info "Installed $NEW_BINARY"
|
||||
else
|
||||
warn "Could not find vantage-agent binary in release — skipping binary install."
|
||||
fi
|
||||
else
|
||||
warn "Could not reach Gitea API — skipping binary download."
|
||||
fi
|
||||
elif [ -f "$OLD_BINARY" ]; then
|
||||
warn "GITEA_HOST/GITEA_OWNER not set — skipping binary download."
|
||||
warn "You must manually install the vantage-agent binary to $NEW_BINARY before starting the service."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Install new systemd service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing $NEW_SERVICE_FILE..."
|
||||
cat > "$NEW_SERVICE_FILE" <<'EOF'
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$NEW_SERVICE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Start new service (only if binary exists)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$NEW_BINARY" ]; then
|
||||
info "Starting $NEW_SERVICE..."
|
||||
systemctl start "$NEW_SERVICE"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$NEW_SERVICE"; then
|
||||
info "vantage-agent is running."
|
||||
else
|
||||
warn "vantage-agent failed to start. Check: journalctl -u vantage-agent"
|
||||
fi
|
||||
else
|
||||
warn "Binary not yet installed — service NOT started."
|
||||
warn "Install the binary then run: systemctl start vantage-agent"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Clean up old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Cleaning up old keymanager-agent files..."
|
||||
rm -f "$OLD_SERVICE_FILE"
|
||||
rm -f "$OLD_BINARY"
|
||||
rm -rf "$OLD_CONFIG_DIR"
|
||||
rm -f "$OLD_SSH_CONF"
|
||||
|
||||
shopt -s nullglob
|
||||
for old_key in /root/.ssh/keymanager_*; do
|
||||
rm -f "$old_key"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
systemctl daemon-reload
|
||||
info "Migration complete."
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs inside the migration container.
|
||||
# Copies all collections + indexes from $SRC_DB to $DST_DB,
|
||||
# verifies document counts, then optionally drops the source.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
MONGO_HOST="${MONGO_HOST:-mongo}"
|
||||
MONGO_PORT="${MONGO_PORT:-27017}"
|
||||
SRC_DB="${SRC_DB:-keymanager}"
|
||||
DST_DB="${DST_DB:-vantage}"
|
||||
DROP_SRC="${DROP_SRC:-false}"
|
||||
|
||||
MONGO_URI="mongodb://${MONGO_HOST}:${MONGO_PORT}"
|
||||
|
||||
mongosh_eval() {
|
||||
local db="$1"; local script="$2"
|
||||
mongosh --quiet "${MONGO_URI}/${db}" --eval "$script"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Wait for MongoDB to be reachable
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Waiting for MongoDB at ${MONGO_HOST}:${MONGO_PORT}..."
|
||||
for i in $(seq 1 30); do
|
||||
mongosh --quiet "${MONGO_URI}/admin" --eval "db.adminCommand('ping')" >/dev/null 2>&1 && break
|
||||
[ "$i" -eq 30 ] && die "MongoDB not reachable after 30 attempts."
|
||||
sleep 2
|
||||
done
|
||||
info "MongoDB is ready."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Check source database
|
||||
# ---------------------------------------------------------------------------
|
||||
SRC_COLLECTIONS=$(mongosh_eval admin "
|
||||
const names = db.getSiblingDB('${SRC_DB}').getCollectionNames();
|
||||
print(names.join(','));
|
||||
")
|
||||
|
||||
if [ -z "$SRC_COLLECTIONS" ] || [ "$SRC_COLLECTIONS" = "," ]; then
|
||||
warn "Source database '${SRC_DB}' has no collections — nothing to migrate."
|
||||
warn "If this is a fresh deployment, '${DST_DB}' will be created automatically."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Collections in '${SRC_DB}': ${SRC_COLLECTIONS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Copy all collections via \$out
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Copying collections from '${SRC_DB}' to '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const cols = src.getCollectionNames();
|
||||
cols.forEach(function(name) {
|
||||
src[name].aggregate([{ \\\$out: { db: '${DST_DB}', coll: name } }]);
|
||||
print('Copied: ' + name);
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Recreate indexes
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Recreating indexes in '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const dst = db.getSiblingDB('${DST_DB}');
|
||||
src.getCollectionNames().forEach(function(col) {
|
||||
src[col].getIndexes().forEach(function(idx) {
|
||||
if (idx.name === '_id_') return;
|
||||
const opts = { name: idx.name };
|
||||
if (idx.unique) opts.unique = true;
|
||||
if (idx.sparse) opts.sparse = true;
|
||||
if (idx.expireAfterSeconds !== undefined) opts.expireAfterSeconds = idx.expireAfterSeconds;
|
||||
try {
|
||||
dst[col].createIndex(idx.key, opts);
|
||||
print('Index: ' + col + '.' + idx.name);
|
||||
} catch(e) {
|
||||
print('Skipped index ' + idx.name + ' on ' + col + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Verify document counts
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Verifying document counts..."
|
||||
|
||||
MISMATCH=0
|
||||
IFS=',' read -ra COLS <<< "$SRC_COLLECTIONS"
|
||||
for col in "${COLS[@]}"; do
|
||||
[ -z "$col" ] && continue
|
||||
SRC_N=$(mongosh_eval "$SRC_DB" "print(db['${col}'].countDocuments())")
|
||||
DST_N=$(mongosh_eval "$DST_DB" "print(db['${col}'].countDocuments())")
|
||||
if [ "$SRC_N" = "$DST_N" ]; then
|
||||
info " ${col}: ${SRC_N} docs OK"
|
||||
else
|
||||
warn " ${col}: src=${SRC_N} dst=${DST_N} MISMATCH"
|
||||
MISMATCH=1
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$MISMATCH" -eq 1 ] && die "Count mismatch — source database NOT dropped. Investigate and re-run."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Optionally drop source database
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$DROP_SRC" = "true" ]; then
|
||||
info "Dropping source database '${SRC_DB}'..."
|
||||
mongosh_eval admin "db.getSiblingDB('${SRC_DB}').dropDatabase(); print('Dropped.');"
|
||||
info "Dropped '${SRC_DB}'."
|
||||
else
|
||||
warn "Source database '${SRC_DB}' kept. Set DROP_SRC=true to drop it automatically."
|
||||
fi
|
||||
|
||||
info "Migration complete."
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
# Spec 3 — Admin Backend
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
|
||||
Ships: with spec 4 (the site it serves). Blocks specs 4 and 5.
|
||||
|
||||
## Context
|
||||
|
||||
A fourth Go service, `admin/`, owning customers, instances, licences and
|
||||
subscriptions. It is the only service that holds the signing key.
|
||||
|
||||
Its data model separates two things the control plane deliberately does not know
|
||||
about:
|
||||
|
||||
- **Account** — a paying customer. Holds a Paddle customer, a billing email, and
|
||||
one or more instances.
|
||||
- **Instance** — one deployment. Cloud instances mirror a control-plane
|
||||
`Instance` row; self-hosted instances exist only here, because the customer's
|
||||
database is theirs and we cannot see it.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Issue, store and re-issue licences, with full history.
|
||||
2. Inject licences into cloud instances.
|
||||
3. Serve both staff and customers, with the right things hidden from each.
|
||||
4. Never be a runtime dependency of a Vantage instance. If admin is down,
|
||||
every instance keeps working; only purchasing and renewals stop.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- The UI. Spec 4.
|
||||
- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance
|
||||
functions that spec 5's webhooks call, and nothing more.
|
||||
- Rebuilding billing management. Card changes, invoices and cancellation go to
|
||||
Paddle's own customer portal.
|
||||
|
||||
## Design
|
||||
|
||||
### Module
|
||||
|
||||
```
|
||||
admin/
|
||||
├── go.mod # replace => ../shared
|
||||
├── cmd/main.go
|
||||
└── internal/
|
||||
├── api/ # gin handlers
|
||||
├── auth/ # staff, cloud-customer and local-customer sessions
|
||||
├── db/ # two connections: admin DB and control-plane DB
|
||||
├── models/ # admin-owned documents
|
||||
├── licensing/ # issuance, renewal, relink
|
||||
├── inject/ # control-plane writes
|
||||
├── mail/ # licence delivery
|
||||
└── paddle/ # spec 5 lands here
|
||||
```
|
||||
|
||||
Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is
|
||||
**excluded from the self-hosted deployment**. A self-hosted customer runs
|
||||
instances, not the licensing authority.
|
||||
|
||||
### Two database connections
|
||||
|
||||
The service holds two:
|
||||
|
||||
- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner.
|
||||
- `CONTROL_MONGO_URI` — the control plane's database, used to write licence
|
||||
fields onto cloud instance documents and to authenticate cloud customers.
|
||||
|
||||
The control-plane connection uses the `Instance` and `User` structs from
|
||||
`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side
|
||||
copy of the document shape to drift, which is the coupling hazard sitesvc used to
|
||||
carry.
|
||||
|
||||
Admin's control-plane access is **narrow by construction**: it reads
|
||||
`instances` and `users`, and it writes exactly three fields on `instances`. The
|
||||
Mongo credential it is given should be scoped to that where the deployment allows
|
||||
it. It must never write to any other collection.
|
||||
|
||||
### Data model
|
||||
|
||||
```go
|
||||
type Account struct {
|
||||
ID bson.ObjectID
|
||||
AccountID string // uuid
|
||||
Name string
|
||||
BillingEmail string
|
||||
PaddleCustomerID string // empty until first checkout
|
||||
Status string // active | suspended
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
ID bson.ObjectID
|
||||
InstanceID string // for cloud: equals the control-plane instance_id
|
||||
// for self-hosted: the UUID the customer pasted
|
||||
AccountID string
|
||||
Name string
|
||||
Slug string // cloud only; the subdomain label
|
||||
Deployment string // cloud | self_hosted
|
||||
Tier string
|
||||
Status string // awaiting_link | active | lapsed | cancelled
|
||||
CurrentLicense string // licence ID
|
||||
RelinkCount int // reset each term
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type License struct {
|
||||
ID bson.ObjectID
|
||||
LicenseID string
|
||||
InstanceID string
|
||||
AccountID string
|
||||
Tier string
|
||||
Deployment string
|
||||
Limits license.Limits // snapshot
|
||||
Features []string // snapshot
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Blob string
|
||||
SupersededBy string // licence ID, when replaced
|
||||
IssuedBy string // staff user, "system", or "paddle:<event id>"
|
||||
Reason string // new | renewal | tier_change | relink | manual
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID bson.ObjectID
|
||||
SubscriptionID string
|
||||
AccountID string
|
||||
InstanceID string
|
||||
PaddleSubscriptionID string
|
||||
PaddlePriceID string
|
||||
Tier string
|
||||
Term string // monthly | annual
|
||||
Status string // active | past_due | cancelled | awaiting_link
|
||||
CurrentPeriodEnd time.Time
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
Limits license.Limits
|
||||
Features []string
|
||||
PaddleProductID string
|
||||
PaddlePriceIDs map[string]string // "monthly" | "annual"
|
||||
Active bool
|
||||
}
|
||||
```
|
||||
|
||||
Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`,
|
||||
`plans`, `staff_users`, `customer_users`, `admin_audit`.
|
||||
|
||||
Unique indexes: `accounts.account_id`, `admin_instances.instance_id`,
|
||||
`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`,
|
||||
`staff_users.email`, `customer_users.email`.
|
||||
|
||||
`admin_instances.instance_id` unique is load-bearing: it is what stops the same
|
||||
self-hosted UUID being linked to two accounts.
|
||||
|
||||
**Licences are append-only.** A renewal writes a new row and sets
|
||||
`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support
|
||||
question arrives about why a customer's instance stopped working on a given
|
||||
date, the answer is in the table.
|
||||
|
||||
`plans` holds tier contents so they change without a deploy, seeded from the
|
||||
table in spec 1. Every issued licence snapshots the plan, so editing a plan never
|
||||
changes an existing licence — the same rule as `workflow_runs.steps_snapshot`.
|
||||
|
||||
### Issuance
|
||||
|
||||
```go
|
||||
func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error)
|
||||
```
|
||||
|
||||
1. Load the instance and its account.
|
||||
2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`.
|
||||
**This is the check that makes Free cloud-only** — Free's plan is
|
||||
`deployment: cloud`, so it can never be issued to a self-hosted instance.
|
||||
3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term
|
||||
plus a **3-day grace** so a renewal webhook arriving slightly late does not
|
||||
create a gap.
|
||||
4. Sign with `LICENSE_SIGNING_KEY`.
|
||||
5. Insert the licence row; set `SupersededBy` on the previous one; update
|
||||
`instance.CurrentLicense` and `instance.Tier`.
|
||||
6. If cloud, inject. If self-hosted, email the blob and make it downloadable.
|
||||
7. Write an `admin_audit` entry.
|
||||
|
||||
Steps 5 and 6 are not transactional. Order matters: **record first, deliver
|
||||
second.** A licence recorded but not delivered is recoverable — the customer
|
||||
downloads it. A licence delivered but not recorded is a support mystery.
|
||||
|
||||
### Free tier rule
|
||||
|
||||
One Free instance per account, enforced in `Issue`: refuse a second Free instance
|
||||
for an account that already has one that is not `cancelled`. Additional
|
||||
instances must be paid.
|
||||
|
||||
### Injection
|
||||
|
||||
```go
|
||||
func InjectCloud(ctx, instanceID string, lic *models.License) error
|
||||
```
|
||||
|
||||
Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane
|
||||
`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to
|
||||
re-run.
|
||||
|
||||
Retries three times with backoff; on final failure the licence stays recorded and
|
||||
`instance.Status` is set to `active` regardless, with the failure logged and
|
||||
surfaced as a staff alert. A **reconciliation job runs every 15 minutes**,
|
||||
comparing each cloud instance's `CurrentLicense` against the blob actually stored
|
||||
in the control plane, and re-injecting on mismatch. That job, not the webhook, is
|
||||
what guarantees eventual consistency.
|
||||
|
||||
The control-plane instance caches licence state for 60 seconds (spec 2), so an
|
||||
injection takes effect within a minute without a restart.
|
||||
|
||||
### Self-hosted linking
|
||||
|
||||
The flow, end to end:
|
||||
|
||||
```
|
||||
Customer runs /setup on their own install → instance UUID generated and shown
|
||||
Customer buys Self Hosted in the admin site → subscription created,
|
||||
status awaiting_link
|
||||
Customer pastes the UUID into the admin site → admin_instances row created,
|
||||
status active
|
||||
Admin issues the licence with that UUID bound in
|
||||
Customer downloads the .lic file or copies the blob
|
||||
Customer pastes it into /settings/license on their install
|
||||
```
|
||||
|
||||
Validation on link: the UUID must parse as a UUID, must not already exist in
|
||||
`admin_instances`, and must not collide with a cloud instance ID. A duplicate
|
||||
returns "That instance ID is already linked to an account" without revealing
|
||||
which — it is a small enumeration surface but there is no reason to leave it
|
||||
open.
|
||||
|
||||
### Relink
|
||||
|
||||
A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new
|
||||
UUID:
|
||||
|
||||
- Allowed **3 times per term**, `RelinkCount` reset on renewal.
|
||||
- Updates `admin_instances.instance_id`, issues a replacement licence for the
|
||||
**remaining term** with `reason: relink`, supersedes the old one.
|
||||
- The old licence is not revoked — it cannot be, offline verification has no
|
||||
revocation. It simply no longer matches any UUID the customer controls, and its
|
||||
binding stops it being useful on a different machine anyway.
|
||||
- Beyond 3, the endpoint returns a message directing the customer to support, and
|
||||
staff can relink without limit.
|
||||
|
||||
`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to
|
||||
put a human in front of the fourth attempt.
|
||||
|
||||
### Authentication
|
||||
|
||||
Three identities, three paths, one session store (Redis, `admin_session`
|
||||
cookie, 24h).
|
||||
|
||||
**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI
|
||||
only; there is no staff signup.
|
||||
|
||||
**Cloud customers** — authenticate against the control plane's `users`
|
||||
collection with the credentials they already use. Admin looks the user up
|
||||
by email, checks bcrypt, resolves their control-plane instance, then resolves the
|
||||
account that owns it.
|
||||
|
||||
Two consequences, stated plainly because they are real:
|
||||
|
||||
1. A cloud user's control-plane password now also unlocks billing. Any password
|
||||
change or compromise has a wider blast radius than before.
|
||||
2. Only users with control-plane role `owner` may sign in to the admin site.
|
||||
`admin` and `member` are refused. Billing is an owner concern.
|
||||
|
||||
Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per
|
||||
hour; log every attempt to `admin_audit`; return an identical error for unknown
|
||||
email and wrong password.
|
||||
|
||||
**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12,
|
||||
created during purchase, scoped to one account. Email verification reuses the
|
||||
pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored,
|
||||
24-hour expiry, TTL index.
|
||||
|
||||
A single email address could in principle be both a cloud user and a
|
||||
self-hosted customer user. `customer_users` is checked first; if it matches, that
|
||||
identity wins. Documented so the behaviour is chosen rather than emergent.
|
||||
|
||||
### API
|
||||
|
||||
Staff:
|
||||
|
||||
```
|
||||
GET /api/staff/accounts list, search
|
||||
POST /api/staff/accounts
|
||||
GET /api/staff/accounts/:id
|
||||
GET /api/staff/instances filter by account, deployment, status, expiry
|
||||
POST /api/staff/instances/:id/issue manual issue or reissue
|
||||
POST /api/staff/instances/:id/relink no limit
|
||||
GET /api/staff/licenses full history, filterable
|
||||
GET /api/staff/plans
|
||||
PUT /api/staff/plans/:tier
|
||||
GET /api/staff/audit
|
||||
GET /api/staff/health/injection reconciliation status and failures
|
||||
```
|
||||
|
||||
Customer:
|
||||
|
||||
```
|
||||
GET /api/account own account and instances
|
||||
POST /api/instances/link self-hosted UUID link
|
||||
POST /api/instances/:id/relink rate-limited
|
||||
GET /api/instances/:id/license current licence metadata
|
||||
GET /api/instances/:id/license/download .lic file
|
||||
GET /api/subscriptions status, next renewal
|
||||
POST /api/billing/portal Paddle portal redirect (spec 5)
|
||||
```
|
||||
|
||||
Every customer handler resolves the account from the session and scopes by it.
|
||||
The scoping is enforced by a helper every handler calls, not by each handler
|
||||
remembering — the same deny-by-default reasoning as spec 2's middleware.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Required | Notes |
|
||||
|---|---|---|
|
||||
| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent |
|
||||
| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth |
|
||||
| `REDIS_ADDR` | yes | sessions |
|
||||
| `LICENSE_SIGNING_KEY` | yes | ECDSA P-384 private key, base32 (lk PrivateKey.ToB32String). **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
|
||||
| `PUBLIC_URL` | yes | for verification and licence links |
|
||||
| `SMTP_*` | yes | licence delivery |
|
||||
| `ADMIN_ORIGIN` | yes | CORS allow-list |
|
||||
| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` |
|
||||
| Paddle variables | spec 5 | |
|
||||
|
||||
### Backfill
|
||||
|
||||
Licences issued by `lkctl` during the spec 1–2 period exist only as blobs.
|
||||
A one-shot `admin backfill --from=blobs.json` parses each with
|
||||
`license.Parse`, creates the account, instance and licence rows, and marks them
|
||||
`reason: manual`. Run once when admin goes live.
|
||||
|
||||
## Testing
|
||||
|
||||
**Issuance:**
|
||||
|
||||
1. `Issue` produces a licence that `license.Verify` accepts for that instance.
|
||||
2. Deployment mismatch (Free plan, self-hosted instance) is refused.
|
||||
3. A second Free instance for the same account is refused; a third paid one is
|
||||
allowed.
|
||||
4. Renewal supersedes the previous licence and leaves it in the table.
|
||||
5. The issued licence snapshots the plan; editing the plan afterwards does not
|
||||
change the issued licence.
|
||||
6. Grace period: `ExpiresAt` is term end plus 3 days.
|
||||
|
||||
**Injection:**
|
||||
|
||||
7. `InjectCloud` writes all three fields; the control plane then reports `valid`.
|
||||
8. Injection is idempotent across two calls.
|
||||
9. Injection failure leaves the licence recorded and flags the instance.
|
||||
10. The reconciliation job detects a control-plane blob that does not match
|
||||
`CurrentLicense` and re-injects.
|
||||
|
||||
**Linking and relink:**
|
||||
|
||||
11. Linking an unknown UUID succeeds; linking one already linked is refused.
|
||||
12. Relink issues a licence for the *remaining* term, not a fresh full term.
|
||||
13. The fourth relink in a term is refused for a customer and allowed for staff.
|
||||
14. `RelinkCount` resets on renewal.
|
||||
|
||||
**Auth:**
|
||||
|
||||
15. Cloud owner signs in with control-plane credentials; `admin` and `member`
|
||||
roles are refused.
|
||||
16. Unknown email and wrong password return identical errors and timing is not a
|
||||
meaningful oracle.
|
||||
17. Rate limits trigger at the documented thresholds.
|
||||
18. Self-hosted customer cannot sign in before verifying their email.
|
||||
19. A customer requesting another account's instance gets `404`, not `403` —
|
||||
no existence disclosure.
|
||||
|
||||
**Scoping:**
|
||||
|
||||
20. Every customer endpoint, called with a session for account A against a
|
||||
resource of account B, returns `404`. Written as a table-driven test over the
|
||||
route list so a new endpoint that forgets to scope fails the build.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full suite green, including the scoping table test (test 20).
|
||||
2. End to end, cloud: create account → create instance → issue Professional →
|
||||
confirm the control-plane instance reports `valid` within 60 seconds with no
|
||||
restart.
|
||||
3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID,
|
||||
link it, issue, download, paste, confirm `valid`.
|
||||
4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or
|
||||
any collection other than `instances`.
|
||||
5. Kill the admin service and confirm every Vantage instance keeps working
|
||||
entirely normally.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin |
|
||||
| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 |
|
||||
| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes |
|
||||
| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint |
|
||||
| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change |
|
||||
| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error |
|
||||
@@ -0,0 +1,203 @@
|
||||
# Spec 4 — Admin Site
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 3 (`admin-backend`)
|
||||
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
|
||||
is stable.
|
||||
|
||||
## Context
|
||||
|
||||
A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:
|
||||
|
||||
- **Staff** — internal operators. Accounts, instances, licence history, plan
|
||||
editing, injection health, audit.
|
||||
- **Customers** — their own account, instances, licences and subscription state.
|
||||
|
||||
They share auth plumbing and a component library but almost no screens. The
|
||||
split is by route group, so a customer route can never accidentally render a
|
||||
staff view.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A customer can buy, link a self-hosted instance, download a licence, and see
|
||||
when it expires — without contacting anyone.
|
||||
2. Staff can answer "why did this customer's instance stop working" in one screen.
|
||||
3. Nothing about the marketing site or the control-plane UI changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rebuilding billing management. Card details, invoices, payment methods and
|
||||
cancellation all deep-link into Paddle's customer portal.
|
||||
- Server management. This is not a second control plane; there is exactly one
|
||||
link out to the instance and no data about servers, keys or workflows.
|
||||
- Public signup for cloud. That stays on the marketing site (moving to admin's
|
||||
backend in spec 5, but the *form* stays where customers already find it).
|
||||
|
||||
## Design
|
||||
|
||||
### App
|
||||
|
||||
Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
|
||||
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
|
||||
published as `3002`. In `docker-compose.site.yml` only.
|
||||
|
||||
`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
|
||||
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
|
||||
this wrong is the single most common deployment failure in this repo's history —
|
||||
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
|
||||
renders an explicit "not connected" state rather than failing silently.
|
||||
|
||||
```
|
||||
adminsite/
|
||||
├── app/
|
||||
│ ├── login/
|
||||
│ ├── signup/ # self-hosted customer account creation
|
||||
│ ├── verify/
|
||||
│ ├── (customer)/
|
||||
│ │ ├── page.tsx # account overview
|
||||
│ │ ├── instances/[id]/
|
||||
│ │ ├── instances/link/
|
||||
│ │ ├── billing/
|
||||
│ │ └── layout.tsx # customer nav, account guard
|
||||
│ └── (staff)/staff/
|
||||
│ ├── page.tsx # operations dashboard
|
||||
│ ├── accounts/[id]/
|
||||
│ ├── instances/[id]/
|
||||
│ ├── licenses/
|
||||
│ ├── plans/
|
||||
│ └── layout.tsx # staff nav, staff guard
|
||||
├── components/
|
||||
└── lib/
|
||||
```
|
||||
|
||||
Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
|
||||
redirected, not a 403 page — there is nothing to tell them about.
|
||||
|
||||
### Customer screens
|
||||
|
||||
**Overview** — the account, its instances as cards. Each card: name, cloud or
|
||||
self-hosted, tier, licence state, expiry with days remaining, and a link either
|
||||
to the instance's subdomain (cloud) or to its licence page (self-hosted).
|
||||
|
||||
Licence state is colour-coded and blunt: green valid, amber under 14 days, red
|
||||
expired. An expired card says what still works — "servers and monitors are still
|
||||
running; changes are disabled" — because that is the first thing a worried
|
||||
customer wants to know.
|
||||
|
||||
**Instance detail** — tier, limits, features, subscription status, next renewal
|
||||
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
|
||||
in a copy-to-clipboard box, and step-by-step paste instructions with the target
|
||||
route named (`Settings → Licence` on their own install). A **Relink** action
|
||||
showing the remaining allowance ("2 of 3 relinks remaining this term").
|
||||
|
||||
**Link an instance** — the self-hosted activation screen. Explains where to find
|
||||
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
|
||||
paste, validates the format client-side, and on success issues the licence and
|
||||
lands the customer directly on the download.
|
||||
|
||||
The whole flow — buy, link, download, paste — should be completable without
|
||||
reading documentation. That is the bar for this screen.
|
||||
|
||||
**Billing** — subscription list with status and renewal date, plus a button to
|
||||
Paddle's portal. Deliberately thin.
|
||||
|
||||
### Staff screens
|
||||
|
||||
**Dashboard** — the operational answers, not vanity metrics: licences expiring
|
||||
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
|
||||
than 48 hours, and **failed injections** from the reconciliation job. Each row
|
||||
links straight to the thing that needs doing.
|
||||
|
||||
**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
|
||||
Searching by UUID matters: a support email arrives containing a UUID and nothing
|
||||
else.
|
||||
|
||||
**Account detail** — instances, subscriptions, customer users, audit trail.
|
||||
|
||||
**Instance detail** — everything about one instance, with the **full licence
|
||||
history as a timeline**: issued, superseded, renewed, relinked, each with a
|
||||
timestamp, reason and who did it. This is the screen that answers "why did this
|
||||
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
|
||||
live view of the control-plane injection state for cloud instances.
|
||||
|
||||
**Licences** — global history, filterable by tier, deployment, expiry window and
|
||||
issuance reason.
|
||||
|
||||
**Plans** — edit limits and features per tier. Two guard rails, because this
|
||||
screen changes what every future customer gets:
|
||||
|
||||
- A confirmation step naming exactly what changes and stating that existing
|
||||
licences are unaffected until reissued.
|
||||
- The deployment field is not editable. Moving Free to `self_hosted` would break
|
||||
the cloud-only rule that spec 1 leans on; changing it is a code review, not a
|
||||
form field.
|
||||
|
||||
**Audit** — every mutating action, filterable.
|
||||
|
||||
### Design language
|
||||
|
||||
Visually distinct from `web/`. Staff regularly have both open, and a moment of
|
||||
"which app am I in" before clicking Reissue is worth designing out. Different
|
||||
accent colour and a persistent environment badge in the header (sandbox or
|
||||
production, from a build-time flag) — clicking Issue against the wrong Paddle
|
||||
environment should be hard.
|
||||
|
||||
Shared component patterns with `web/` where they exist; this is not a reason to
|
||||
invent a second design system.
|
||||
|
||||
### Error and empty states
|
||||
|
||||
- Backend unreachable: a page-level "not connected" state naming
|
||||
`ADMIN_API_URL`, matching the pattern the marketing site already uses.
|
||||
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
|
||||
or buy self-hosted and link.
|
||||
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
|
||||
paid and not linked is a customer who has paid for nothing yet.
|
||||
- Licence download failure: show the blob inline as a fallback so the customer is
|
||||
never blocked by a file download.
|
||||
|
||||
## Testing
|
||||
|
||||
Component and integration tests with mocked API responses. The repo has no
|
||||
frontend test setup today; this is where one starts, scoped to the flows that
|
||||
lose money or leak data when broken.
|
||||
|
||||
1. Customer session on `/staff/*` redirects; staff session reaches it.
|
||||
2. Instance card renders correctly for each licence state, including expired,
|
||||
and the expired copy names what still works.
|
||||
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
|
||||
client-side; already-linked UUID surfaces the backend's message.
|
||||
4. Relink shows the remaining allowance and disables at zero with the support
|
||||
message.
|
||||
5. Licence download failure falls back to the inline blob.
|
||||
6. Not-connected state renders when the API is unreachable.
|
||||
7. Staff dashboard renders each alert category and links to the right resource.
|
||||
8. Plan edit requires confirmation and shows the "existing licences unaffected"
|
||||
wording.
|
||||
9. Instance search by UUID returns the instance.
|
||||
10. Licence history timeline renders every reason type in order.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Test suite green.
|
||||
2. Full manual pass, self-hosted purchase to working licence, using only the UI
|
||||
and no documentation — timed, and if it takes more than five minutes the flow
|
||||
needs work.
|
||||
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
|
||||
within a minute, confirm the instance's own settings page agrees.
|
||||
4. Staff pass: find an account by instance UUID, read its licence history,
|
||||
reissue, confirm the control plane picks it up.
|
||||
5. Responsive check at mobile width — a customer hit by an expiry email will open
|
||||
this on a phone.
|
||||
6. `docker build` from the repo root succeeds and the image runs with
|
||||
`ADMIN_API_URL` baked in.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
|
||||
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
|
||||
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
|
||||
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
|
||||
@@ -0,0 +1,353 @@
|
||||
# Spec 2 — Instance Licensing and Enforcement
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
|
||||
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
|
||||
needed.
|
||||
|
||||
## Context
|
||||
|
||||
Spec 1 defines what a license is. This spec makes the control plane hold one,
|
||||
act on it, and let a self-hosted operator paste one in.
|
||||
|
||||
The guiding rule: **an expired license must never break a running fleet.** Agents
|
||||
keep their keys, monitors keep watching, alerts keep firing. What stops is
|
||||
growth and change. A customer whose card fails should be inconvenienced, not
|
||||
paged at 3am because their monitoring went dark when Vantage decided to sulk.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A license lives on the instance document and is verified on read.
|
||||
2. Enforcement is deny-by-default: a new mutating route is gated because of where
|
||||
it is mounted, not because someone remembered.
|
||||
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
|
||||
4. Self-hosted operators get an instance UUID they can hand to the admin site.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
|
||||
- Any outbound network call. Verification is offline, permanently.
|
||||
- Per-user or per-role licensing. The unit is the instance.
|
||||
|
||||
## Design
|
||||
|
||||
### Instance identity
|
||||
|
||||
Every install already has an `Instance` document with an `InstanceID` UUID. For
|
||||
cloud instances this is created by signup; for self-hosted it is created by
|
||||
`/setup`.
|
||||
|
||||
Change to `/setup`: after bootstrapping the first instance and its owner, the
|
||||
setup page **displays the instance UUID** with a copy button and the text that
|
||||
it is needed to activate a license. It is also shown permanently on
|
||||
`/settings/license`.
|
||||
|
||||
No new identifier is invented. The instance UUID is the licensing identity.
|
||||
|
||||
### Storage
|
||||
|
||||
`shared/models.Instance` gains:
|
||||
|
||||
```go
|
||||
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
|
||||
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
|
||||
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
|
||||
```
|
||||
|
||||
The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
|
||||
cache for listing and for the admin site's queries, rewritten from the verified
|
||||
payload every time a blob is accepted. Nothing reads them for enforcement.
|
||||
|
||||
`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
|
||||
it is signed public data — but there is no reason to spray it through API
|
||||
responses.
|
||||
|
||||
### Runtime state
|
||||
|
||||
```go
|
||||
type State struct {
|
||||
Status license.State // valid | expired | invalid
|
||||
Reason string
|
||||
Tier string
|
||||
ExpiresAt *time.Time
|
||||
Limits license.Limits
|
||||
Features map[string]bool
|
||||
}
|
||||
```
|
||||
|
||||
Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
|
||||
alongside the existing instance cache and invalidated immediately when a blob is
|
||||
stored.
|
||||
|
||||
Three inputs, in precedence order:
|
||||
|
||||
1. `Instance.LicenseBlob`.
|
||||
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
|
||||
stored blob**. This lets an automated self-hosted deployment ship a license
|
||||
without a human pasting one. A blob stored through the UI always wins
|
||||
afterwards, so an operator is never locked out by a stale environment value.
|
||||
3. Neither → `Status: invalid`, `Reason: no_license`.
|
||||
|
||||
The verifier is called with `InstanceID` from the instance document and
|
||||
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
|
||||
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
|
||||
an operator who removes the variable gets the stricter mode, not the looser one.
|
||||
|
||||
`invalid` and `expired` degrade identically. They differ only in the message.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Three layers, deliberately separate because they answer different questions.
|
||||
|
||||
**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
|
||||
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.
|
||||
|
||||
```go
|
||||
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
|
||||
```
|
||||
|
||||
Non-`valid` → `403 {"error":"license_required","state":"expired","reason":"..."}`.
|
||||
|
||||
Mounting at the group means **a route added tomorrow is gated by default**. That
|
||||
is the whole point of putting it here rather than on individual handlers.
|
||||
|
||||
Explicit exemptions, allow-listed by path because they must work in degraded
|
||||
mode:
|
||||
|
||||
| Route | Why |
|
||||
|---|---|
|
||||
| `POST /api/license` | Pasting a valid license is how you recover |
|
||||
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
|
||||
| `DELETE` on any resource | Deleting is how you get back under a limit |
|
||||
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |
|
||||
|
||||
The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
|
||||
servers must be able to remove 7 of them. Blocking deletes would trap them.
|
||||
|
||||
**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
|
||||
it:
|
||||
|
||||
- `console` → `POST /api/console/connect`, `GET /api/console/tunnel`
|
||||
- `oidc` → `GET,PUT /api/instance/oidc`
|
||||
|
||||
Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.
|
||||
|
||||
OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
|
||||
and outside `/api`. They check the feature directly and, if unavailable, redirect
|
||||
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
|
||||
sessions are not terminated** — losing the feature stops new SSO logins, it does
|
||||
not evict people mid-session.
|
||||
|
||||
**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
|
||||
count that middleware does not have:
|
||||
|
||||
| Limit | Checked in |
|
||||
|---|---|
|
||||
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
|
||||
| `max_secret_groups` | `services.CreateSecretGroup` |
|
||||
| `max_channels` | `services.CreateChannel` |
|
||||
|
||||
`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
|
||||
|
||||
Counts are of live rows: revoked assignments and deleted servers do not count.
|
||||
|
||||
**Over-limit instances are never truncated.** A Professional instance with 20
|
||||
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
|
||||
Deleting resources is always permitted. Silently disabling a customer's servers
|
||||
because their card expired is not a behaviour this system will have.
|
||||
|
||||
### Background work in degraded mode
|
||||
|
||||
This is where "read-only" needs to be specific, because these paths do not go
|
||||
through gin at all.
|
||||
|
||||
| Subsystem | Degraded behaviour |
|
||||
|---|---|
|
||||
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
|
||||
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
|
||||
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
|
||||
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
|
||||
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
|
||||
| Inventory, heartbeat, update reporting | Unaffected. |
|
||||
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
|
||||
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
|
||||
| Log retention sweep, offline sweep | Unaffected. |
|
||||
|
||||
Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
|
||||
is the single most important line in this spec: **billing state must not take
|
||||
away a customer's ability to know their infrastructure is on fire.**
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
GET /api/license any authenticated user
|
||||
POST /api/license owner only
|
||||
```
|
||||
|
||||
`GET` returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": "…",
|
||||
"state": "valid",
|
||||
"reason": "",
|
||||
"tier": "professional",
|
||||
"expires_at": "2027-07-24T00:00:00Z",
|
||||
"days_remaining": 365,
|
||||
"limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
|
||||
"features": { "console": true, "oidc": true },
|
||||
"usage": { "servers": 12, "secret_groups": 4, "channels": 2 },
|
||||
"source": "stored"
|
||||
}
|
||||
```
|
||||
|
||||
`usage` is included so the UI can render "12 of 3 servers" honestly when an
|
||||
instance is over its limit, rather than pretending.
|
||||
|
||||
`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
|
||||
deployment mode, and on success stores the blob, refreshes the cache, and writes
|
||||
an audit event. On failure it returns `400` with the specific reason:
|
||||
|
||||
| Reason | Message |
|
||||
|---|---|
|
||||
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
|
||||
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
|
||||
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
|
||||
| `expired` | This licence expired on `<date>`. |
|
||||
|
||||
An **expired** blob is still stored if it is otherwise valid, so the UI can show
|
||||
what expired and when. An **invalid** blob is rejected and the previous one kept.
|
||||
|
||||
Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
|
||||
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.
|
||||
|
||||
### Frontend
|
||||
|
||||
`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
|
||||
invalidated after a successful paste.
|
||||
|
||||
- **Banner, persistent, top of every page** when `state != valid`:
|
||||
- `expired` — "Your Vantage licence expired on `<date>`. Your servers and
|
||||
monitors are still running, but changes are disabled until it is renewed."
|
||||
with a link to the admin site.
|
||||
- `invalid` / `no_license` — "This instance has no valid licence. Add one in
|
||||
Settings → Licence."
|
||||
- **Warning banner** in the final 14 days of a valid term, dismissible per
|
||||
session.
|
||||
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
|
||||
customer cannot buy what they cannot see, and a feature that vanishes reads as
|
||||
a bug.
|
||||
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
|
||||
servers used" with the create button disabled at the cap.
|
||||
- `/settings/license`: current state, tier, expiry, limits with live usage, the
|
||||
instance UUID with a copy button, and a textarea plus file upload for a new
|
||||
blob. Owner-only; other roles see the state read-only.
|
||||
|
||||
### Grandfathering existing tenants
|
||||
|
||||
Migration `0005_grandfather_licenses`, cloud only, guarded on
|
||||
`VANTAGE_DEPLOYMENT == "cloud"`:
|
||||
|
||||
For every instance with no `license_blob`, issue a Professional license expiring
|
||||
**one year** from the migration date and store it.
|
||||
|
||||
The migration cannot sign — the server has no private key and, per spec 1, no
|
||||
signing code. So the blobs are **generated ahead of time with `lkctl`** and
|
||||
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
|
||||
instance ID to blob. The migration stores what it is given, verifies each blob
|
||||
against its instance before storing, and logs any instance it had no blob for.
|
||||
|
||||
Clumsy, and correct. The alternative is putting a signing key in the control
|
||||
plane, which is the thing this design most wants to avoid.
|
||||
|
||||
Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
|
||||
and read-only until an operator pastes a key — which is the intended behaviour
|
||||
for a paid product, and is why the release notes must lead with it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit, no database:**
|
||||
|
||||
1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
|
||||
environment used when no blob; neither → `no_license`.
|
||||
2. Feature map construction from the payload's `Features` slice.
|
||||
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.
|
||||
|
||||
**Middleware, with a stub state:**
|
||||
|
||||
4. `GET` passes in every state.
|
||||
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
|
||||
`invalid` — except `DELETE`, which passes in all states.
|
||||
6. `POST /api/license` passes when `expired` (the recovery path).
|
||||
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
|
||||
8. `RequireFeature("console")` passes with the feature, `403`s without it.
|
||||
9. **Coverage test:** enumerate every registered route and assert that every
|
||||
non-`GET` route is either behind `RequireActiveLicense` or on the exemption
|
||||
allow-list. This test is what stops layer 1 rotting as routes are added.
|
||||
|
||||
**Service layer, against MongoDB:**
|
||||
|
||||
10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
|
||||
11. Over-limit instance can still `DELETE` a server, and can create again once
|
||||
back under the cap.
|
||||
12. Deleted and revoked rows do not count toward limits.
|
||||
|
||||
**Degraded background behaviour:**
|
||||
|
||||
13. Monitor scheduler executes checks for an instance with an expired license.
|
||||
14. An incident opened during degraded mode still dispatches notifications.
|
||||
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
|
||||
16. A new agent registering against an over-limit instance is refused; an
|
||||
existing agent re-registers successfully.
|
||||
17. A workflow run in flight when the license expires completes its remaining
|
||||
steps.
|
||||
|
||||
**API:**
|
||||
|
||||
18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
|
||||
19. Each rejection reason returns its own message and leaves the stored blob
|
||||
untouched.
|
||||
20. An expired-but-well-formed blob is stored and reported as `expired`.
|
||||
21. Non-owner `POST` → `403`.
|
||||
|
||||
**Migration:**
|
||||
|
||||
22. `0005` stores and verifies supplied blobs, skips instances that already have
|
||||
one, logs instances with no blob supplied, and is a no-op when
|
||||
`VANTAGE_DEPLOYMENT != "cloud"`.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full test suite green, including the route-coverage test (test 9).
|
||||
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
|
||||
paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
|
||||
confirm the banner appears, mutations `403`, **monitors keep firing**, and
|
||||
pasting a fresh license restores normal operation without a restart.
|
||||
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
|
||||
are visibly disabled with upgrade tooltips, and that a 4th server is refused
|
||||
with a clear message.
|
||||
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
|
||||
with `deployment_mismatch`.
|
||||
5. Confirm a license issued for another instance is rejected with
|
||||
`instance_mismatch` and the message shows the correct local UUID.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
|
||||
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
|
||||
3. Verify every cloud instance reports `valid`, Professional, one year out.
|
||||
4. Unset the variable on the next deploy — it is single-use.
|
||||
5. Release notes for self-hosted must state plainly that upgrading requires a
|
||||
licence key, and how to get one.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
|
||||
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
|
||||
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
|
||||
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
|
||||
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
|
||||
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
|
||||
@@ -0,0 +1,240 @@
|
||||
# Spec 0b — Org to Instance Rename
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a (`shared-module`)
|
||||
Ships: independently, before any licensing code
|
||||
|
||||
## Context
|
||||
|
||||
The licensing model separates two concepts that the codebase currently conflates
|
||||
under one word:
|
||||
|
||||
- **Account** — a paying customer. Lives only in the admin control plane
|
||||
(spec 3). The control plane never learns about it.
|
||||
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
|
||||
its own servers, keys, workflows, monitors and secrets. One license attaches
|
||||
to one instance.
|
||||
|
||||
Today's control-plane `Org` **is** an Instance. An Account may hold several,
|
||||
some cloud and some self-hosted, and the self-hosted ones have no row in the
|
||||
cloud database at all.
|
||||
|
||||
Keeping the name `Org` would leave the control plane using a word that means
|
||||
something different in the admin site, in Paddle, and in every support
|
||||
conversation. This spec renames it everywhere, including on disk.
|
||||
|
||||
This is the highest-risk change in the programme: `org_id` is the tenant
|
||||
isolation key on every document in every collection. It is done alone, before
|
||||
anything else, so that nothing else is in flight when it deploys.
|
||||
|
||||
## Goals
|
||||
|
||||
1. `Instance` is the only word for a tenant, in code, API, UI and database.
|
||||
2. No document is lost and no tenant scoping is weakened.
|
||||
3. The migration is reversible.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Any behaviour change. Same routes' semantics, same permissions, same data.
|
||||
- Introducing Accounts. The control plane never gets them.
|
||||
- Touching the agent. It talks gRPC and has no concept of a tenant.
|
||||
|
||||
## Design
|
||||
|
||||
### Naming map
|
||||
|
||||
| Today | After |
|
||||
|---|---|
|
||||
| collection `orgs` | `instances` |
|
||||
| collection `org_oidc` | `instance_oidc` |
|
||||
| field `org_id` (all collections) | `instance_id` |
|
||||
| `models.Org` | `models.Instance` |
|
||||
| `Org.OrgID` | `Instance.InstanceID` |
|
||||
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
|
||||
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
|
||||
| `services/org_oidc.go` | `services/instance_oidc.go` |
|
||||
| `auth/orghost.go` | `auth/instancehost.go` |
|
||||
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
|
||||
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
|
||||
| session field `org_id` | `instance_id` |
|
||||
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
|
||||
| UI copy "Organisation" | "Instance" |
|
||||
|
||||
Reserved slugs gain no new entries here, but note `admin` is already reserved,
|
||||
which the admin site relies on later.
|
||||
|
||||
### Collections carrying `org_id`
|
||||
|
||||
All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
|
||||
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
|
||||
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
|
||||
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.
|
||||
|
||||
`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
|
||||
`instance_name` for consistency; it is sitesvc-private so this is free.
|
||||
|
||||
The migration must derive this list from a constant in code, not from a
|
||||
hand-written list in a runbook, so that a collection added between design and
|
||||
deploy is not silently missed:
|
||||
|
||||
```go
|
||||
var scopedCollections = []string{ /* the list above */ }
|
||||
```
|
||||
|
||||
A boot-time assertion (spec 2 onwards) checks that no collection outside this
|
||||
list contains an `org_id` field. Cheap insurance against a future collection
|
||||
being added without being renamed.
|
||||
|
||||
### Migration `0004_org_to_instance`
|
||||
|
||||
Recorded in `migrations` like the existing three. Runs after
|
||||
`0003_missed_org_scopes`.
|
||||
|
||||
**The migration only renames. It never deletes and never drops.** A bad deploy
|
||||
is recovered by running the inverse rename, not by restoring a backup.
|
||||
|
||||
Steps, in order:
|
||||
|
||||
1. **Guard.** If collection `instances` already exists and `orgs` does not, the
|
||||
migration has already run against this database by an earlier binary; record
|
||||
the marker and return. Idempotency matters because the marker write and the
|
||||
data work are not in one transaction.
|
||||
2. **Rename collections.** `orgs` → `instances`, `org_oidc` → `instance_oidc`,
|
||||
via `adminCommand{renameCollection}`. Fails loudly if the target exists.
|
||||
3. **Rename the field.** For each collection in `scopedCollections`:
|
||||
`UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
|
||||
Record `matched` and `modified` per collection in the log.
|
||||
4. **Verify.** For each collection, assert
|
||||
`CountDocuments({org_id: {$exists: true}}) == 0` and
|
||||
`CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
|
||||
aborts before the marker is written, leaving the migration to retry.
|
||||
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
|
||||
unique `settings.instance_id`, the ESO token-hash index, and any compound
|
||||
scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
|
||||
the field rename but are re-declared idempotently.
|
||||
6. **Write the marker.**
|
||||
|
||||
Steps 2–4 are not atomic across collections. Mongo multi-document transactions
|
||||
would require a replica set, which is not guaranteed for self-hosted installs.
|
||||
Instead the migration is written to be **safely re-runnable**: `$rename` on a
|
||||
document that has already been renamed matches nothing, and the collection
|
||||
rename is guarded in step 1.
|
||||
|
||||
Rollback, if ever needed, is the same code with the rename reversed, shipped as
|
||||
a one-shot command rather than a migration — deliberately manual, because the
|
||||
only reason to run it is a decision to revert the release.
|
||||
|
||||
### Version skew
|
||||
|
||||
`sitesvc` and `server` write the same documents. A skew where one writes
|
||||
`org_id` and the other reads `instance_id` creates tenants that are invisible to
|
||||
the application — the exact failure `CLAUDE.md` warns about.
|
||||
|
||||
After spec 0a both read the shape from `shared`, so the skew window is a
|
||||
deployment-ordering problem rather than a code-drift problem:
|
||||
|
||||
- Both images are built from the same commit and deployed together.
|
||||
- The migration runs from the `server` container at boot, as the existing three
|
||||
do.
|
||||
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
|
||||
start otherwise, with the message
|
||||
`instances collection not found; deploy the control plane first`. Failing to
|
||||
start is strictly better than provisioning into a collection nobody reads.
|
||||
|
||||
The self-hosted deployment runs no sitesvc, so it sees only the server change.
|
||||
|
||||
### API and frontend
|
||||
|
||||
REST route renames are **breaking**, but every consumer is first-party (`web/`)
|
||||
and ships in the same release. No compatibility aliases — a permanent dual path
|
||||
in the tenant-scoping layer is worse than a coordinated release.
|
||||
|
||||
`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
|
||||
"Organisation" to "Instance", and the settings route `/settings/org` →
|
||||
`/settings/instance`.
|
||||
|
||||
`site/` marketing copy changes where it says "organisation" about a tenant. Where
|
||||
it means the customer, it becomes "account" — that word now has a specific
|
||||
meaning and the marketing site is the first place a customer meets it.
|
||||
|
||||
## Testing
|
||||
|
||||
**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.
|
||||
|
||||
This is the change where that costs the most: it moves the tenant isolation key
|
||||
across 17 collections, and a mistake orphans a customer's entire fleet rather
|
||||
than breaking a build. The compensating controls are therefore not optional, and
|
||||
the implementation plan makes each a mandatory step:
|
||||
|
||||
1. **Dry run against a restored copy** before the code is even committed —
|
||||
migrate a `mongorestore`d duplicate of production and read the per-collection
|
||||
rename counts.
|
||||
2. **Idempotency by hand** — run the dry run twice; the second must complete
|
||||
with no error and nothing left to rename.
|
||||
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
|
||||
migration; it must complete and leave every document carrying `instance_id`.
|
||||
4. **Count comparison against a production snapshot** — record every
|
||||
collection's document count before and after; any difference stops the
|
||||
release.
|
||||
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
|
||||
`servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
|
||||
`org_id` before and by `instance_id` after. Identical, or the release stops.
|
||||
This is the check that proves tenant isolation survived.
|
||||
6. **Stale-field sweep** — assert no collection anywhere still holds an
|
||||
`org_id`.
|
||||
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
|
||||
the counts return to baseline and the pre-release binary boots against it.
|
||||
Deploying without having done this is not permitted.
|
||||
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
|
||||
and start normally against a migrated one.
|
||||
|
||||
`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
|
||||
suite it is the standing protection against a future collection being added
|
||||
without being added to `ScopedCollections`.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
Run against a **restored production snapshot**, not a synthetic database:
|
||||
|
||||
1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
|
||||
2. Run the migration.
|
||||
3. Assert every count is identical afterwards.
|
||||
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
|
||||
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
|
||||
after (by `instance_id`) and confirm identical result sets. This is the test
|
||||
that proves tenant isolation survived.
|
||||
6. Boot the server against the migrated snapshot; log in as a real user; confirm
|
||||
servers, keys, workflows, monitors and secrets all list correctly.
|
||||
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
|
||||
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
|
||||
with the expected message.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Take a database backup. Not optional — this is the one change where the
|
||||
inverse rename is the recovery path and the backup is the second.
|
||||
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
|
||||
3. Server boots, migration runs, marker recorded.
|
||||
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
|
||||
will restart cleanly.
|
||||
|
||||
Expect a short window during the server restart where the API is unavailable.
|
||||
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
|
||||
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
|
||||
| sitesvc deployed before server | Boot guard refuses to start |
|
||||
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
|
||||
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
|
||||
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |
|
||||
|
||||
## Follow-on
|
||||
|
||||
With `Instance` established, spec 1 (`licensing-core`) can define a license
|
||||
payload that binds to `instance_id` without inventing a word the codebase does
|
||||
not use.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Spec 1 — Licensing Core
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
|
||||
Ships: independently. Adds a package and a CLI; changes no running behaviour.
|
||||
|
||||
## Context
|
||||
|
||||
Licenses are **offline-verified signed blobs**. A Vantage server checks a
|
||||
signature and an expiry date and asks nobody's permission. That choice buys
|
||||
self-hosted installs that work in air-gapped networks and a control plane with no
|
||||
licensing availability dependency.
|
||||
|
||||
It costs revocation. Once issued, a license is valid until it expires, whatever
|
||||
Paddle later says. Every other decision in the programme follows from accepting
|
||||
that: Self Hosted is annual-only so the unenforceable window is bounded, and
|
||||
cancellation takes effect at term end rather than immediately (spec 5).
|
||||
|
||||
This spec defines the payload, the signing and verification, and a CLI to issue
|
||||
licenses by hand. It deliberately lands before the admin site so that specs 1+2
|
||||
together give working licensing with no new service to operate.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One struct, in `shared`, read identically by the verifier and the issuer.
|
||||
2. Verification that needs no network, no clock sync beyond a rough one, and no
|
||||
configuration.
|
||||
3. A hand-issuance path good enough to run production on until spec 3 lands.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
|
||||
history.
|
||||
- Deciding tier contents. Tiers are data; the values in this spec are the
|
||||
initial seed, and spec 3's `plans` table becomes their home.
|
||||
- Any phone-home, revocation list or online check. There is none, anywhere, by
|
||||
design.
|
||||
|
||||
## Design
|
||||
|
||||
### Package
|
||||
|
||||
`shared/license/`, inside the module created by spec 0a:
|
||||
|
||||
```
|
||||
shared/license/
|
||||
├── license.go # License, Limits, feature constants
|
||||
├── sign.go # Sign, build-tagged out of the server binary
|
||||
├── verify.go # Verify, Parse
|
||||
├── keys.go # trustedPublicKeys
|
||||
└── license_test.go
|
||||
```
|
||||
|
||||
Uses `github.com/hyperboloide/lk` (ECDSA P-384 with SHA-256, base32 encoding).
|
||||
|
||||
### Payload
|
||||
|
||||
```go
|
||||
package license
|
||||
|
||||
type License struct {
|
||||
ID string `json:"id"` // uuid, for support and audit
|
||||
InstanceID string `json:"instance_id"` // the instance this license is bound to
|
||||
AccountID string `json:"account_id"` // admin-side customer, informational
|
||||
InstanceName string `json:"instance_name"` // display only
|
||||
Tier string `json:"tier"` // "free" | "professional" | "self_hosted"
|
||||
Deployment string `json:"deployment"` // "cloud" | "self_hosted"
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Limits Limits `json:"limits"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"` // -1 means unlimited
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
}
|
||||
|
||||
const (
|
||||
FeatureConsole = "console" // browser SSH/RDP/VNC
|
||||
FeatureOIDC = "oidc" // per-instance single sign-on
|
||||
)
|
||||
|
||||
const (
|
||||
TierFree = "free"
|
||||
TierProfessional = "professional"
|
||||
TierSelfHosted = "self_hosted"
|
||||
|
||||
DeploymentCloud = "cloud"
|
||||
DeploymentSelfHosted = "self_hosted"
|
||||
)
|
||||
```
|
||||
|
||||
`InstanceID` is **always populated**. There is no unbound license: the
|
||||
self-hosted purchase flow (spec 4) links the instance UUID before the license is
|
||||
issued, so binding happens at signing time. This removes the claim endpoint, the
|
||||
best-effort phone-home and the multi-claim reconciliation that an unbound design
|
||||
would have needed.
|
||||
|
||||
**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
|
||||
`Tier` exists for display, support and analytics. Adding a tier, or changing what
|
||||
a tier includes, must never require a server release.
|
||||
|
||||
### Tier seed values
|
||||
|
||||
Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
|
||||
each license at issue, so changing the table never rewrites an issued license —
|
||||
the same principle as `workflow_runs.steps_snapshot`.
|
||||
|
||||
| | Free | Professional | Self Hosted |
|
||||
|---|---|---|---|
|
||||
| `deployment` | `cloud` | `cloud` | `self_hosted` |
|
||||
| `max_servers` | 3 | -1 | -1 |
|
||||
| `max_secret_groups` | 1 | -1 | -1 |
|
||||
| `max_channels` | 1 | -1 | -1 |
|
||||
| `console` | no | yes | yes |
|
||||
| `oidc` | no | yes | yes |
|
||||
| billing term | monthly, £0 | monthly or annual | **annual only** |
|
||||
|
||||
Free is cloud-only. A self-hosted install can never hold a valid Free license
|
||||
because Free is only ever signed with `deployment: "cloud"`, and verification
|
||||
rejects a deployment mismatch. There is no server-side flag to edit.
|
||||
|
||||
### Signing
|
||||
|
||||
```go
|
||||
//go:build !noSign
|
||||
|
||||
func Sign(l License, privateKeyHex string) (string, error)
|
||||
```
|
||||
|
||||
Marshals to canonical JSON, signs with lk, returns the base32 blob.
|
||||
|
||||
`Sign` is excluded from the server binary with a build tag. The server has no
|
||||
reason to hold signing code and there is no reason to ship it into a customer's
|
||||
data centre.
|
||||
|
||||
The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
|
||||
CLI now, the admin backend from spec 3. It is never in the repo, never in an
|
||||
image, never in the control plane's environment.
|
||||
|
||||
### Verification
|
||||
|
||||
```go
|
||||
type VerifyOpts struct {
|
||||
InstanceID string // required: the verifier's own instance
|
||||
Deployment string // required: "cloud" or "self_hosted"
|
||||
Now time.Time // injectable for tests
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
License License
|
||||
State State // Valid, Expired, Invalid
|
||||
Reason string
|
||||
}
|
||||
|
||||
const (
|
||||
StateValid State = "valid"
|
||||
StateExpired State = "expired"
|
||||
StateInvalid State = "invalid"
|
||||
)
|
||||
|
||||
func Verify(blob string, opts VerifyOpts) Result
|
||||
```
|
||||
|
||||
Checks, in order, stopping at the first failure:
|
||||
|
||||
1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
|
||||
Failure → `Invalid`, reason `bad_signature`.
|
||||
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
|
||||
`deployment_mismatch`. This is the check that makes Free cloud-only.
|
||||
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
|
||||
`instance_mismatch`.
|
||||
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
|
||||
5. Otherwise `Valid`.
|
||||
|
||||
**`Expired` and `Invalid` are distinct states and the caller treats them
|
||||
differently in messaging** (spec 2), even though both degrade the instance the
|
||||
same way. A customer whose card failed and a customer who pasted the wrong blob
|
||||
need different words.
|
||||
|
||||
`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
|
||||
expiry. Used by the admin site to display a license and by support to inspect a
|
||||
blob a customer has emailed in. Never used for enforcement.
|
||||
|
||||
Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
|
||||
clock is wrong by enough to matter has bigger problems, and a tolerance window is
|
||||
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
|
||||
which is the signal that a clock is badly off.
|
||||
|
||||
### Key management
|
||||
|
||||
```go
|
||||
// trustedPublicKeys is ordered. Index 0 is the current signing key.
|
||||
// To rotate: prepend the new key, ship a server release, then reissue.
|
||||
// Remove a retired key only after every license signed with it has expired.
|
||||
var trustedPublicKeys = []string{
|
||||
"<base32 ECDSA P-384 public key>",
|
||||
}
|
||||
```
|
||||
|
||||
A slice from day one even though it holds one entry, because retrofitting a
|
||||
single-key verifier into a multi-key one during an incident is not a thing to
|
||||
plan for.
|
||||
|
||||
Public keys are compiled in. They are not configurable, because a configurable
|
||||
trust root is a licensing bypass: a self-hosted operator could point it at a
|
||||
keypair they generated.
|
||||
|
||||
Key generation is a documented one-off:
|
||||
|
||||
```
|
||||
go run ./shared/license/cmd/lkgen keypair
|
||||
```
|
||||
|
||||
prints a private key (base32) for the vault and a public key (base32) to paste into
|
||||
`keys.go`. The private key is stored in a password manager and in the admin
|
||||
service's environment. **If it is lost, no new licenses can be issued for any
|
||||
existing customer without a server release.** Back it up in two places.
|
||||
|
||||
### CLI issuer
|
||||
|
||||
`shared/license/cmd/lkctl`, built only for internal use:
|
||||
|
||||
```
|
||||
lkctl keypair
|
||||
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
|
||||
--tier=professional --deployment=cloud \
|
||||
--term=1y [--account-id=<id>] [--out=acme.lic]
|
||||
lkctl inspect <file-or-blob>
|
||||
```
|
||||
|
||||
`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
|
||||
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
|
||||
explicit `--expires=RFC3339`.
|
||||
|
||||
This is the production issuance path until spec 3 ships. It is kept afterwards
|
||||
for support and disaster recovery — if the admin service is down and a customer's
|
||||
license expires, a blob can still be cut by hand.
|
||||
|
||||
Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
|
||||
`licenses` table from `inspect` output when it takes over.
|
||||
|
||||
## Testing
|
||||
|
||||
`shared/license` is pure and needs no database, so this suite is fast and
|
||||
thorough. Written test-first.
|
||||
|
||||
1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
|
||||
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
|
||||
3. Tampering with intent: re-sign a payload with a *different* keypair →
|
||||
`Invalid`. This is the test that proves an attacker cannot mint licenses.
|
||||
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
|
||||
future → `Valid`.
|
||||
5. Deployment mismatch: a Free (`cloud`) license verified with
|
||||
`Deployment: "self_hosted"` → `Invalid`, `deployment_mismatch`.
|
||||
6. Instance mismatch: correct signature, different `InstanceID` → `Invalid`,
|
||||
`instance_mismatch`.
|
||||
7. Check order: a blob that is both expired *and* instance-mismatched reports
|
||||
`instance_mismatch`, not `Expired`. Order is part of the contract because the
|
||||
reason drives the message.
|
||||
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
|
||||
with a key not in the slice does not.
|
||||
9. `Parse` returns the payload for an expired and for a mismatched license, and
|
||||
errors for a bad signature.
|
||||
10. Unicode and long instance names survive the round trip.
|
||||
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
|
||||
keypair, must keep verifying. This catches an accidental change to the
|
||||
canonical JSON encoding, which would silently invalidate every issued
|
||||
license in the field.
|
||||
|
||||
Test 11 matters more than it looks. The encoding is part of the wire format.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. `go test ./shared/license/...` passes, including the golden fixture.
|
||||
2. `lkctl keypair` → `lkctl issue` → `lkctl inspect` round trips at the command
|
||||
line.
|
||||
3. `go build -tags noSign ./server/...` succeeds and
|
||||
`go tool nm` on the resulting binary shows no `license.Sign` symbol.
|
||||
4. The production keypair is generated, the private half stored in two places,
|
||||
and the public half committed in `keys.go`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
|
||||
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
|
||||
| Canonical encoding changes | Golden fixture test |
|
||||
| Signing code shipped to customers | Build tag plus a symbol check in verification |
|
||||
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
|
||||
@@ -0,0 +1,271 @@
|
||||
# Spec 5 — Paddle Billing
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 3 (`admin-backend`)
|
||||
Ships: after spec 3. Can be developed in parallel with spec 4.
|
||||
|
||||
## Context
|
||||
|
||||
Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
|
||||
customer billing portal. This spec connects Paddle's subscription lifecycle to
|
||||
the licence issuance functions spec 3 defines, and moves cloud signup off
|
||||
sitesvc.
|
||||
|
||||
The central constraint, restated because every table below follows from it:
|
||||
**licences are offline-verified, so nothing Paddle says can revoke one early.**
|
||||
Cancellation takes effect when the licence expires. Self Hosted is annual-only to
|
||||
bound that window; the alternative — a customer holding a valid key for eleven
|
||||
months after cancelling a monthly plan — is not acceptable.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A catalog in Paddle sandbox, promotable to production by configuration alone.
|
||||
2. Webhooks that issue and renew licences reliably, including under retries and
|
||||
out-of-order delivery.
|
||||
3. Cloud signup owned by one service instead of two.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Building any part of billing Paddle already provides.
|
||||
- Usage-based or metered pricing. Tiers are flat.
|
||||
- Proration logic. Paddle handles money; we react to the resulting subscription
|
||||
state.
|
||||
|
||||
## Design
|
||||
|
||||
### Catalog
|
||||
|
||||
Three products, created in **sandbox** first. Production is a configuration
|
||||
change: the same `plans` rows carry different `paddle_product_id` and
|
||||
`paddle_price_ids`, selected by `PADDLE_ENV`.
|
||||
|
||||
| Product | Prices | Notes |
|
||||
|---|---|---|
|
||||
| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. |
|
||||
| Vantage Professional | monthly, annual | Cloud |
|
||||
| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year |
|
||||
|
||||
**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are
|
||||
edited through the staff UI. A price change in Paddle is a data edit, not a
|
||||
deploy.
|
||||
|
||||
`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This
|
||||
is what lets a webhook route without a lookup table, and it is why the
|
||||
self-hosted flow creates the instance record *before* checkout completes.
|
||||
|
||||
### Checkout
|
||||
|
||||
Paddle Checkout, overlay mode, in the admin site.
|
||||
|
||||
**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing
|
||||
Paddle customer reused.
|
||||
|
||||
**Self-hosted purchase** — the instance does not exist yet. Order:
|
||||
|
||||
```
|
||||
Customer creates an admin-site account (verified email)
|
||||
Account row created, then an admin_instances row with status awaiting_link
|
||||
and a generated placeholder instance record
|
||||
Checkout opened with account_id and that instance row's id in custom_data
|
||||
subscription.created fires → subscription recorded, status awaiting_link,
|
||||
NO licence issued
|
||||
Customer pastes their install's UUID → instance_id set, status active
|
||||
→ licence issued and delivered
|
||||
```
|
||||
|
||||
The instance row exists before payment so the webhook has something to attach to.
|
||||
The licence is not issued until the UUID is known, because a licence with no
|
||||
instance to bind to cannot be signed — spec 1 has no unbound licence.
|
||||
|
||||
A customer who pays and never links has a subscription and no licence. Spec 4's
|
||||
staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email
|
||||
goes out at 24 hours and 72 hours. This is the most likely place for a paying
|
||||
customer to get stuck, so it gets active chasing rather than a support queue.
|
||||
|
||||
### Webhooks
|
||||
|
||||
`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`.
|
||||
An unsigned or badly signed request is rejected `401` and logged — never
|
||||
processed.
|
||||
|
||||
**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in
|
||||
`paddle_events` with a unique index before processing; a duplicate returns `200`
|
||||
without acting. `200` on duplicates matters — returning an error would make
|
||||
Paddle retry a message we have already handled, forever.
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. |
|
||||
| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. |
|
||||
| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. |
|
||||
| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. |
|
||||
| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. |
|
||||
| `transaction.payment_failed` | Record for staff visibility. No licence action. |
|
||||
| `customer.updated` | Sync `billing_email` onto the account. |
|
||||
|
||||
Out-of-order delivery is handled by making every handler a function of the
|
||||
subscription's *current* state as reported in the event payload, rather than of
|
||||
the transition. An `updated` arriving before its `created` creates the
|
||||
subscription row and proceeds.
|
||||
|
||||
Renewal licences are issued with a **3-day grace** past the period end (spec 3),
|
||||
so a webhook delayed by hours never produces a gap in coverage.
|
||||
|
||||
**Webhook failures must be visible.** Every failed handler writes to
|
||||
`admin_audit` and appears on the staff dashboard. A licence that silently failed
|
||||
to issue is a customer who paid and got nothing.
|
||||
|
||||
### Cancellation, stated plainly
|
||||
|
||||
When a customer cancels:
|
||||
|
||||
- Paddle stops billing at period end.
|
||||
- We issue no further licences.
|
||||
- Their current licence keeps working until it expires — up to a month for
|
||||
Professional monthly, up to a year for Self Hosted.
|
||||
- On expiry the instance degrades per spec 2: monitors keep running, changes stop.
|
||||
|
||||
This is documented in the terms and shown on the cancellation confirmation
|
||||
screen, because a customer who cancels and sees their instance keep working
|
||||
should understand why rather than assume the cancellation failed.
|
||||
|
||||
### Signup migration off sitesvc
|
||||
|
||||
Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification
|
||||
email, and provisioning on link click. It now needs to also create an Account, a
|
||||
Paddle customer, a Free subscription and a licence.
|
||||
|
||||
**Signup moves to the admin backend.** The form stays on the marketing site where
|
||||
customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the
|
||||
contact form only.
|
||||
|
||||
The reason is the one `CLAUDE.md` already names: provisioning logic duplicated
|
||||
across services drifts. Spec 0a removed the second copy; adding signup to admin
|
||||
while leaving it in sitesvc would create a third.
|
||||
|
||||
New flow, preserving every property of the current one:
|
||||
|
||||
```
|
||||
Marketing site form → POST /api/signup on admin
|
||||
→ pending record, password bcrypt cost 12, token 32 random bytes,
|
||||
only the SHA-256 hash stored, 24h expiry, TTL index
|
||||
→ verification email
|
||||
Link opened → FindOneAndDelete the pending record (atomic, before provisioning)
|
||||
→ shared.CreateInstance + shared.CreateUser in the control plane
|
||||
→ Account created
|
||||
→ Paddle customer created, Free subscription created
|
||||
→ Free licence issued and injected
|
||||
→ redirect to APP_LOGIN_URL with {slug} filled in
|
||||
```
|
||||
|
||||
Properties that must survive, verified by test:
|
||||
|
||||
- Nothing written to `instances` or `users` until the link is opened.
|
||||
- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create
|
||||
two instances.
|
||||
- Instance rollback if the owner insert fails, refusing to delete an instance
|
||||
that has users.
|
||||
- Re-submitting for the same address replaces the pending record.
|
||||
- Rate limited to 3 signups per IP per hour, plus the honeypot field.
|
||||
|
||||
Two failure modes are new, because provisioning now spans two systems:
|
||||
|
||||
- **Paddle customer creation fails** — the instance and user are already created.
|
||||
Complete the signup, record the account with an empty `PaddleCustomerID`, issue
|
||||
the Free licence anyway, and flag for staff. A new customer must never be
|
||||
blocked from signing in by a billing-system hiccup.
|
||||
- **Licence issuance fails** — the instance exists with no licence and is
|
||||
read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3)
|
||||
retries. The customer can log in and sees the licence banner.
|
||||
|
||||
Both resolve toward "the customer gets in", because a signup that half-fails
|
||||
silently is worse than either outcome.
|
||||
|
||||
sitesvc changes: signup, verify, `site_pending_signups` and the provisioning
|
||||
calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the
|
||||
marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two
|
||||
form targets are explicit rather than implied.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Required | Notes |
|
||||
|---|---|---|
|
||||
| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves |
|
||||
| `PADDLE_API_KEY` | yes | server-side API |
|
||||
| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build |
|
||||
| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through |
|
||||
| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template |
|
||||
|
||||
### Cutover
|
||||
|
||||
Signup migration is the only user-visible switch:
|
||||
|
||||
1. Deploy admin with signup enabled; sitesvc still serving its own.
|
||||
2. Point the marketing site's form at admin. Deploy.
|
||||
3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while
|
||||
its verify endpoint stays live. **Do not delete the collection until it is
|
||||
empty**, or someone's verification link breaks.
|
||||
4. Deploy sitesvc with signup removed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Webhooks:**
|
||||
|
||||
1. Each event type produces its documented action against a mock Paddle payload.
|
||||
2. Replaying an event ID is a no-op returning `200`.
|
||||
3. A bad signature is rejected `401` and processes nothing.
|
||||
4. `subscription.updated` before `subscription.created` creates the subscription
|
||||
and applies the update.
|
||||
5. `subscription.canceled` issues nothing and leaves the current licence intact.
|
||||
6. `past_due` leaves the licence intact and flags the account.
|
||||
7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new
|
||||
`ExpiresAt` is period end plus 3 days.
|
||||
8. A handler failure writes to `admin_audit` and surfaces on the dashboard.
|
||||
|
||||
**Checkout:**
|
||||
|
||||
9. `custom_data` round-trips account, instance and tier through to the webhook.
|
||||
10. Self-hosted checkout leaves the instance `awaiting_link` with no licence.
|
||||
11. Linking after checkout issues the licence.
|
||||
|
||||
**Signup:**
|
||||
|
||||
12. Nothing is written to `instances` or `users` before the link is opened.
|
||||
13. A double-clicked verification link creates exactly one instance.
|
||||
14. Owner-insert failure rolls the instance back; rollback refuses an instance
|
||||
with users.
|
||||
15. Re-submitting replaces the pending record and invalidates the earlier link.
|
||||
16. Rate limit and honeypot both reject.
|
||||
17. Paddle customer creation failure still completes signup and issues the Free
|
||||
licence.
|
||||
18. Licence issuance failure still lets the user log in, showing the banner.
|
||||
19. Expired pending records are dropped by the TTL index.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full suite green.
|
||||
2. Against Paddle **sandbox**, end to end for each tier: checkout with a test
|
||||
card, confirm the licence is issued, confirm the instance reports `valid`.
|
||||
3. Trigger a sandbox renewal and confirm the next term's licence arrives and is
|
||||
injected.
|
||||
4. Cancel in sandbox and confirm the licence keeps working to expiry, then the
|
||||
instance degrades correctly — monitors still running.
|
||||
5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences
|
||||
are created.
|
||||
6. Full signup end to end through admin, then confirm the new user can log into
|
||||
their control-plane instance and sees a valid Free licence.
|
||||
7. Confirm sitesvc's pending-signup collection is empty before its signup code is
|
||||
removed.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Duplicate licences from webhook retries | Unique index on event ID, checked before processing |
|
||||
| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences |
|
||||
| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen |
|
||||
| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty |
|
||||
| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site |
|
||||
| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret |
|
||||
| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h |
|
||||
@@ -0,0 +1,286 @@
|
||||
# Spec 0a — Shared Module Extraction
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Ships: independently. No dependency on any other licensing spec.
|
||||
|
||||
## Context
|
||||
|
||||
Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
|
||||
root `go.mod` and no `go.work`.
|
||||
|
||||
`sitesvc` writes into the same MongoDB collections the control plane reads, but
|
||||
cannot import the control plane, so it carries hand-copied duplicates:
|
||||
|
||||
- `sitesvc/internal/models/models.go` — `Org` and `User` mirrored field for field
|
||||
- `sitesvc/internal/provision/provision.go` — `Slugify`, `ReservedSlugs`,
|
||||
`MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules
|
||||
|
||||
Both files carry comments saying they must be changed in lockstep with the
|
||||
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
|
||||
match. **The duplication has already drifted.** The control plane's `CreateOrg`
|
||||
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
|
||||
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
|
||||
helper. They currently agree by luck, not by construction.
|
||||
|
||||
The licensing programme adds a fourth service (`admin`) that writes the license
|
||||
blob onto the same tenant document. Adding a third copy of these rules is not
|
||||
acceptable. This spec removes the duplication before any licensing code is
|
||||
written.
|
||||
|
||||
This spec is a **pure refactor**. No database document changes. No behaviour
|
||||
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
|
||||
spec 0b, deliberately kept separate so that a failed deploy has one suspect
|
||||
rather than two.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One authoritative definition of every document shape written by more than one
|
||||
service.
|
||||
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
|
||||
creation, rollback).
|
||||
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
|
||||
on the control plane. The original design intent survives; only the copying
|
||||
dies.
|
||||
4. The agent is untouched.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Renaming anything. That is spec 0b.
|
||||
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
|
||||
`server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
|
||||
`audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
|
||||
plane touches them, and hoisting them would make `shared` a dumping ground.
|
||||
- Merging the repo into a single module.
|
||||
|
||||
## Design
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
vantage/
|
||||
├── go.work # NEW: server, sitesvc, shared (NOT agent)
|
||||
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
|
||||
│ ├── go.mod
|
||||
│ ├── models/
|
||||
│ │ ├── org.go # Org
|
||||
│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
|
||||
│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings
|
||||
│ ├── provision/
|
||||
│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
|
||||
│ │ ├── org.go # CreateOrg
|
||||
│ │ ├── user.go # CreateUser, BcryptCost
|
||||
│ │ └── rollback.go # RollbackOrg
|
||||
│ └── indexes/
|
||||
│ └── indexes.go # EnsureCoreIndexes
|
||||
├── server/ # replace => ../shared
|
||||
├── sitesvc/ # replace => ../shared
|
||||
└── agent/ # untouched
|
||||
```
|
||||
|
||||
`go.work`:
|
||||
|
||||
```
|
||||
go 1.26
|
||||
|
||||
use (
|
||||
./shared
|
||||
./server
|
||||
./sitesvc
|
||||
)
|
||||
```
|
||||
|
||||
Each consumer's `go.mod` also carries an explicit replace:
|
||||
|
||||
```
|
||||
require github.com/mrhid6/vantage/shared v0.0.0
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
```
|
||||
|
||||
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
|
||||
across modules. The `replace` directives make Docker builds work whether or not
|
||||
`go.work` is present, and stop `go build` outside the workspace from silently
|
||||
trying to resolve `shared` from the network.
|
||||
|
||||
`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
|
||||
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
|
||||
gin, redis, guac or anything else from the control plane's tree — that is what
|
||||
keeps sitesvc small.
|
||||
|
||||
### What moves
|
||||
|
||||
**`shared/models`** — the three documents written by more than one service:
|
||||
|
||||
| Type | From | Written by |
|
||||
|---|---|---|
|
||||
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
|
||||
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
|
||||
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
|
||||
|
||||
`Settings` moves now rather than later because spec 3's admin service reads it,
|
||||
and moving it later would mean a second round of import churn across both
|
||||
services.
|
||||
|
||||
`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
|
||||
and the control plane does not know the collection exists.
|
||||
|
||||
**`shared/provision`** — the rules, promoted from private helpers to a real API:
|
||||
|
||||
```go
|
||||
const (
|
||||
MinSlugLength = 3
|
||||
MaxSlugLength = 40
|
||||
BcryptCost = 12
|
||||
)
|
||||
|
||||
var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }
|
||||
|
||||
func Slugify(name string) string
|
||||
func BaseSlug(name string) (string, error) // validates length + reserved
|
||||
func NextSlug(base string, attempt int) string
|
||||
|
||||
// CreateOrg resolves a free slug and inserts. The caller supplies the
|
||||
// collection handle so shared does not own a Mongo connection.
|
||||
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)
|
||||
|
||||
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)
|
||||
|
||||
// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
|
||||
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
|
||||
```
|
||||
|
||||
`shared.CreateOrg` becomes the single implementation. The control plane's
|
||||
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
|
||||
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
|
||||
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
|
||||
seed, which is the behaviour it has today.
|
||||
|
||||
Note on the slug loop: it is count-then-insert and therefore racy. It is safe
|
||||
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
|
||||
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
|
||||
not lose that. Document the reliance in a comment at the loop.
|
||||
|
||||
**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
|
||||
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
|
||||
existing index is a no-op. These indexes are a security property, not an
|
||||
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
|
||||
duplicates would break the OIDC cross-org guard), so the shared version is
|
||||
**fatal on failure** for both callers.
|
||||
|
||||
Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
|
||||
`EnsureWorkflowIndexes`) stay in the server and keep their current
|
||||
fatal/warn behaviour.
|
||||
|
||||
### What is deleted
|
||||
|
||||
- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
|
||||
- `sitesvc/internal/provision/` — deleted entirely
|
||||
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
|
||||
`shared/provision`; pending-signup storage stays
|
||||
|
||||
### Docker and CI
|
||||
|
||||
Both Go Dockerfiles currently build with the module directory as context:
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build ... ./cmd
|
||||
```
|
||||
|
||||
A `replace => ../shared` cannot resolve from that context. Build contexts move
|
||||
to the repo root:
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /src
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN cd server && go mod download
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
ARG VERSION=dev
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
```
|
||||
|
||||
The two-stage copy keeps the dependency-download layer cached, which is the
|
||||
reason the current Dockerfiles are written the way they are.
|
||||
|
||||
`.gitea/workflows/server-deploy.yml` must set `context: .` and
|
||||
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
|
||||
`web` and `site` image builds are unaffected.
|
||||
|
||||
`agent-release.yml` is untouched. The agent is not in the workspace, has no
|
||||
`replace`, and cross-compiles exactly as it does today.
|
||||
|
||||
### Error handling
|
||||
|
||||
No new error paths. `shared/provision` returns the same error strings the two
|
||||
callers produce today so that API responses do not change. The one place to be
|
||||
careful is wording: sitesvc says "organisation" and the control plane says
|
||||
"organization". `shared` standardises on **"organisation"**; the control plane's
|
||||
two error strings change spelling. This is user-visible in API error text and is
|
||||
called out here so it is a decision rather than an accident.
|
||||
|
||||
## Testing
|
||||
|
||||
**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite
|
||||
and one is not being started here. Verification is by compiler, `grep`, and
|
||||
running both services end to end.
|
||||
|
||||
That places the whole weight on three manual checks, which the implementation
|
||||
plan makes mandatory steps rather than suggestions:
|
||||
|
||||
1. **bson tag diff** — `diff` the `bson:"…"` tags of each moved struct against
|
||||
the originals. A changed tag orphans production data silently, and this is
|
||||
the only thing that catches it.
|
||||
2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`,
|
||||
`BaseSlug` and `NextSlug` output for a fixed input table, compared against
|
||||
expected output recorded in the plan.
|
||||
3. **End-to-end agreement** — sign up through sitesvc against a scratch
|
||||
database, open the verification link, then log into the control plane with
|
||||
those credentials. This is the check that proves the two services still agree
|
||||
about the documents they share. If it passes, the refactor worked.
|
||||
|
||||
Plus `grep` assertions that exactly one definition of `Slugify` and
|
||||
`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries
|
||||
a `bson:"org_id"` tag.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
Evidence required, not assertions:
|
||||
|
||||
1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
|
||||
2. `go vet ./...` clean in all three.
|
||||
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
|
||||
both succeed from the repo root.
|
||||
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
|
||||
`shared` imports — no local struct redefinitions.
|
||||
5. End-to-end against a scratch database: sitesvc signup form → verification link
|
||||
→ org and owner created → that owner logs into the control plane
|
||||
successfully. This is the test that proves the two services still agree.
|
||||
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Single release. `server` and `sitesvc` images must be deployed together — a skew
|
||||
is harmless here (documents are unchanged) but there is no reason to split it.
|
||||
|
||||
No database migration. No downtime.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
|
||||
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
|
||||
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
|
||||
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
|
||||
|
||||
## Follow-on
|
||||
|
||||
Spec 0b (`instance-rename`) becomes a rename inside one module plus its
|
||||
consumers, rather than a rename across three independent copies. That is the
|
||||
whole reason this spec goes first.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Vantage Licensing Programme — Spec Index
|
||||
|
||||
Seven specs, designed 2026-07-24. Build in this order.
|
||||
|
||||
| # | Spec | Plan | Status |
|
||||
|---|---|---|---|
|
||||
| 0a | [shared-module](2026-07-24-shared-module-design.md) | [plan](../plans/2026-07-24-shared-module.md) | **shipped** |
|
||||
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
|
||||
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** |
|
||||
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |
|
||||
|
||||
Specs 1 and 2 together give working licensing with licences cut by hand with
|
||||
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
|
||||
|
||||
4 and 5 can run in parallel once 3 lands.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
Account (admin only)
|
||||
├── Instance 1 cloud vantage.hostxtra.co.uk/<slug> licence auto-injected
|
||||
├── Instance 2 cloud licence auto-injected
|
||||
└── Instance 3 self-hosted customer's own deployment licence pasted by hand
|
||||
```
|
||||
|
||||
The control plane knows only **Instance**. Accounts exist solely in the admin
|
||||
service, because a self-hosted instance has no row in the cloud database at all.
|
||||
|
||||
## Decisions that everything else follows from
|
||||
|
||||
**Licences are offline-verified signed blobs.** ECDSA P-384 with SHA-256 via
|
||||
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
|
||||
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
|
||||
depends on the licensing service being up. It costs revocation: a licence is
|
||||
valid until it expires whatever Paddle later says. Self Hosted is annual-only to
|
||||
bound that window.
|
||||
|
||||
**Every licence is bound to one instance UUID.** Self-hosted customers link their
|
||||
UUID before the licence is signed, so there is no unbound licence and no claim
|
||||
protocol.
|
||||
|
||||
**Expiry degrades, it does not break.** Monitors keep executing, alerts keep
|
||||
firing, agents keep their keys, in-flight workflow runs finish. Mutations stop.
|
||||
Deletes and OS-update application stay open so a customer is never trapped
|
||||
over-limit or unpatched.
|
||||
|
||||
**Tiers are data, not code.** The server reads `Limits` and `Features` and never
|
||||
branches on tier name. Tier contents live in the admin `plans` table and are
|
||||
snapshotted into each issued licence, so editing a plan never rewrites history —
|
||||
the same rule as `workflow_runs.steps_snapshot`.
|
||||
|
||||
| | Free | Professional | Self Hosted |
|
||||
|---|---|---|---|
|
||||
| deployment | cloud only | cloud | self-hosted |
|
||||
| max servers | 3 | unlimited | unlimited |
|
||||
| max secret groups | 1 | unlimited | unlimited |
|
||||
| max channels | 1 | unlimited | unlimited |
|
||||
| console | no | yes | yes |
|
||||
| OIDC | no | yes | yes |
|
||||
| term | monthly, £0 | monthly or annual | annual only |
|
||||
|
||||
Free is cloud-only by construction: it is only ever signed with
|
||||
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
|
||||
no server-side flag to edit. One Free instance per account.
|
||||
|
||||
**Existing cloud tenants** are grandfathered to Professional, one year out, by
|
||||
migration `0005`.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
|
||||
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
|
||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
Binary file not shown.
@@ -0,0 +1,138 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -8,6 +8,10 @@ service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
@@ -54,6 +58,8 @@ message AgentMessage {
|
||||
oneof payload {
|
||||
AgentReady ready = 3;
|
||||
CommandResult result = 4;
|
||||
StepResult step_result = 5;
|
||||
StepOutputChunk step_output = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,15 +71,114 @@ message CommandResult {
|
||||
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 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 InventoryReport {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
bool include_static = 3;
|
||||
CPUReport cpu = 4;
|
||||
MemReport memory = 5;
|
||||
uint64 swap_total = 6;
|
||||
uint64 swap_used = 7;
|
||||
repeated PartitionReport partitions = 8;
|
||||
string kernel = 9;
|
||||
}
|
||||
|
||||
message InventoryReportResponse {}
|
||||
|
||||
message MonitorSpec {
|
||||
string monitor_id = 1;
|
||||
string type = 2;
|
||||
string url = 3;
|
||||
string host = 4;
|
||||
int32 port = 5;
|
||||
string method = 6;
|
||||
int32 expected_status = 7;
|
||||
string keyword = 8;
|
||||
int32 tls_warn_days = 9;
|
||||
int32 interval_sec = 10;
|
||||
int32 retries = 11;
|
||||
bool insecure = 12;
|
||||
}
|
||||
|
||||
message SyncMonitorsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
}
|
||||
|
||||
message SyncMonitorsResponse {
|
||||
repeated MonitorSpec monitors = 1;
|
||||
}
|
||||
|
||||
message CheckResult {
|
||||
string monitor_id = 1;
|
||||
bool up = 2;
|
||||
int32 latency_ms = 3;
|
||||
string message = 4;
|
||||
int64 cert_expiry_unix = 5;
|
||||
}
|
||||
|
||||
message ReportChecksRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated CheckResult results = 3;
|
||||
}
|
||||
|
||||
message ReportChecksResponse {}
|
||||
|
||||
message ApplyUpdatesCmd {}
|
||||
|
||||
message ServerCommand {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
message CleanupWorkspaceCmd {
|
||||
string workspace_id = 1;
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
@@ -90,3 +195,26 @@ message GenerateKeyCmd {
|
||||
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;
|
||||
string workspace_id = 5; // per-run working dir the agent creates & uses as cwd
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+12
-7
@@ -1,17 +1,22 @@
|
||||
# Build stage
|
||||
#
|
||||
# Context is the repository root, not server/, because server depends on the
|
||||
# shared module through a replace directive.
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
|
||||
# Download dependencies first (layer cache)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
# Manifests first so the dependency layer caches independently of source edits.
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN cd server && go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
+72
-9
@@ -11,6 +11,7 @@ import (
|
||||
"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/monitorsched"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
@@ -18,41 +19,103 @@ func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
dbName := getEnv("MONGO_DB", "vantage")
|
||||
|
||||
if os.Getenv("GRPC_HOST") == "" {
|
||||
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
|
||||
}
|
||||
|
||||
if err := db.Connect(mongoURI, dbName); err != nil {
|
||||
log.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
log.Println("connected to MongoDB")
|
||||
|
||||
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
|
||||
// so they must run before 0004 renames everything underneath them.
|
||||
if err := services.RunMigrations(); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
|
||||
// 0002 into its ambiguous multi-org branch.
|
||||
if err := services.MigrateSettingsOrg(); err != nil {
|
||||
log.Fatalf("settings org migration failed: %v", err)
|
||||
}
|
||||
if err := services.MigrateMissedOrgScopes(); err != nil {
|
||||
log.Fatalf("missed org scope migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 0004 renames orgs to instances. It must run BEFORE the index builders:
|
||||
// EnsureAuthIndexes creates instances.slug, which would create an empty
|
||||
// instances collection and make 0004 refuse to rename onto it.
|
||||
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
log.Fatalf("instance rename migration failed: %v", migErr)
|
||||
}
|
||||
|
||||
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
|
||||
assertCancel()
|
||||
if assertErr != nil {
|
||||
log.Fatalf("scoped collection check failed: %v", assertErr)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuthIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSecretIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure secret indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSettingsIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure settings indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureWorkflowIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
|
||||
log.Printf("warning: failed to list instances for default step seeding: %v", err)
|
||||
} else {
|
||||
for _, instanceID := range instanceIDs {
|
||||
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
services.StartLogSweeper()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start gRPC server
|
||||
go func() {
|
||||
if err := grpcserver.StartGRPC(9090); err != nil {
|
||||
log.Fatalf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start REST server
|
||||
r := gin.Default()
|
||||
monitorsched.Start(context.Background())
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
r.Use(corsMiddleware())
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
|
||||
+18
-10
@@ -1,17 +1,21 @@
|
||||
module github.com/mrhid6/vantage/server
|
||||
|
||||
go 1.26
|
||||
go 1.26.4
|
||||
|
||||
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
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
)
|
||||
|
||||
require github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
@@ -25,29 +29,33 @@ require (
|
||||
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/compress v1.17.6 // 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/mrhid6/vantage/shared v0.0.0
|
||||
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/scram v1.2.0 // 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
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
|
||||
+32
-19
@@ -35,21 +35,26 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
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/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
|
||||
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/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -65,26 +70,33 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
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=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
@@ -92,8 +104,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
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.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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=
|
||||
@@ -101,36 +113,37 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.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=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/channels", listChannels)
|
||||
g.POST("/channels", createChannel)
|
||||
g.PUT("/channels/:id", updateChannel)
|
||||
g.DELETE("/channels/:id", deleteChannel)
|
||||
g.POST("/channels/:id/test", testChannel)
|
||||
}
|
||||
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
|
||||
func createChannel(c *gin.Context) {
|
||||
var ch models.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ch.Name == "" || ch.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func updateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Config *map[string]string `json:"config"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Config != nil {
|
||||
upd["config"] = *body.Config
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateChannel(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
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(auth.InstanceID(c), body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := services.CreateConsoleSession(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "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",
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
instanceID := auth.InstanceID(c)
|
||||
sess, err := services.GetConsoleSession(instanceID, sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if actor := actorFromCtx(c); actor != sess.User {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var privKey, passphrase string
|
||||
if sess.Protocol == "ssh" && sess.KeyID != "" {
|
||||
privKey, err = services.GetPrivateKey(auth.InstanceID(c), 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(instanceID, 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"
|
||||
}
|
||||
|
||||
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(instanceID, sessionID)
|
||||
}
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
+175
-42
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
@@ -11,20 +12,39 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func actorFromCtx(c *gin.Context) string {
|
||||
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
|
||||
return sess.Email
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/install", handleInstallScript)
|
||||
r.GET("/install.ps1", handleInstallScriptWindows)
|
||||
r.GET("/update", handleUpdateScript)
|
||||
r.GET("/update.ps1", handleUpdateScriptWindows)
|
||||
|
||||
// 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("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
|
||||
|
||||
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
|
||||
r.POST("/auth/bootstrap", auth.HandleBootstrap)
|
||||
r.POST("/auth/login", auth.HandleLocalLogin)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/me", auth.HandleMe)
|
||||
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
|
||||
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
|
||||
|
||||
// API endpoints protected by session middleware
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Deny by default: every non-GET route under /api is gated unless it is on
|
||||
// the exemption list in licence.go. A route added later is covered because
|
||||
// of where it is mounted, not because someone remembered.
|
||||
apiGroup.Use(RequireActiveLicense())
|
||||
{
|
||||
apiGroup.GET("/license", getLicence)
|
||||
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
|
||||
|
||||
apiGroup.GET("/servers", listServers)
|
||||
apiGroup.POST("/servers", createServer)
|
||||
apiGroup.GET("/servers/new", newServer)
|
||||
@@ -33,9 +53,28 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
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)
|
||||
|
||||
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
|
||||
|
||||
apiGroup.GET("/audit", listAuditEvents)
|
||||
|
||||
settings := apiGroup.Group("/settings")
|
||||
settings.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
settings.GET("", getSettings)
|
||||
settings.PUT("", saveSettings)
|
||||
settings.POST("/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)
|
||||
@@ -43,11 +82,29 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.DELETE("/keys/:id", deleteKey)
|
||||
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||
|
||||
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
|
||||
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
|
||||
|
||||
registerWorkflowRoutes(apiGroup)
|
||||
registerMonitorRoutes(apiGroup)
|
||||
registerChannelRoutes(apiGroup)
|
||||
|
||||
instance := apiGroup.Group("/instance")
|
||||
instance.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
instance.GET("/users", listInstanceUsers)
|
||||
instance.POST("/users", createInstanceUser)
|
||||
instance.PUT("/users/:id/role", updateInstanceUserRole)
|
||||
instance.DELETE("/users/:id", deleteInstanceUser)
|
||||
instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
|
||||
instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listServers(c *gin.Context) {
|
||||
servers, err := services.ListServers()
|
||||
servers, err := services.ListServers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -56,8 +113,11 @@ func listServers(c *gin.Context) {
|
||||
}
|
||||
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer()
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -69,44 +129,51 @@ func createServer(c *gin.Context) {
|
||||
}
|
||||
|
||||
func newServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer()
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
|
||||
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
host := os.Getenv("PUBLIC_HOST")
|
||||
if host == "" {
|
||||
host = "https://vantage.example.com"
|
||||
}
|
||||
|
||||
host := publicHostFromRequest(c)
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`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,
|
||||
})
|
||||
}
|
||||
|
||||
func getServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(id)
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
|
||||
|
||||
// Build response matching ServerWithKeys shape expected by frontend
|
||||
type serverResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
@@ -119,10 +186,16 @@ func getServer(c *gin.Context) {
|
||||
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := services.DeleteServer(id); err != nil {
|
||||
s, _ := services.GetServer(auth.InstanceID(c), id)
|
||||
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
hostname := id
|
||||
if s != nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -141,7 +214,7 @@ func generateKey(c *gin.Context) {
|
||||
body.Label = "generated"
|
||||
}
|
||||
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -159,6 +232,7 @@ func generateKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "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,
|
||||
@@ -167,7 +241,7 @@ func generateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
func listKeys(c *gin.Context) {
|
||||
keys, err := services.ListKeys()
|
||||
keys, err := services.ListKeys(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -180,23 +254,25 @@ func createKey(c *gin.Context) {
|
||||
Label string `json:"label" binding:"required"`
|
||||
PublicKey string `json:"public_key" binding:"required"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
|
||||
key, err := services.CreateKey(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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)
|
||||
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -206,13 +282,13 @@ func getPrivateKey(c *gin.Context) {
|
||||
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(id)
|
||||
key, err := services.GetKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(id)
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
@@ -226,10 +302,16 @@ func getKey(c *gin.Context) {
|
||||
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := services.DeleteKey(id); err != nil {
|
||||
k, _ := services.GetKey(auth.InstanceID(c), id)
|
||||
if err := services.DeleteKey(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
label := id
|
||||
if k != nil {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -243,11 +325,12 @@ func assignKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := services.AssignKey(keyID, body.ServerID)
|
||||
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
|
||||
c.JSON(http.StatusCreated, a)
|
||||
}
|
||||
|
||||
@@ -255,10 +338,11 @@ func revokeAssignment(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
serverID := c.Param("serverId")
|
||||
|
||||
if err := services.RevokeAssignment(keyID, serverID); err != nil {
|
||||
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
|
||||
@@ -273,7 +357,7 @@ func getLatestAgentVersion(c *gin.Context) {
|
||||
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -284,12 +368,29 @@ func updateAgent(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), "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 == "" {
|
||||
@@ -318,7 +419,7 @@ if [ -z "$LATEST" ]; then
|
||||
fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
LATEST_ENCODED="${LATEST/\
|
||||
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"
|
||||
|
||||
@@ -346,6 +447,48 @@ echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
func listAuditEvents(c *gin.Context) {
|
||||
limit := int64(100)
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
events, err := services.ListAuditEvents(auth.InstanceID(c), 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(auth.InstanceID(c))
|
||||
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"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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")
|
||||
@@ -354,14 +497,7 @@ func handleInstallScript(c *gin.Context) {
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
publicHost := os.Getenv("PUBLIC_HOST")
|
||||
if publicHost == "" {
|
||||
publicHost = "vantage.example.com"
|
||||
}
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
if grpcHost == "" {
|
||||
grpcHost = publicHost
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -369,9 +505,6 @@ set -euo pipefail
|
||||
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://}"
|
||||
@@ -393,7 +526,7 @@ if [ -z "$LATEST" ]; then
|
||||
fi
|
||||
|
||||
VERSION="${LATEST#agent/}"
|
||||
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||
LATEST_ENCODED="${LATEST/\
|
||||
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"
|
||||
|
||||
@@ -444,7 +577,7 @@ systemctl daemon-reload
|
||||
systemctl enable --now vantage-agent
|
||||
|
||||
echo "vantage-agent installed and started."
|
||||
`, serverID, token, giteaHost, publicHost, grpcHost)
|
||||
`, serverID, token, giteaHost, grpcHost)
|
||||
|
||||
c.Header("Content-Type", "text/x-shellscript")
|
||||
c.String(http.StatusOK, script)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func actorMayGrantOwner(c *gin.Context) bool {
|
||||
return auth.Role(c) == models.RoleOwner
|
||||
}
|
||||
|
||||
func createInstanceUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email required"})
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = models.RoleMember
|
||||
}
|
||||
if !models.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
if body.Role == models.RoleOwner && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"})
|
||||
return
|
||||
}
|
||||
u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
func updateInstanceUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role required"})
|
||||
return
|
||||
}
|
||||
if !models.ValidRole(body.Role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if (body.Role == models.RoleOwner || target.Role == models.RoleOwner) && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func deleteInstanceUser(c *gin.Context) {
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if target.Role == models.RoleOwner && !actorMayGrantOwner(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DeleteUser(instanceID, targetID); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func orgUserErrStatus(err error) int {
|
||||
if errors.Is(err, services.ErrLastOwner) {
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func getInstanceOIDC(c *gin.Context) {
|
||||
cfg, err := services.GetInstanceOIDC(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": cfg.InstanceID,
|
||||
"issuer": cfg.Issuer,
|
||||
"client_id": cfg.ClientID,
|
||||
"enabled": cfg.Enabled,
|
||||
"updated_at": cfg.UpdatedAt,
|
||||
"client_secret_set": cfg.ClientSecretEnc != "",
|
||||
})
|
||||
}
|
||||
|
||||
func putInstanceOIDC(c *gin.Context) {
|
||||
var body struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveInstanceOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
auth.EvictOIDCProvider(auth.InstanceID(c))
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
)
|
||||
|
||||
// licenceExemptPaths are routes that must work while a licence is expired or
|
||||
// missing, because they are how a customer recovers or stays safe.
|
||||
//
|
||||
// /api/license pasting a valid licence is the way out of degraded mode
|
||||
// apply-updates security patching is never paywalled
|
||||
//
|
||||
// All DELETE requests are exempt separately (see RequireActiveLicense): a
|
||||
// customer downgraded below their current usage must be able to delete their
|
||||
// way back under the cap.
|
||||
var licenceExemptPaths = map[string]bool{
|
||||
"/api/license": true,
|
||||
}
|
||||
|
||||
// mutatingGETs are routes that change state despite their method. GET is
|
||||
// otherwise always allowed through, so these have to be named explicitly:
|
||||
// GET /api/servers/new mints a pre-registration token, which is a creation.
|
||||
var mutatingGETs = map[string]bool{
|
||||
"/api/servers/new": true,
|
||||
}
|
||||
|
||||
func licenceExempt(c *gin.Context) bool {
|
||||
if c.Request.Method == http.MethodDelete {
|
||||
return true
|
||||
}
|
||||
if licenceExemptPaths[c.FullPath()] {
|
||||
return true
|
||||
}
|
||||
if c.FullPath() == "/api/servers/:id/apply-updates" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireActiveLicense blocks mutating requests when the licence is not valid.
|
||||
//
|
||||
// Mounted on the /api group, so a route added tomorrow is gated because of where
|
||||
// it lives rather than because someone remembered. GET and HEAD always pass —
|
||||
// reading is never blocked.
|
||||
func RequireActiveLicense() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
|
||||
!mutatingGETs[c.FullPath()] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if licenceExempt(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
st := services.GetLicenseState(auth.InstanceID(c))
|
||||
if st.Active() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "license_required",
|
||||
"state": st.Status,
|
||||
"reason": st.Reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireFeature blocks a route when the licence does not grant a feature.
|
||||
func RequireFeature(name string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
st := services.GetLicenseState(auth.InstanceID(c))
|
||||
if st.Feature(name) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "feature_unavailable",
|
||||
"feature": name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type licenceResponse struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
State license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
DaysRemaining *int `json:"days_remaining,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
Usage licenceUsageResponse `json:"usage"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type licenceUsageResponse struct {
|
||||
Servers int `json:"servers"`
|
||||
SecretGroups int `json:"secret_groups"`
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
func getLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
st := services.GetLicenseState(instanceID)
|
||||
servers, groups, channels := services.LicenseUsage(instanceID)
|
||||
|
||||
resp := licenceResponse{
|
||||
InstanceID: instanceID,
|
||||
State: st.Status,
|
||||
Reason: st.Reason,
|
||||
Tier: st.Tier,
|
||||
ExpiresAt: st.ExpiresAt,
|
||||
Limits: st.Limits,
|
||||
Features: st.Features,
|
||||
Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
|
||||
Source: st.Source,
|
||||
}
|
||||
if st.ExpiresAt != nil {
|
||||
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
|
||||
resp.DaysRemaining = &d
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
var (
|
||||
licencePostMu sync.Mutex
|
||||
licencePostCounts = map[string][]time.Time{}
|
||||
)
|
||||
|
||||
const licencePostLimit = 10
|
||||
|
||||
// licencePostAllowed permits 10 attempts per instance per hour.
|
||||
func licencePostAllowed(instanceID string) bool {
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
|
||||
licencePostMu.Lock()
|
||||
defer licencePostMu.Unlock()
|
||||
|
||||
kept := licencePostCounts[instanceID][:0]
|
||||
for _, t := range licencePostCounts[instanceID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= licencePostLimit {
|
||||
licencePostCounts[instanceID] = kept
|
||||
return false
|
||||
}
|
||||
licencePostCounts[instanceID] = append(kept, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
func postLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
if !licencePostAllowed(instanceID) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many licence attempts. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Blob string `json:"blob"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
|
||||
return
|
||||
}
|
||||
|
||||
st, err := services.StoreLicense(instanceID, body.Blob)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": licenceRejectionMessage(err.Error(), instanceID),
|
||||
"reason": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
|
||||
"licence accepted (tier "+st.Tier+")")
|
||||
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
|
||||
}
|
||||
|
||||
// licenceRejectionMessage turns a machine reason into something a person can act
|
||||
// on. The instance ID is included in the mismatch case because that is the one
|
||||
// piece of information the customer needs and cannot guess.
|
||||
func licenceRejectionMessage(reason, instanceID string) string {
|
||||
switch reason {
|
||||
case license.ReasonBadSignature:
|
||||
return "This licence key is not valid. Check it was copied in full."
|
||||
case license.ReasonDeploymentMismatch:
|
||||
return "This licence is for Vantage Cloud and cannot be used on a self-hosted install."
|
||||
case license.ReasonInstanceMismatch:
|
||||
return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
|
||||
case license.ReasonNoLicense:
|
||||
return "No licence key was provided."
|
||||
default:
|
||||
return "This licence could not be accepted."
|
||||
}
|
||||
}
|
||||
|
||||
// limitStatus maps a LimitError to a 403 body. Handlers that create countable
|
||||
// resources call this so the UI gets a machine-readable limit name.
|
||||
func limitStatus(c *gin.Context, err error) bool {
|
||||
var le *services.LimitError
|
||||
if !errors.As(err, &le) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "limit_exceeded",
|
||||
"limit": le.Limit,
|
||||
"current": le.Current,
|
||||
"max": le.Max,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors", listMonitors)
|
||||
g.POST("/monitors", createMonitor)
|
||||
g.GET("/monitors/:id", getMonitor)
|
||||
g.PUT("/monitors/:id", updateMonitor)
|
||||
g.DELETE("/monitors/:id", deleteMonitor)
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
}
|
||||
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
func createMonitor(c *gin.Context) {
|
||||
var m models.Monitor
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m.Name == "" || m.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Target *models.MonitorTarget `json:"target"`
|
||||
IntervalSec *int `json:"interval_sec"`
|
||||
Runner *string `json:"runner"`
|
||||
Retries *int `json:"retries"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ChannelIDs *[]string `json:"channel_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Target != nil {
|
||||
upd["target"] = *body.Target
|
||||
}
|
||||
if body.IntervalSec != nil {
|
||||
upd["interval_sec"] = *body.IntervalSec
|
||||
}
|
||||
if body.Runner != nil {
|
||||
upd["runner"] = *body.Runner
|
||||
}
|
||||
if body.Retries != nil {
|
||||
upd["retries"] = *body.Retries
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.ChannelIDs != nil {
|
||||
upd["channel_ids"] = *body.ChannelIDs
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
incidents, err := services.ListIncidents(auth.InstanceID(c), c.Param("id"), 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-30 * 24 * time.Hour)
|
||||
rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rollups)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func publicHostFromRequest(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if h := firstForwarded(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
return "https://vantage.example.com"
|
||||
}
|
||||
return schemeFor(c, host) + "://" + host
|
||||
}
|
||||
|
||||
func schemeFor(c *gin.Context, host string) string {
|
||||
if p := firstForwarded(c.GetHeader("X-Forwarded-Proto")); p != "" {
|
||||
return p
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if isLoopback(host) {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
func firstForwarded(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(v, ",")[0])
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
h = host
|
||||
}
|
||||
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func validName(s string) bool {
|
||||
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
|
||||
}
|
||||
|
||||
const ctxSecretsInstanceKey = "km_secrets_instance"
|
||||
|
||||
func secretsReadAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
const prefix = "Bearer "
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if len(authHeader) <= len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSecretsInstanceKey, instanceID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
|
||||
instanceID := c.GetString(ctxSecretsInstanceKey)
|
||||
if instanceID == "" {
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
values, err := services.GetSecretGroupDecrypted(instanceID, 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(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
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(auth.InstanceID(c), body.Group, body.Values); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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})
|
||||
}
|
||||
|
||||
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(auth.InstanceID(c), group, values); err != nil {
|
||||
if limitStatus(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group, body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group, key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"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("/steps/:id/export", exportStep)
|
||||
g.POST("/steps/import", importStep)
|
||||
g.POST("/steps/seed-defaults", seedDefaults)
|
||||
g.GET("/steps/usage", stepUsage)
|
||||
g.POST("/steps/parse", parseStep)
|
||||
|
||||
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)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
|
||||
}
|
||||
|
||||
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() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, _ := f.Read(buf)
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
instanceID := auth.InstanceID(c)
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(instanceID, runID, serverID) {
|
||||
sendNew()
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serverRunTerminal(instanceID, runID, serverID string) bool {
|
||||
r, err := services.GetRun(instanceID, 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
|
||||
}
|
||||
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
|
||||
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(auth.InstanceID(c), s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id"), s); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id")))
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.ImportStepToLibrary(auth.InstanceID(c), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func parseStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s, err := services.ParseStepDoc(body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows(auth.InstanceID(c))
|
||||
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(auth.InstanceID(c), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
type cachedInstance struct {
|
||||
instance *models.Instance
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
instanceCacheMu sync.Mutex
|
||||
instanceCache = map[string]cachedInstance{}
|
||||
)
|
||||
|
||||
const instanceCacheTTL = 60 * time.Second
|
||||
|
||||
func appRootLabel() string {
|
||||
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
return "vantage"
|
||||
}
|
||||
|
||||
func hostSlug(host string) string {
|
||||
host = strings.ToLower(host)
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
root := appRootLabel()
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
if parts[1] != root {
|
||||
return ""
|
||||
}
|
||||
if parts[0] == root || parts[0] == "www" {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
slug := hostSlug(c.Request.Host)
|
||||
if slug == "" {
|
||||
return nil, false
|
||||
}
|
||||
instanceCacheMu.Lock()
|
||||
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
|
||||
instanceCacheMu.Unlock()
|
||||
return e.instance, e.instance != nil
|
||||
}
|
||||
instanceCacheMu.Unlock()
|
||||
|
||||
inst, err := services.GetInstanceBySlug(slug)
|
||||
if err != nil || inst == nil {
|
||||
|
||||
return nil, false
|
||||
}
|
||||
instanceCacheMu.Lock()
|
||||
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
|
||||
instanceCacheMu.Unlock()
|
||||
return inst, true
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
func HandleLocalLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
|
||||
return
|
||||
}
|
||||
u, err := services.GetUserByEmail(body.Email)
|
||||
if err != nil || !services.VerifyPassword(u, body.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchLastLogin(u.UserID)
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func HandleBootstrapStatus(c *gin.Context) {
|
||||
var (
|
||||
n int64
|
||||
err error
|
||||
instName string
|
||||
)
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
n, err = services.CountInstanceUsers(inst.InstanceID)
|
||||
instName = inst.Name
|
||||
} else {
|
||||
n, err = services.CountUsers()
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName})
|
||||
}
|
||||
|
||||
func HandleBootstrap(c *gin.Context) {
|
||||
n, err := services.CountUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "setup already complete"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_name, email, and password (>=8 chars) required"})
|
||||
return
|
||||
}
|
||||
|
||||
instanceCount, err := services.CountInstances()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var inst *models.Instance
|
||||
switch instanceCount {
|
||||
case 0:
|
||||
inst, err = services.CreateInstance(body.InstanceName)
|
||||
case 1:
|
||||
var existing *models.Instance
|
||||
existing, err = services.FirstInstance()
|
||||
if err == nil {
|
||||
inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName)
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
|
||||
"cannot bootstrap: %d organizations already exist but no users do; "+
|
||||
"create the owner against the intended inst rather than through setup, "+
|
||||
"or remove the unintended orgs and retry", instanceCount)})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"instance": inst,
|
||||
"slug": inst.Slug,
|
||||
"instance_id": inst.InstanceID,
|
||||
})
|
||||
}
|
||||
|
||||
func HandleMe(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
inst, _ := services.GetInstance(sess.InstanceID)
|
||||
c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst})
|
||||
}
|
||||
@@ -14,13 +14,42 @@ func GetSessionFromContext(c *gin.Context) *Session {
|
||||
return sess
|
||||
}
|
||||
|
||||
func InstanceID(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.InstanceID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Role(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.Role
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func UserID(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.UserID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
r := Role(c)
|
||||
for _, want := range roles {
|
||||
if r == want {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
@@ -33,7 +62,18 @@ func Middleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if sess.InstanceID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxSessionKey, sess)
|
||||
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,123 +2,154 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
oidcProvider *oidc.Provider
|
||||
oauth2Cfg *oauth2.Config
|
||||
authEnabled bool
|
||||
provMu sync.Mutex
|
||||
provCache = map[string]*oidc.Provider{}
|
||||
)
|
||||
|
||||
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 EvictOIDCProvider(instanceID string) {
|
||||
provMu.Lock()
|
||||
delete(provCache, instanceID)
|
||||
provMu.Unlock()
|
||||
}
|
||||
|
||||
func Enabled() bool { return authEnabled }
|
||||
func redirectURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
func HandleLogin(c *gin.Context) {
|
||||
state, err := randomHex(16)
|
||||
func providerForInstance(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
|
||||
cfg, err := services.GetInstanceOIDC(instanceID)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil, nil, fmt.Errorf("inst SSO not configured")
|
||||
}
|
||||
secret, err := services.GetInstanceOIDCSecret(instanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
|
||||
return nil, nil, err
|
||||
}
|
||||
provMu.Lock()
|
||||
p := provCache[instanceID]
|
||||
provMu.Unlock()
|
||||
if p == nil {
|
||||
p, err = oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
provMu.Lock()
|
||||
provCache[instanceID] = p
|
||||
provMu.Unlock()
|
||||
}
|
||||
return p, &oauth2.Config{
|
||||
ClientID: cfg.ClientID, ClientSecret: secret,
|
||||
RedirectURL: redirectURL(c), Endpoint: p.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func HandleOIDCStart(c *gin.Context) {
|
||||
inst, ok := InstanceFromHost(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown instance host"})
|
||||
return
|
||||
}
|
||||
if err := SaveState(c.Request.Context(), state); err != nil {
|
||||
// Losing the feature stops new SSO logins. It deliberately does not touch
|
||||
// session validation, so nobody is evicted mid-session.
|
||||
if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
|
||||
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
_, oauthCfg, err := providerForInstance(ctx, c, inst.InstanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
state, err := randomHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
|
||||
return
|
||||
}
|
||||
if err := SaveStateInstance(ctx, state, inst.InstanceID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
|
||||
c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
|
||||
}
|
||||
|
||||
func HandleCallback(c *gin.Context) {
|
||||
func HandleOIDCCallback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !ConsumeState(ctx, c.Query("state")) {
|
||||
instanceID, ok := ConsumeStateInstance(ctx, c.Query("state"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
|
||||
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, err := oauthCfg.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)
|
||||
idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).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 {
|
||||
if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(claims.Email)
|
||||
u, err := services.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
|
||||
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
|
||||
return
|
||||
}
|
||||
} else if u.InstanceID != instanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := SaveSession(ctx, &Session{
|
||||
UserID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.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)
|
||||
_ = services.TouchLastLogin(u.UserID)
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func HandleLogout(c *gin.Context) {
|
||||
@@ -134,21 +165,3 @@ func HandleLogout(c *gin.Context) {
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ const sessionPrefix = "km:session:"
|
||||
const statePrefix = "km:state:"
|
||||
|
||||
type Session struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"user_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
@@ -69,11 +71,14 @@ 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 SaveStateInstance(ctx context.Context, state, instanceID string) error {
|
||||
return rdb.Set(ctx, statePrefix+state, instanceID, 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
|
||||
func ConsumeStateInstance(ctx context.Context, state string) (string, bool) {
|
||||
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
|
||||
if err != nil || instanceID == "" {
|
||||
return "", false
|
||||
}
|
||||
return instanceID, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
Method string
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
Message string
|
||||
CertExpiry *time.Time
|
||||
}
|
||||
|
||||
func (s Spec) timeout() time.Duration {
|
||||
t := s.TimeoutSec
|
||||
if t <= 0 || t > 10 {
|
||||
t = 10
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
return runHTTP(ctx, s)
|
||||
case TypeTCP:
|
||||
return runTCP(ctx, s)
|
||||
case TypeICMP:
|
||||
return runICMP(ctx, s)
|
||||
case TypeTLS:
|
||||
return runTLS(ctx, s)
|
||||
default:
|
||||
return Result{Message: "unknown check type: " + s.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func runHTTP(ctx context.Context, s Spec) Result {
|
||||
method := s.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
expect := s.ExpectedStatus
|
||||
if expect == 0 {
|
||||
expect = 200
|
||||
}
|
||||
client := &http.Client{Timeout: s.timeout()}
|
||||
if s.Insecure {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res := Result{LatencyMs: msSince(start), Up: true}
|
||||
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
exp := resp.TLS.PeerCertificates[0].NotAfter
|
||||
res.CertExpiry = &exp
|
||||
}
|
||||
if resp.StatusCode != expect {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
|
||||
}
|
||||
if s.Keyword != "" {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if !strings.Contains(string(body), s.Keyword) {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func runTCP(ctx context.Context, s Spec) Result {
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
conn.Close()
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
|
||||
func runTLS(ctx context.Context, s Spec) Result {
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
|
||||
}
|
||||
exp := certs[0].NotAfter
|
||||
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
|
||||
warn := s.TLSWarnDays
|
||||
if warn <= 0 {
|
||||
warn = 14
|
||||
}
|
||||
remaining := time.Until(exp)
|
||||
if remaining <= 0 {
|
||||
res.Message = "certificate expired"
|
||||
return res
|
||||
}
|
||||
if remaining <= time.Duration(warn)*24*time.Hour {
|
||||
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
|
||||
return res
|
||||
}
|
||||
res.Up = true
|
||||
return res
|
||||
}
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return Result{Message: "icmp socket: " + err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := os.Getpid() & 0xffff
|
||||
pkt := icmpEcho(id, 1)
|
||||
deadline := time.Now().Add(s.timeout())
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(pkt, dst); err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
reply := make([]byte, 1500)
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(reply)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 {
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
b[3] = byte(cs)
|
||||
return b
|
||||
}
|
||||
|
||||
func icmpChecksum(b []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i < len(b)-1; i += 2 {
|
||||
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
||||
}
|
||||
if len(b)%2 == 1 {
|
||||
sum += uint32(b[len(b)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// JSONCodec is a gRPC codec that uses JSON encoding.
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
@@ -16,5 +15,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto" // override default proto codec name so gRPC uses it
|
||||
return "proto"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Hand-written gRPC bindings for vantage.proto using JSON codec.
|
||||
// To use: register the JSON codec before creating gRPC servers/clients.
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
@@ -11,8 +8,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Message types
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
@@ -47,13 +42,99 @@ 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 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{}
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse 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"`
|
||||
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"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -74,10 +155,12 @@ type GenerateKeyCmd struct {
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
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{}
|
||||
@@ -88,7 +171,29 @@ type CommandResult struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CommandStream server-side interface
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
WorkspaceId string `json:"workspace_id,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"`
|
||||
}
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
@@ -112,8 +217,6 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
@@ -136,12 +239,14 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
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)
|
||||
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
|
||||
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
}
|
||||
|
||||
@@ -159,16 +264,34 @@ func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKey
|
||||
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) ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportInventory not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SyncMonitors not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportChecks 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)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -204,6 +327,38 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
||||
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) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", 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 {
|
||||
@@ -212,8 +367,6 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
// Server registration
|
||||
|
||||
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
|
||||
s.RegisterService(&Vantage_ServiceDesc, srv)
|
||||
}
|
||||
@@ -225,6 +378,10 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
{MethodName: "Register", Handler: _Vantage_Register_Handler},
|
||||
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
|
||||
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
|
||||
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
|
||||
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
|
||||
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
|
||||
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
@@ -282,6 +439,66 @@ func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, d
|
||||
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_ReportInventory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(InventoryReport)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportInventory(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportInventory"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportInventory(ctx, req.(*InventoryReport))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_SyncMonitors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SyncMonitorsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).SyncMonitors(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncMonitors"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).SyncMonitors(ctx, req.(*SyncMonitorsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_ReportChecks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportChecksRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportChecks(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportChecks"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportChecks(ctx, req.(*ReportChecksRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -22,6 +26,16 @@ type vantageServer struct {
|
||||
pb.UnimplementedVantageServer
|
||||
}
|
||||
|
||||
// Register carries no licence check, deliberately.
|
||||
//
|
||||
// A server row only ever comes from CreateServer, which checks the cap before
|
||||
// issuing a pre-registration token. By the time an agent calls Register its row
|
||||
// already exists, so counting here would count the caller itself: an instance
|
||||
// sitting exactly at its cap would reject the very agent it just authorised, and
|
||||
// every re-registration after a reinstall would fail too.
|
||||
//
|
||||
// The cap is enforced where rows are created, which is the only place it can be
|
||||
// enforced correctly.
|
||||
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 {
|
||||
@@ -40,6 +54,10 @@ func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.
|
||||
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)
|
||||
@@ -54,21 +72,98 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
|
||||
key, err := services.CreateKey(srv.InstanceID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
|
||||
}
|
||||
|
||||
// Auto-assign to the generating server
|
||||
if _, err := services.AssignKey(key.KeyID, srv.ServerID); err != nil {
|
||||
if _, err := services.AssignKey(srv.InstanceID, key.KeyID, srv.ServerID); err != nil {
|
||||
log.Printf("failed to auto-assign generated key: %v", err)
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
|
||||
func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRequest) (*pb.SyncMonitorsResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
monitors, err := services.ListMonitorsForRunner(srv.InstanceID, srv.ServerID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list monitors")
|
||||
}
|
||||
specs := make([]pb.MonitorSpec, 0, len(monitors))
|
||||
for _, m := range monitors {
|
||||
specs = append(specs, pb.MonitorSpec{
|
||||
MonitorId: m.MonitorID,
|
||||
Type: m.Type,
|
||||
URL: m.Target.URL,
|
||||
Host: m.Target.Host,
|
||||
Port: m.Target.Port,
|
||||
Method: m.Target.Method,
|
||||
ExpectedStatus: m.Target.ExpectedStatus,
|
||||
Keyword: m.Target.Keyword,
|
||||
TLSWarnDays: m.Target.TLSWarnDays,
|
||||
Insecure: m.Target.Insecure,
|
||||
IntervalSec: m.IntervalSec,
|
||||
Retries: m.Retries,
|
||||
})
|
||||
}
|
||||
return &pb.SyncMonitorsResponse{Monitors: specs}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
for _, r := range req.Results {
|
||||
res := checker.Result{Up: r.Up, LatencyMs: r.LatencyMs, Message: r.Message}
|
||||
if r.CertExpiryUnix > 0 {
|
||||
t := time.Unix(r.CertExpiryUnix, 0)
|
||||
res.CertExpiry = &t
|
||||
}
|
||||
if err := services.IngestResult(srv.InstanceID, srv.ServerID, r.MonitorId, res); err != nil {
|
||||
log.Printf("ingest check %s: %v", r.MonitorId, err)
|
||||
}
|
||||
}
|
||||
return &pb.ReportChecksResponse{}, 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)
|
||||
@@ -89,8 +184,6 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
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()
|
||||
@@ -101,6 +194,16 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
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)
|
||||
}
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -126,7 +229,18 @@ func StartGRPC(port int) error {
|
||||
return fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
s := grpc.NewServer()
|
||||
s := grpc.NewServer(
|
||||
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
|
||||
Time: 45 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
)
|
||||
pb.RegisterVantageServer(s, &vantageServer{})
|
||||
|
||||
log.Printf("gRPC server listening on :%d", port)
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
|
||||
type Assignment struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
|
||||
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
|
||||
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type AuditEvent struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_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,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelWebhook = "webhook"
|
||||
ChannelSMTP = "smtp"
|
||||
ChannelDiscord = "discord"
|
||||
ChannelSlack = "slack"
|
||||
ChannelTelegram = "telegram"
|
||||
)
|
||||
|
||||
type NotificationChannel struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
ChannelID string `bson:"channel_id" json:"channel_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Config map[string]string `bson:"config" json:"config"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type ConsoleSession struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
SessionID string `bson:"session_id" json:"session_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Protocol string `bson:"protocol" json:"protocol"`
|
||||
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 *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:"-"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
// Instance is defined in the shared module because sitesvc and the admin
|
||||
// control plane write the same documents.
|
||||
type Instance = shared.Instance
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type InstanceOIDC struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Issuer string `bson:"issuer" json:"issuer"`
|
||||
ClientID string `bson:"client_id" json:"client_id"`
|
||||
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
@@ -8,13 +8,16 @@ import (
|
||||
|
||||
type Key struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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
|
||||
Source string `bson:"source" json:"source"`
|
||||
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,77 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
MonitorHTTP = "http"
|
||||
MonitorTCP = "tcp"
|
||||
MonitorICMP = "icmp"
|
||||
MonitorTLS = "tls"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusUp = "up"
|
||||
StatusDown = "down"
|
||||
StatusPending = "pending"
|
||||
)
|
||||
|
||||
const RunnerServer = "server"
|
||||
|
||||
type MonitorTarget struct {
|
||||
URL string `bson:"url,omitempty" json:"url,omitempty"`
|
||||
Host string `bson:"host,omitempty" json:"host,omitempty"`
|
||||
Port int `bson:"port,omitempty" json:"port,omitempty"`
|
||||
Method string `bson:"method,omitempty" json:"method,omitempty"`
|
||||
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
|
||||
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
|
||||
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"`
|
||||
}
|
||||
|
||||
type MonitorState struct {
|
||||
Status string `bson:"status" json:"status"`
|
||||
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
|
||||
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
|
||||
Message string `bson:"message,omitempty" json:"message,omitempty"`
|
||||
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
|
||||
Fails int `bson:"fails" json:"fails"`
|
||||
LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"`
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
Runner string `bson:"runner" json:"runner"`
|
||||
Retries int `bson:"retries" json:"retries"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
|
||||
State MonitorState `bson:"state" json:"state"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Incident struct {
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
IncidentID string `bson:"incident_id" json:"incident_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
|
||||
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
type Rollup struct {
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"`
|
||||
Checks int `bson:"checks" json:"checks"`
|
||||
UpCount int `bson:"up_count" json:"up_count"`
|
||||
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Secret struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type GroupSummary struct {
|
||||
Group string `json:"group"`
|
||||
KeyCount int `json:"key_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -6,17 +6,62 @@ 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"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
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 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"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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"`
|
||||
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
type (
|
||||
Settings = shared.Settings
|
||||
AlertSettings = shared.AlertSettings
|
||||
EmailSettings = shared.EmailSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
type User = shared.User
|
||||
|
||||
const (
|
||||
RoleOwner = shared.RoleOwner
|
||||
RoleAdmin = shared.RoleAdmin
|
||||
RoleMember = shared.RoleMember
|
||||
)
|
||||
|
||||
func ValidRole(role string) bool { return shared.ValidRole(role) }
|
||||
@@ -0,0 +1,103 @@
|
||||
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:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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"`
|
||||
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"`
|
||||
Source string `bson:"source" json:"source"`
|
||||
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
|
||||
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,omitempty" json:"step_id,omitempty"`
|
||||
Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
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:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Attempts int `bson:"attempts" json:"attempts"`
|
||||
ExitCode int `bson:"exit_code" json:"exit_code"`
|
||||
LogOffset int64 `bson:"log_offset" json:"log_offset"`
|
||||
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"`
|
||||
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:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
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"`
|
||||
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,105 @@
|
||||
package monitorsched
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
const reloadInterval = 30 * time.Second
|
||||
|
||||
type runner struct {
|
||||
monitorID string
|
||||
intervalSec int
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func Start(ctx context.Context) {
|
||||
go loop(ctx)
|
||||
}
|
||||
|
||||
func loop(ctx context.Context) {
|
||||
active := map[string]*runner{}
|
||||
var mu sync.Mutex
|
||||
|
||||
// Monitors run regardless of licence state, deliberately.
|
||||
//
|
||||
// A customer whose card failed must not lose the ability to know their
|
||||
// infrastructure is on fire. Creating and editing monitors is blocked by the
|
||||
// API gate; executing the ones that already exist is not.
|
||||
sync := func() {
|
||||
monitors, err := services.ListServerScheduledMonitors()
|
||||
if err != nil {
|
||||
log.Printf("monitorsched: list monitors: %v", err)
|
||||
return
|
||||
}
|
||||
want := map[string]models.Monitor{}
|
||||
for _, m := range monitors {
|
||||
want[m.MonitorID] = m
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for id, r := range active {
|
||||
m, ok := want[id]
|
||||
if !ok || m.IntervalSec != r.intervalSec {
|
||||
r.cancel()
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
|
||||
for id, m := range want {
|
||||
if _, ok := active[id]; ok {
|
||||
continue
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
active[id] = &runner{monitorID: id, intervalSec: m.IntervalSec, cancel: cancel}
|
||||
go runMonitor(rctx, m)
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
t := time.NewTicker(reloadInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runMonitor(ctx context.Context, m models.Monitor) {
|
||||
interval := time.Duration(m.IntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
spec := services.SpecFor(&m)
|
||||
|
||||
run := func() {
|
||||
res := checker.Run(ctx, spec)
|
||||
if err := services.IngestServerScheduledResult(m.MonitorID, res); err != nil {
|
||||
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
MonitorName string
|
||||
Type string
|
||||
OldStatus string
|
||||
NewStatus string
|
||||
Message string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func (e Event) title() string {
|
||||
verb := "recovered"
|
||||
if e.NewStatus == models.StatusDown {
|
||||
verb = "is DOWN"
|
||||
}
|
||||
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
|
||||
if e.Message != "" {
|
||||
s += ": " + e.Message
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Dispatch(ch models.NotificationChannel, ev Event) error {
|
||||
switch ch.Type {
|
||||
case models.ChannelWebhook:
|
||||
return dispatchWebhook(ch, ev)
|
||||
case models.ChannelDiscord:
|
||||
return dispatchDiscord(ch, ev)
|
||||
case models.ChannelSlack:
|
||||
return dispatchSlack(ch, ev)
|
||||
case models.ChannelTelegram:
|
||||
return dispatchTelegram(ch, ev)
|
||||
case models.ChannelSMTP:
|
||||
return dispatchSMTP(ch, ev)
|
||||
default:
|
||||
return fmt.Errorf("unknown channel type: %s", ch.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func Test(ch models.NotificationChannel) error {
|
||||
return Dispatch(ch, Event{
|
||||
MonitorName: "Test monitor",
|
||||
Type: "http",
|
||||
OldStatus: models.StatusUp,
|
||||
NewStatus: models.StatusDown,
|
||||
Message: "this is a test alert from Vantage",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func postJSON(target string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(target, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("webhook: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]any{
|
||||
"monitor": ev.MonitorName,
|
||||
"type": ev.Type,
|
||||
"old_status": ev.OldStatus,
|
||||
"new_status": ev.NewStatus,
|
||||
"message": ev.Message,
|
||||
"time": ev.Time.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("discord: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]string{"content": ev.title()})
|
||||
}
|
||||
|
||||
func dispatchSlack(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("slack: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]string{"text": ev.title()})
|
||||
}
|
||||
|
||||
func dispatchTelegram(ch models.NotificationChannel, ev Event) error {
|
||||
token := ch.Config["token"]
|
||||
chatID := ch.Config["chat_id"]
|
||||
if token == "" || chatID == "" {
|
||||
return fmt.Errorf("telegram: missing token or chat_id")
|
||||
}
|
||||
api := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
||||
return postJSON(api, map[string]string{"chat_id": chatID, "text": ev.title()})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
const smtpTimeout = 15 * time.Second
|
||||
|
||||
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
host := ch.Config["host"]
|
||||
port := ch.Config["port"]
|
||||
from := ch.Config["from"]
|
||||
to := ch.Config["to"]
|
||||
if host == "" || port == "" || from == "" || to == "" {
|
||||
return fmt.Errorf("smtp: missing host/port/from/to")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: dial %s: %w", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
|
||||
|
||||
if port == "465" {
|
||||
conn = tls.Client(conn, &tls.Config{ServerName: host})
|
||||
}
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp: client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if port != "465" {
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return fmt.Errorf("smtp: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user := ch.Config["username"]; user != "" {
|
||||
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
|
||||
return fmt.Errorf("smtp: auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
recipients := strings.Split(to, ",")
|
||||
for i := range recipients {
|
||||
recipients[i] = strings.TrimSpace(recipients[i])
|
||||
}
|
||||
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("smtp: mail from: %w", err)
|
||||
}
|
||||
for _, rcpt := range recipients {
|
||||
if rcpt == "" {
|
||||
continue
|
||||
}
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := buildMIME(from, to, ev.title(), textEmail(ev), htmlEmail(ev))
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: build message: %w", err)
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("smtp: write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp: close data: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
colBg = "#0f1117"
|
||||
colSurface = "#1a1d27"
|
||||
colSurface2 = "#232635"
|
||||
colBorder = "#2e3147"
|
||||
colText = "#e8eaf0"
|
||||
colTextMuted = "#9095a8"
|
||||
colAccent = "#6366f1"
|
||||
colSuccess = "#22c55e"
|
||||
colDanger = "#ef4444"
|
||||
)
|
||||
|
||||
func statusColor(status string) string {
|
||||
switch status {
|
||||
case models.StatusUp:
|
||||
return colSuccess
|
||||
case models.StatusDown:
|
||||
return colDanger
|
||||
default:
|
||||
return colTextMuted
|
||||
}
|
||||
}
|
||||
|
||||
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
|
||||
var buf strings.Builder
|
||||
w := multipart.NewWriter(&buf)
|
||||
|
||||
var head strings.Builder
|
||||
head.WriteString("From: " + from + "\r\n")
|
||||
head.WriteString("To: " + to + "\r\n")
|
||||
head.WriteString("Subject: " + subject + "\r\n")
|
||||
head.WriteString("MIME-Version: 1.0\r\n")
|
||||
head.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", w.Boundary()))
|
||||
|
||||
textPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
textPart.Write([]byte(text))
|
||||
|
||||
htmlPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
htmlPart.Write([]byte(htmlBody))
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(head.String() + buf.String()), nil
|
||||
}
|
||||
|
||||
func htmlEmail(ev Event) string {
|
||||
accent := statusColor(ev.NewStatus)
|
||||
label := "Recovered"
|
||||
if ev.NewStatus == models.StatusDown {
|
||||
label = "Down"
|
||||
}
|
||||
|
||||
esc := html.EscapeString
|
||||
row := func(k, v string) string {
|
||||
if v == "" {
|
||||
v = ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<tr>`+
|
||||
`<td style="padding:8px 0;color:%s;font-size:13px;width:120px;">%s</td>`+
|
||||
`<td style="padding:8px 0;color:%s;font-size:13px;font-weight:500;">%s</td>`+
|
||||
`</tr>`,
|
||||
colTextMuted, k, colText, esc(v))
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ev.Message != "" {
|
||||
message = fmt.Sprintf(
|
||||
`<p style="margin:0 0 20px;padding:12px 14px;background:%s;border:1px solid %s;border-radius:8px;color:%s;font-size:13px;">%s</p>`,
|
||||
colSurface2, colBorder, colText, esc(ev.Message))
|
||||
}
|
||||
|
||||
transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus)
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:%s;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:%s;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:%s;border:1px solid %s;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<tr><td style="height:4px;background:%s;"></td></tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:18px;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:700;color:%s;">Vantage</td>
|
||||
</tr>
|
||||
</table>
|
||||
<span style="display:inline-block;padding:4px 12px;border-radius:9999px;background:%s22;color:%s;font-size:12px;font-weight:600;letter-spacing:.02em;">%s</span>
|
||||
<h1 style="margin:14px 0 6px;font-size:20px;font-weight:700;color:%s;">%s</h1>
|
||||
<p style="margin:0 0 20px;color:%s;font-size:13px;">%s check</p>
|
||||
%s
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="border-top:1px solid %s;">
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 28px;border-top:1px solid %s;">
|
||||
<p style="margin:0;color:%s;font-size:12px;">Sent by Vantage · self-hosted service monitoring</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
colBg,
|
||||
colBg,
|
||||
colSurface, colBorder,
|
||||
accent,
|
||||
colText,
|
||||
accent, accent, label,
|
||||
colText, esc(ev.MonitorName),
|
||||
colTextMuted, esc(ev.Type),
|
||||
message,
|
||||
colBorder,
|
||||
row("Status", transition),
|
||||
row("Type", ev.Type),
|
||||
row("Time", ev.Time.Format("2006-01-02 15:04:05 MST")),
|
||||
colBorder,
|
||||
colTextMuted,
|
||||
)
|
||||
}
|
||||
|
||||
func textEmail(ev Event) string {
|
||||
return strings.Join([]string{
|
||||
ev.title(),
|
||||
"",
|
||||
"Monitor: " + ev.MonitorName,
|
||||
"Type: " + ev.Type,
|
||||
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
|
||||
"Message: " + ev.Message,
|
||||
"Time: " + ev.Time.Format("2006-01-02 15:04:05 MST"),
|
||||
"",
|
||||
"Sent by Vantage · self-hosted service monitoring",
|
||||
}, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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(instanceID, eventType, actor, serverID, keyID, details string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
event := models.AuditEvent{
|
||||
InstanceID: instanceID,
|
||||
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(instanceID string, 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{"instance_id": instanceID}, 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,115 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/notify"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func ListChannels(instanceID string) ([]models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.NotificationChannel
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetChannel(instanceID, channelID string) (*models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var ch models.NotificationChannel
|
||||
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}).Decode(&ch)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
func GetChannels(instanceID string, channelIDs []string) ([]models.NotificationChannel, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID, "channel_id": bson.M{"$in": channelIDs}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.NotificationChannel
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateChannelIDs(instanceID string, channelIDs []string) error {
|
||||
for _, id := range channelIDs {
|
||||
ch, err := GetChannel(instanceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ch == nil {
|
||||
return errors.New("channel " + id + " not found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
|
||||
if err := CheckChannelLimit(instanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
ch.InstanceID = instanceID
|
||||
ch.ChannelID = uuid.NewString()
|
||||
ch.CreatedAt = time.Now()
|
||||
if ch.Config == nil {
|
||||
ch.Config = map[string]string{}
|
||||
}
|
||||
if _, err := db.Col("notification_channels").InsertOne(ctx, ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteChannel(instanceID, channelID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestChannel(instanceID, channelID string) error {
|
||||
ch, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ch == nil {
|
||||
return errors.New("channel not found")
|
||||
}
|
||||
return notify.Test(*ch)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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) {
|
||||
|
||||
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) }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(instanceID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s := &models.ConsoleSession{
|
||||
InstanceID: instanceID,
|
||||
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(instanceID, 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, "instance_id": instanceID}).Decode(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func StashConsoleRDPCreds(instanceID, 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, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ConsumeConsoleRDPCreds(instanceID, sessionID string) (username, password string, err error) {
|
||||
s, err := GetConsoleSession(instanceID, 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, "instance_id": instanceID},
|
||||
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
|
||||
)
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func SetConsoleSSHUser(instanceID, 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, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"ssh_username": username}})
|
||||
return err
|
||||
}
|
||||
|
||||
func ConsumeSessionToken(instanceID, 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, "instance_id": instanceID, "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(instanceID, 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, "instance_id": instanceID, "ended_at": nil},
|
||||
bson.M{"$set": bson.M{"ended_at": now}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/indexes"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureAuthIndexes declares the indexes tenant isolation depends on.
|
||||
//
|
||||
// It lives here rather than in migrate.go so that the org-to-instance rename
|
||||
// could touch it without touching migrations 0001 to 0003, which deliberately
|
||||
// still speak the pre-rename shape.
|
||||
//
|
||||
// It MUST run after MigrateOrgToInstance. Creating the instances.slug index
|
||||
// first would create an empty instances collection, and migration 0004 refuses
|
||||
// to rename orgs when instances already exists.
|
||||
func EnsureAuthIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// users.email and instances.slug are declared in the shared module so the
|
||||
// control plane and sitesvc cannot disagree about them.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// instance_oidc is control-plane only, so its index stays here.
|
||||
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func encryptionKey() ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptPrivateKey(plaintext string) (string, error) {
|
||||
func encryptString(plaintext string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -43,7 +43,7 @@ func encryptPrivateKey(plaintext string) (string, error) {
|
||||
return hex.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func decryptPrivateKey(ciphertextHex string) (string, error) {
|
||||
func decryptString(ciphertextHex string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -70,3 +70,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
|
||||
|
||||
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func DefaultStepsDir() string {
|
||||
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "default-steps")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.WorkflowStep{}
|
||||
for _, path := range matches {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s, err := ParseStepDoc(b)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.Source = "default"
|
||||
s.Slug = provision.Slugify(s.Name)
|
||||
if s.Slug == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SeedDefaultSteps(instanceID string) (created, updated int, err error) {
|
||||
steps, err := readDefaultStepFiles()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
col := db.Col("workflow_steps")
|
||||
for _, s := range steps {
|
||||
filter := bson.M{"instance_id": instanceID, "slug": s.Slug, "source": "default"}
|
||||
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(),
|
||||
}
|
||||
res, uerr := col.UpdateOne(ctx, filter, bson.M{
|
||||
"$set": set,
|
||||
"$setOnInsert": bson.M{
|
||||
"instance_id": instanceID,
|
||||
"step_id": uuid.New().String(),
|
||||
"slug": s.Slug,
|
||||
"source": "default",
|
||||
"created_at": time.Now(),
|
||||
},
|
||||
}, options.UpdateOne().SetUpsert(true))
|
||||
if uerr != nil {
|
||||
return created, updated, uerr
|
||||
}
|
||||
if res.UpsertedCount > 0 {
|
||||
created++
|
||||
} else if res.ModifiedCount > 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return created, updated, nil
|
||||
}
|
||||
@@ -17,13 +17,10 @@ type commandDispatcher struct {
|
||||
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()
|
||||
@@ -32,14 +29,12 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||
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]
|
||||
@@ -62,7 +57,20 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
|
||||
}
|
||||
}
|
||||
|
||||
// KeyGenParams carries all options for a generate-key command.
|
||||
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
}
|
||||
|
||||
func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
}
|
||||
_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
|
||||
})
|
||||
}
|
||||
|
||||
type KeyGenParams struct {
|
||||
Label string
|
||||
KeyType string
|
||||
@@ -71,15 +79,13 @@ type KeyGenParams struct {
|
||||
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
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch releases: %w", err)
|
||||
}
|
||||
@@ -103,8 +109,6 @@ func GetLatestAgentVersion() (string, error) {
|
||||
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")
|
||||
@@ -134,8 +138,17 @@ func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
func DispatchDeleteKey(serverID, label string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
@@ -145,13 +158,11 @@ func DispatchDeleteKey(serverID, label 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")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 GetInstanceOIDC(instanceID string) (*models.InstanceOIDC, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.InstanceOIDC
|
||||
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func GetInstanceOIDCSecret(instanceID string) (string, error) {
|
||||
o, err := GetInstanceOIDC(instanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptString(o.ClientSecretEnc)
|
||||
}
|
||||
|
||||
func SaveInstanceOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
set := bson.M{
|
||||
"instance_id": instanceID, "issuer": issuer, "client_id": clientID,
|
||||
"enabled": enabled, "updated_at": time.Now(),
|
||||
}
|
||||
if clientSecret != "" {
|
||||
enc, err := encryptString(clientSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
set["client_secret_enc"] = enc
|
||||
}
|
||||
_, err := db.Col("instance_oidc").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID}, bson.M{"$set": set},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func GetInstance(instanceID string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
err := db.Col("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func GetInstanceBySlug(slug string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func ListInstanceIDs() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cursor, err := db.Col("instances").Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
var orgs []models.Instance
|
||||
if err := cursor.All(ctx, &orgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
ids = append(ids, o.InstanceID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func CountInstances() (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("instances").CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
|
||||
func FirstInstance() (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
if err := db.Col("instances").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func AdoptInstance(instanceID, name string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
set := bson.M{"name": name}
|
||||
|
||||
slug := provision.Slugify(name)
|
||||
if len(slug) > provision.MaxSlugLength {
|
||||
slug = slug[:provision.MaxSlugLength]
|
||||
}
|
||||
if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] {
|
||||
n, err := db.Col("instances").CountDocuments(ctx, bson.M{"slug": slug, "instance_id": bson.M{"$ne": instanceID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
set["slug"] = slug
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Col("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("instance slug already taken")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return GetInstance(instanceID)
|
||||
}
|
||||
|
||||
// CreateInstance creates an organisation and seeds its default workflow steps.
|
||||
//
|
||||
// The creation rules live in shared/provision because sitesvc creates
|
||||
// organisations too. Seeding stays here: shared must not know about workflow
|
||||
// steps.
|
||||
func CreateInstance(name string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
o, err := provision.CreateInstance(ctx, db.Database, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if created, updated, err := SeedDefaultSteps(o.InstanceID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for new org %s: %v", o.InstanceID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.InstanceID, created, updated)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -33,10 +33,12 @@ func computeFingerprint(pubKey string) string {
|
||||
|
||||
func setKeyMeta(k *models.Key) {
|
||||
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
|
||||
k.HasPassphrase = k.PassphraseEncrypted != ""
|
||||
}
|
||||
|
||||
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
|
||||
func CreateKey(instanceID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
|
||||
key := &models.Key{
|
||||
InstanceID: instanceID,
|
||||
KeyID: uuid.NewString(),
|
||||
Label: label,
|
||||
PublicKey: publicKey,
|
||||
@@ -52,6 +54,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
|
||||
}
|
||||
key.PrivateKeyEncrypted = enc
|
||||
}
|
||||
if passphrase != "" {
|
||||
enc, err := encryptString(passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt passphrase: %w", err)
|
||||
}
|
||||
key.PassphraseEncrypted = enc
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -63,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func GetKey(keyID string) (*models.Key, error) {
|
||||
func GetKey(instanceID, keyID string) (*models.Key, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var key models.Key
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key)
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) {
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func GetPrivateKey(keyID string) (string, error) {
|
||||
func GetPrivateKey(instanceID, 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 {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key.PrivateKeyEncrypted == "" {
|
||||
@@ -90,16 +99,30 @@ func GetPrivateKey(keyID string) (string, error) {
|
||||
return decryptPrivateKey(key.PrivateKeyEncrypted)
|
||||
}
|
||||
|
||||
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) {
|
||||
func ListKeys(instanceID string) ([]KeyWithCount, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("keys").Find(ctx, bson.M{})
|
||||
cursor, err := db.Col("keys").Find(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,27 +137,28 @@ func ListKeys() ([]KeyWithCount, error) {
|
||||
for _, k := range keys {
|
||||
setKeyMeta(&k)
|
||||
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
||||
"key_id": k.KeyID,
|
||||
"revoked_at": nil,
|
||||
"instance_id": instanceID,
|
||||
"key_id": k.KeyID,
|
||||
"revoked_at": nil,
|
||||
})
|
||||
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteKey(keyID string) error {
|
||||
func DeleteKey(instanceID, keyID 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 {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
|
||||
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
|
||||
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -144,22 +168,30 @@ func DeleteKey(keyID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
|
||||
func AssignKey(instanceID, keyID, serverID string) (*models.Assignment, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check if already assigned and active
|
||||
if _, err := GetKey(instanceID, keyID); err != nil {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
if _, err := GetServer(instanceID, serverID); err != nil {
|
||||
return nil, fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
var existing models.Assignment
|
||||
err := db.Col("assignments").FindOne(ctx, bson.M{
|
||||
"key_id": keyID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
"instance_id": instanceID,
|
||||
"key_id": keyID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
}).Decode(&existing)
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
a := &models.Assignment{
|
||||
InstanceID: instanceID,
|
||||
KeyID: keyID,
|
||||
ServerID: serverID,
|
||||
AssignedAt: time.Now(),
|
||||
@@ -171,23 +203,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func RevokeAssignment(keyID, serverID string) error {
|
||||
func RevokeAssignment(instanceID, keyID, serverID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
_, err := db.Col("assignments").UpdateOne(ctx,
|
||||
bson.M{"key_id": keyID, "server_id": serverID, "revoked_at": nil},
|
||||
bson.M{"instance_id": instanceID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
|
||||
bson.M{"$set": bson.M{"revoked_at": now}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetAssignmentsForKey(keyID string) ([]models.Assignment, error) {
|
||||
func GetAssignmentsForKey(instanceID, keyID string) ([]models.Assignment, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID, "revoked_at": nil})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID, "revoked_at": nil})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,11 +237,11 @@ type AssignmentWithServer struct {
|
||||
Server *models.Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
|
||||
func GetAssignmentsWithServers(instanceID, keyID string) ([]AssignmentWithServer, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,7 +256,7 @@ func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
|
||||
for _, a := range assignments {
|
||||
item := AssignmentWithServer{Assignment: a}
|
||||
var srv models.Server
|
||||
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID}).Decode(&srv); err == nil {
|
||||
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "instance_id": instanceID}).Decode(&srv); err == nil {
|
||||
item.Server = &srv
|
||||
}
|
||||
result = append(result, item)
|
||||
@@ -237,11 +269,11 @@ type AssignmentWithKey struct {
|
||||
Key *models.Key `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) {
|
||||
func GetAssignmentsWithKeysForServer(instanceID, serverID string) ([]AssignmentWithKey, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"server_id": serverID})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "server_id": serverID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -255,7 +287,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
|
||||
result := make([]AssignmentWithKey, 0, len(assignments))
|
||||
for _, a := range assignments {
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
continue
|
||||
}
|
||||
setKeyMeta(&key)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// LicenseState is the resolved licence for one instance.
|
||||
type LicenseState struct {
|
||||
Status license.State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Limits license.Limits `json:"limits"`
|
||||
Features map[string]bool `json:"features"`
|
||||
// Source is "stored", "env" or "none" — useful when a self-hosted operator
|
||||
// asks why the licence they pasted is not the one in effect.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// Active reports whether mutations are allowed.
|
||||
func (s LicenseState) Active() bool { return s.Status == license.StateValid }
|
||||
|
||||
// Feature reports whether a named feature is granted.
|
||||
func (s LicenseState) Feature(name string) bool { return s.Features[name] }
|
||||
|
||||
// DeploymentMode is how this install describes itself to the verifier.
|
||||
//
|
||||
// It defaults to self_hosted, the stricter mode. An operator who removes the
|
||||
// variable gets the tighter behaviour, not the looser one.
|
||||
func DeploymentMode() string {
|
||||
if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud {
|
||||
return license.DeploymentCloud
|
||||
}
|
||||
return license.DeploymentSelfHosted
|
||||
}
|
||||
|
||||
type cachedLicense struct {
|
||||
state LicenseState
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
licenseCacheMu sync.Mutex
|
||||
licenseCache = map[string]cachedLicense{}
|
||||
)
|
||||
|
||||
const licenseCacheTTL = 60 * time.Second
|
||||
|
||||
// InvalidateLicenseCache drops the cached state for one instance, so a pasted
|
||||
// licence takes effect immediately rather than within the TTL.
|
||||
func InvalidateLicenseCache(instanceID string) {
|
||||
licenseCacheMu.Lock()
|
||||
delete(licenseCache, instanceID)
|
||||
licenseCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// GetLicenseState resolves the licence for an instance, cached for 60 seconds.
|
||||
//
|
||||
// Resolution order:
|
||||
//
|
||||
// 1. the blob stored on the instance document
|
||||
// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an
|
||||
// automated self-hosted deployment can ship a licence without a human
|
||||
// pasting one
|
||||
// 3. neither -> invalid / no_license
|
||||
//
|
||||
// A blob stored through the UI always wins afterwards, so an operator is never
|
||||
// locked out by a stale environment value.
|
||||
func GetLicenseState(instanceID string) LicenseState {
|
||||
licenseCacheMu.Lock()
|
||||
if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL {
|
||||
licenseCacheMu.Unlock()
|
||||
return e.state
|
||||
}
|
||||
licenseCacheMu.Unlock()
|
||||
|
||||
state := resolveLicenseState(instanceID)
|
||||
|
||||
licenseCacheMu.Lock()
|
||||
licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()}
|
||||
licenseCacheMu.Unlock()
|
||||
return state
|
||||
}
|
||||
|
||||
func resolveLicenseState(instanceID string) LicenseState {
|
||||
inst, err := GetInstance(instanceID)
|
||||
if err != nil {
|
||||
return LicenseState{
|
||||
Status: license.StateInvalid,
|
||||
Reason: license.ReasonNoLicense,
|
||||
Features: map[string]bool{},
|
||||
Source: "none",
|
||||
}
|
||||
}
|
||||
|
||||
blob, source := inst.LicenseBlob, "stored"
|
||||
if blob == "" {
|
||||
blob, source = os.Getenv("VANTAGE_LICENSE"), "env"
|
||||
}
|
||||
if blob == "" {
|
||||
return LicenseState{
|
||||
Status: license.StateInvalid,
|
||||
Reason: license.ReasonNoLicense,
|
||||
Features: map[string]bool{},
|
||||
Source: "none",
|
||||
}
|
||||
}
|
||||
|
||||
res := license.Verify(blob, license.VerifyOpts{
|
||||
InstanceID: instanceID,
|
||||
Deployment: DeploymentMode(),
|
||||
})
|
||||
return stateFromResult(res, source)
|
||||
}
|
||||
|
||||
func stateFromResult(res license.Result, source string) LicenseState {
|
||||
feats := map[string]bool{}
|
||||
for _, f := range res.License.Features {
|
||||
feats[f] = true
|
||||
}
|
||||
s := LicenseState{
|
||||
Status: res.State,
|
||||
Reason: res.Reason,
|
||||
Tier: res.License.Tier,
|
||||
Limits: res.License.Limits,
|
||||
Features: feats,
|
||||
Source: source,
|
||||
}
|
||||
if !res.License.ExpiresAt.IsZero() {
|
||||
exp := res.License.ExpiresAt
|
||||
s.ExpiresAt = &exp
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StoreLicense verifies a blob against this instance and stores it.
|
||||
//
|
||||
// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired
|
||||
// and when. An invalid blob is rejected and the previous one kept.
|
||||
func StoreLicense(instanceID, blob string) (LicenseState, error) {
|
||||
blob = strings.TrimSpace(blob)
|
||||
|
||||
res := license.Verify(blob, license.VerifyOpts{
|
||||
InstanceID: instanceID,
|
||||
Deployment: DeploymentMode(),
|
||||
})
|
||||
if res.State == license.StateInvalid {
|
||||
return LicenseState{}, fmt.Errorf("%s", res.Reason)
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"license_blob": blob,
|
||||
"license_tier": res.License.Tier,
|
||||
"license_expiry": res.License.ExpiresAt,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Col("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
InvalidateLicenseCache(instanceID)
|
||||
return stateFromResult(res, "stored"), nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// LimitError is returned when a licence cap would be exceeded. The API maps it
|
||||
// to 403 with a machine-readable body.
|
||||
type LimitError struct {
|
||||
Limit string
|
||||
Current int
|
||||
Max int
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max)
|
||||
}
|
||||
|
||||
func limitCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// CheckServerLimit refuses a new server when the instance is at its cap.
|
||||
//
|
||||
// Counts live rows only. An instance already over its cap keeps every server it
|
||||
// has — nothing is truncated — it simply cannot add another.
|
||||
func CheckServerLimit(instanceID string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !license.WithinLimit(int(n), st.Limits.MaxServers) {
|
||||
return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that
|
||||
// already exists is always allowed, so a capped customer can still rotate the
|
||||
// secrets they have.
|
||||
func CheckSecretGroupLimit(instanceID, group string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
existing, err := db.Col("secrets").CountDocuments(ctx,
|
||||
bson.M{"instance_id": instanceID, "group": group})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if err := db.Col("secrets").Distinct(ctx, "group",
|
||||
bson.M{"instance_id": instanceID}).Decode(&groups); err != nil {
|
||||
return err
|
||||
}
|
||||
if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) {
|
||||
return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckChannelLimit refuses a new notification channel at the cap.
|
||||
func CheckChannelLimit(instanceID string) error {
|
||||
st := GetLicenseState(instanceID)
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !license.WithinLimit(int(n), st.Limits.MaxChannels) {
|
||||
return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
|
||||
// honestly when an instance is over its cap rather than pretending.
|
||||
func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
|
||||
ctx, cancel := limitCtx()
|
||||
defer cancel()
|
||||
|
||||
if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
|
||||
servers = int(n)
|
||||
}
|
||||
var groups []string
|
||||
if err := db.Col("secrets").Distinct(ctx, "group",
|
||||
bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
|
||||
secretGroups = len(groups)
|
||||
}
|
||||
if n, err := db.Col("notification_channels").CountDocuments(ctx,
|
||||
bson.M{"instance_id": instanceID}); err == nil {
|
||||
channels = int(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// legacyOrg is the pre-0004 shape of the orgs collection.
|
||||
//
|
||||
// Migrations 0001 to 0003 run BEFORE the org-to-instance rename and must keep
|
||||
// reading and writing org_id in the orgs collection. They deliberately do not
|
||||
// use shared/models, which has moved on to Instance and instance_id.
|
||||
type legacyOrg struct {
|
||||
OrgID string `bson:"org_id"`
|
||||
Name string `bson:"name"`
|
||||
Slug string `bson:"slug"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
|
||||
var backfillCollections = []string{
|
||||
"servers", "keys", "assignments", "secrets",
|
||||
"workflows", "workflow_steps", "workflow_runs",
|
||||
"audit_logs", "monitors", "notification_channels",
|
||||
"console_sessions", "incidents", "monitor_rollups",
|
||||
}
|
||||
|
||||
func defaultBackfillOrg(ctx context.Context) (*legacyOrg, error) {
|
||||
var org legacyOrg
|
||||
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, mongo.ErrNoDocuments):
|
||||
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
return &org, nil
|
||||
}
|
||||
|
||||
func RunMigrations() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const marker = "0001_default_org_backfill"
|
||||
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
needs := false
|
||||
for _, col := range backfillCollections {
|
||||
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
|
||||
if n > 0 {
|
||||
needs = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if needs {
|
||||
org, err := defaultBackfillOrg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, col := range backfillCollections {
|
||||
if _, err := db.Col(col).UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"org_id": org.OrgID}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
|
||||
return err
|
||||
}
|
||||
|
||||
func MigrateMissedOrgScopes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const marker = "0003_missed_org_scopes"
|
||||
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missed := []string{"audit_logs", "notification_channels"}
|
||||
needs := false
|
||||
for _, col := range missed {
|
||||
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
|
||||
if n > 0 {
|
||||
needs = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needs {
|
||||
org, err := defaultBackfillOrg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, col := range missed {
|
||||
if _, err := db.Col(col).UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"org_id": org.OrgID}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := backfillOrgFromOwner(ctx, "console_sessions", "server_id", "servers", "server_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOrgFromOwner(ctx, "incidents", "monitor_id", "monitors", "monitor_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOrgFromOwner(ctx, "monitor_rollups", "monitor_id", "monitors", "monitor_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
|
||||
return err
|
||||
}
|
||||
|
||||
func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error {
|
||||
|
||||
var raw []bson.RawValue
|
||||
if err := db.Col(col).Distinct(ctx, localField,
|
||||
bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rv := range raw {
|
||||
id, ok := rv.StringValueOK()
|
||||
if !ok || id == "" {
|
||||
continue
|
||||
}
|
||||
var owner struct {
|
||||
OrgID string `bson:"org_id"`
|
||||
}
|
||||
if err := db.Col(ownerCol).FindOne(ctx, bson.M{ownerField: id}).Decode(&owner); err != nil {
|
||||
continue
|
||||
}
|
||||
if owner.OrgID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Col(col).UpdateMany(ctx,
|
||||
bson.M{localField: id, "org_id": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"org_id": owner.OrgID}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MigrateSettingsOrg() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const marker = "0002_settings_org_backfill"
|
||||
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
|
||||
if n > 0 {
|
||||
var org legacyOrg
|
||||
orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch orgCount {
|
||||
case 1:
|
||||
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&org); err != nil {
|
||||
return err
|
||||
}
|
||||
case 0:
|
||||
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+
|
||||
"cannot infer the owner. Set org_id manually on each settings document "+
|
||||
"(db.settings.updateOne({_id:<id>},{$set:{org_id:\"<org uuid>\"}})), deleting any "+
|
||||
"duplicates, then restart", n, orgCount)
|
||||
}
|
||||
if org.OrgID != "" {
|
||||
if _, err := db.Col("settings").UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"org_id": org.OrgID}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ScopedCollections lists every collection carrying the tenant key.
|
||||
//
|
||||
// Migration 0004 renames org_id to instance_id in each. A collection missing
|
||||
// from this list keeps the old field name and becomes invisible to every scoped
|
||||
// query — so this list is load-bearing, not documentation.
|
||||
//
|
||||
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
|
||||
// holds an org_id.
|
||||
//
|
||||
// The migrations collection is deliberately absent: it is not tenant-scoped.
|
||||
// The two renamed collections appear under their post-rename names, because the
|
||||
// migration renames the collections before it renames the field.
|
||||
var ScopedCollections = []string{
|
||||
"instances",
|
||||
"servers",
|
||||
"keys",
|
||||
"assignments",
|
||||
"users",
|
||||
"instance_oidc",
|
||||
"settings",
|
||||
"secrets",
|
||||
"workflows",
|
||||
"workflow_steps",
|
||||
"workflow_runs",
|
||||
"monitors",
|
||||
"incidents",
|
||||
"monitor_rollups",
|
||||
"notification_channels",
|
||||
"console_sessions",
|
||||
"audit_logs",
|
||||
}
|
||||
|
||||
// collectionRenames maps the two collections whose names change. Ordered so the
|
||||
// migration is deterministic.
|
||||
var collectionRenames = []struct{ from, to string }{
|
||||
{"orgs", "instances"},
|
||||
{"org_oidc", "instance_oidc"},
|
||||
}
|
||||
|
||||
// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
|
||||
//
|
||||
// It only ever renames documents. It never deletes, drops or unsets one, so a
|
||||
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
|
||||
// rather than by restoring a backup.
|
||||
//
|
||||
// The steps are not atomic across collections — multi-document transactions
|
||||
// would require a replica set, which self-hosted installs do not guarantee.
|
||||
// Instead every step is safely repeatable: a collection rename is skipped when
|
||||
// the source is already gone, and $rename matches nothing on a document that
|
||||
// has already been renamed. A run that fails partway is fixed by running it
|
||||
// again.
|
||||
//
|
||||
// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first
|
||||
// would create an empty instances collection, and step 1 below refuses to
|
||||
// rename orgs onto an existing target.
|
||||
func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
|
||||
names, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
exists := map[string]bool{}
|
||||
for _, n := range names {
|
||||
exists[n] = true
|
||||
}
|
||||
|
||||
// Step 1: rename the collections.
|
||||
for _, r := range collectionRenames {
|
||||
switch {
|
||||
case !exists[r.from]:
|
||||
// Nothing to rename: either already done or never existed.
|
||||
continue
|
||||
case exists[r.to]:
|
||||
return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
|
||||
}
|
||||
cmd := bson.D{
|
||||
{Key: "renameCollection", Value: db.Name() + "." + r.from},
|
||||
{Key: "to", Value: db.Name() + "." + r.to},
|
||||
}
|
||||
if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
|
||||
}
|
||||
log.Printf("0004: renamed collection %s to %s", r.from, r.to)
|
||||
}
|
||||
|
||||
// Step 2: drop indexes keyed on the old field name, BEFORE renaming it.
|
||||
//
|
||||
// Order matters and is not obvious. A unique index on org_id treats a
|
||||
// missing org_id as null, so as soon as $rename strips the field from the
|
||||
// second document the index reports a duplicate null and the whole update
|
||||
// fails. Dropping first avoids that entirely.
|
||||
//
|
||||
// Dropping an index touches no documents. The boot-time index builders
|
||||
// recreate the current ones against the new field name.
|
||||
for _, c := range ScopedCollections {
|
||||
if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: rename the field.
|
||||
for _, c := range ScopedCollections {
|
||||
res, err := db.Collection(c).UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": true}},
|
||||
bson.M{"$rename": bson.M{"org_id": "instance_id"}},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename org_id in %s: %w", c, err)
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: verify before anyone records a marker. Any mismatch aborts, and
|
||||
// the migration is re-run rather than marked done.
|
||||
for _, c := range ScopedCollections {
|
||||
total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count %s: %w", c, err)
|
||||
}
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count stale in %s: %w", c, err)
|
||||
}
|
||||
if stale != 0 {
|
||||
return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
|
||||
}
|
||||
|
||||
scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count scoped in %s: %w", c, err)
|
||||
}
|
||||
if scoped != total {
|
||||
return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexKeyedOn reports whether an index specification's key document mentions
|
||||
// field.
|
||||
//
|
||||
// The key is checked as both bson.D and bson.M because the driver's decoding of
|
||||
// a nested document depends on the target type, and getting this wrong is
|
||||
// silent: the index simply is not found, and the field rename then fails on a
|
||||
// duplicate null.
|
||||
func IndexKeyedOn(key any, field string) bool {
|
||||
switch k := key.(type) {
|
||||
case bson.M:
|
||||
_, ok := k[field]
|
||||
return ok
|
||||
case bson.D:
|
||||
for _, e := range k {
|
||||
if e.Key == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DropIndexesKeyedOn removes every index on coll whose key mentions field,
|
||||
// leaving _id_ alone.
|
||||
//
|
||||
// Both the migration and its rollback must do this BEFORE renaming the field. A
|
||||
// unique index treats a missing field as null, so as soon as $rename strips the
|
||||
// field from the second document the index reports a duplicate null and the
|
||||
// whole update fails. Dropping an index touches no documents; the boot-time
|
||||
// index builders recreate what is needed.
|
||||
func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error {
|
||||
cur, err := db.Collection(coll).Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list indexes on %s: %w", coll, err)
|
||||
}
|
||||
var specs []bson.M
|
||||
if err := cur.All(ctx, &specs); err != nil {
|
||||
return fmt.Errorf("decode indexes on %s: %w", coll, err)
|
||||
}
|
||||
for _, s := range specs {
|
||||
name, _ := s["name"].(string)
|
||||
if name == "_id_" {
|
||||
continue
|
||||
}
|
||||
if !IndexKeyedOn(s["key"], field) {
|
||||
continue
|
||||
}
|
||||
if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil {
|
||||
return fmt.Errorf("drop index %s on %s: %w", name, coll, err)
|
||||
}
|
||||
log.Printf("dropped stale index %s on %s", name, coll)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertNoScopedCollectionMissed reports any collection holding an org_id that
|
||||
// ScopedCollections does not know about. A hit means a collection was added
|
||||
// without being added to the list, and its tenant key was never renamed.
|
||||
func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
|
||||
known := map[string]bool{}
|
||||
for _, c := range ScopedCollections {
|
||||
known[c] = true
|
||||
}
|
||||
|
||||
names, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
|
||||
for _, n := range names {
|
||||
if known[n] {
|
||||
continue
|
||||
}
|
||||
count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count %s: %w", n, err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/notify"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func monCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
func SpecFor(m *models.Monitor) checker.Spec {
|
||||
return checker.Spec{
|
||||
Type: m.Type,
|
||||
URL: m.Target.URL,
|
||||
Host: m.Target.Host,
|
||||
Port: m.Target.Port,
|
||||
Method: m.Target.Method,
|
||||
ExpectedStatus: m.Target.ExpectedStatus,
|
||||
Keyword: m.Target.Keyword,
|
||||
TLSWarnDays: m.Target.TLSWarnDays,
|
||||
Insecure: m.Target.Insecure,
|
||||
TimeoutSec: m.IntervalSec,
|
||||
}
|
||||
}
|
||||
|
||||
func ListMonitors(instanceID string) ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Monitor
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
|
||||
if instanceID == "" {
|
||||
return nil, errors.New("instance id required")
|
||||
}
|
||||
return listMonitorsForRunner(instanceID, runner)
|
||||
}
|
||||
|
||||
func ListServerScheduledMonitors() ([]models.Monitor, error) {
|
||||
return listMonitorsForRunner("", models.RunnerServer)
|
||||
}
|
||||
|
||||
func listMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
filter := bson.M{"runner": runner, "enabled": true}
|
||||
if instanceID != "" {
|
||||
filter["instance_id"] = instanceID
|
||||
}
|
||||
cur, err := db.Col("monitors").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Monitor
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetMonitor(instanceID, monitorID string) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var m models.Monitor
|
||||
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}).Decode(&m)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func getMonitorByID(monitorID string) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var m models.Monitor
|
||||
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID}).Decode(&m)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func validateRunner(instanceID, runner string) error {
|
||||
if runner == "" || runner == models.RunnerServer {
|
||||
return nil
|
||||
}
|
||||
if _, err := GetServer(instanceID, runner); err != nil {
|
||||
return fmt.Errorf("runner server %s not found", runner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRunner(instanceID, m.Runner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.InstanceID = instanceID
|
||||
m.MonitorID = uuid.NewString()
|
||||
m.CreatedAt = time.Now()
|
||||
if m.IntervalSec <= 0 {
|
||||
m.IntervalSec = 60
|
||||
}
|
||||
if m.Retries <= 0 {
|
||||
m.Retries = 1
|
||||
}
|
||||
if m.Runner == "" {
|
||||
m.Runner = models.RunnerServer
|
||||
}
|
||||
m.State = models.MonitorState{Status: models.StatusPending}
|
||||
if _, err := db.Col("monitors").InsertOne(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
if raw, present := upd["channel_ids"]; present {
|
||||
ids, ok := raw.([]string)
|
||||
if !ok {
|
||||
return fmt.Errorf("channel_ids must be a string array")
|
||||
}
|
||||
if err := validateChannelIDs(instanceID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, present := upd["runner"]; present {
|
||||
runner, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("runner must be a string")
|
||||
}
|
||||
if err := validateRunner(instanceID, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runner == "" {
|
||||
upd["runner"] = models.RunnerServer
|
||||
}
|
||||
}
|
||||
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteMonitor(instanceID, monitorID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.DeletedCount == 0 {
|
||||
return nil
|
||||
}
|
||||
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListIncidents(instanceID, monitorID string, limit int64) ([]models.Incident, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID},
|
||||
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Incident
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Rollup, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitor_rollups").Find(ctx,
|
||||
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "period_start": bson.M{"$gte": since}},
|
||||
options.Find().SetSort(bson.M{"period_start": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Rollup
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
|
||||
if instanceID == "" {
|
||||
return errors.New("instance id required")
|
||||
}
|
||||
return ingestResult(instanceID, runner, monitorID, res)
|
||||
}
|
||||
|
||||
func IngestServerScheduledResult(monitorID string, res checker.Result) error {
|
||||
return ingestResult("", models.RunnerServer, monitorID, res)
|
||||
}
|
||||
|
||||
func ingestResult(instanceID, runner, monitorID string, res checker.Result) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
m, err := getMonitorByID(monitorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m == nil {
|
||||
return fmt.Errorf("monitor %s not found", monitorID)
|
||||
}
|
||||
if instanceID != "" && m.InstanceID != instanceID {
|
||||
return fmt.Errorf("monitor %s belongs to another org", monitorID)
|
||||
}
|
||||
if m.Runner != runner {
|
||||
return fmt.Errorf("monitor %s is not run by %s", monitorID, runner)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
prev := m.State.Status
|
||||
retries := m.Retries
|
||||
if retries < 1 {
|
||||
retries = 1
|
||||
}
|
||||
|
||||
newStatus := prev
|
||||
fails := m.State.Fails
|
||||
if res.Up {
|
||||
fails = 0
|
||||
newStatus = models.StatusUp
|
||||
} else {
|
||||
fails++
|
||||
if fails >= retries {
|
||||
newStatus = models.StatusDown
|
||||
} else if prev == "" || prev == models.StatusPending {
|
||||
newStatus = models.StatusPending
|
||||
}
|
||||
}
|
||||
|
||||
state := bson.M{
|
||||
"state.status": newStatus,
|
||||
"state.last_check_at": now,
|
||||
"state.latency_ms": res.LatencyMs,
|
||||
"state.message": res.Message,
|
||||
"state.fails": fails,
|
||||
}
|
||||
if res.CertExpiry != nil {
|
||||
state["state.cert_expiry_at"] = *res.CertExpiry
|
||||
}
|
||||
if _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": state}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bucket := now.Truncate(time.Hour)
|
||||
up := 0
|
||||
if res.Up {
|
||||
up = 1
|
||||
}
|
||||
|
||||
db.Col("monitor_rollups").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "period_start": bucket},
|
||||
bson.M{
|
||||
"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)},
|
||||
"$setOnInsert": bson.M{"instance_id": m.InstanceID},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
|
||||
if newStatus != prev {
|
||||
switch newStatus {
|
||||
case models.StatusDown:
|
||||
inc := models.Incident{
|
||||
InstanceID: m.InstanceID,
|
||||
IncidentID: uuid.NewString(),
|
||||
MonitorID: monitorID,
|
||||
StartedAt: now,
|
||||
Cause: res.Message,
|
||||
}
|
||||
db.Col("incidents").InsertOne(ctx, inc)
|
||||
notifyTransition(m, newStatus, res.Message)
|
||||
case models.StatusUp:
|
||||
if prev == models.StatusDown {
|
||||
db.Col("incidents").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "instance_id": m.InstanceID, "resolved_at": nil},
|
||||
bson.M{"$set": bson.M{"resolved_at": now}})
|
||||
notifyTransition(m, newStatus, res.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notifyTransition(m *models.Monitor, newStatus, message string) {
|
||||
if len(m.ChannelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
channels, err := GetChannels(m.InstanceID, m.ChannelIDs)
|
||||
if err != nil {
|
||||
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
|
||||
return
|
||||
}
|
||||
ev := notify.Event{
|
||||
MonitorName: m.Name,
|
||||
Type: m.Type,
|
||||
OldStatus: m.State.Status,
|
||||
NewStatus: newStatus,
|
||||
Message: message,
|
||||
Time: time.Now(),
|
||||
}
|
||||
for _, ch := range channels {
|
||||
if !ch.Enabled {
|
||||
continue
|
||||
}
|
||||
go func(c models.NotificationChannel) {
|
||||
if err := notify.Dispatch(c, ev); err != nil {
|
||||
log.Printf("notify: dispatch to %s (%s): %v", c.Name, c.Type, err)
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
func EnsureSecretIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.Col("secrets").Indexes().DropOne(ctx, "group_1_key_1"); err != nil && !isIndexNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func isIndexNotFound(err error) bool {
|
||||
var ce mongo.CommandError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.Code == 27 || ce.Name == "IndexNotFound" ||
|
||||
ce.Code == 26 || ce.Name == "NamespaceNotFound"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ListSecretGroups(instanceID string) ([]models.GroupSummary, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.D{{Key: "instance_id", Value: instanceID}}}},
|
||||
{{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
|
||||
}
|
||||
|
||||
func GetSecretGroup(instanceID, 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{"instance_id": instanceID, "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
|
||||
}
|
||||
|
||||
func GetSecretGroupDecrypted(instanceID, group string) (map[string]string, error) {
|
||||
docs, err := GetSecretGroup(instanceID, 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
|
||||
}
|
||||
|
||||
func RevealSecret(instanceID, 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{"instance_id": instanceID, "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)
|
||||
}
|
||||
|
||||
func UpsertSecrets(instanceID, group string, values map[string]string) error {
|
||||
if err := CheckSecretGroupLimit(instanceID, group); err != nil {
|
||||
return err
|
||||
}
|
||||
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{"instance_id": instanceID, "group": group, "key": key},
|
||||
bson.M{"$set": bson.M{
|
||||
"instance_id": instanceID,
|
||||
"encrypted_value": encrypted,
|
||||
"updated_at": time.Now(),
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func DeleteSecret(instanceID, group, key string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteSecretGroup(instanceID, group string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"instance_id": instanceID, "group": group})
|
||||
return err
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,13 +30,17 @@ func HashToken(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func CreateServer() (*models.Server, string, error) {
|
||||
func CreateServer(instanceID string) (*models.Server, string, error) {
|
||||
if err := CheckServerLimit(instanceID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
token, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
expires := time.Now().Add(time.Hour)
|
||||
s := &models.Server{
|
||||
InstanceID: instanceID,
|
||||
ServerID: uuid.NewString(),
|
||||
PreRegToken: token,
|
||||
PreRegExpires: &expires,
|
||||
@@ -52,7 +57,19 @@ func CreateServer() (*models.Server, string, error) {
|
||||
return s, token, nil
|
||||
}
|
||||
|
||||
func GetServer(serverID string) (*models.Server, error) {
|
||||
func GetServer(instanceID, serverID string) (*models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var s models.Server
|
||||
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}).Decode(&s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func getServerByID(serverID string) (*models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -79,6 +96,20 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func OSTypeFromInfo(osInfo string) string {
|
||||
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
|
||||
return "windows"
|
||||
}
|
||||
return "linux"
|
||||
}
|
||||
|
||||
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
|
||||
if osType == "windows" {
|
||||
return []string{"rdp"}, 22, 3389
|
||||
}
|
||||
return []string{"ssh"}, 22, 3389
|
||||
}
|
||||
|
||||
func RegisterServer(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -100,18 +131,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
|
||||
@@ -132,9 +176,39 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid agent token")
|
||||
}
|
||||
|
||||
if s.InstanceID == "" {
|
||||
return nil, fmt.Errorf("server %s has no org", serverID)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -151,12 +225,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func ListServers() ([]models.Server, error) {
|
||||
func ListServers(instanceID string) ([]models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||
cursor, err := db.Col("servers").Find(ctx, bson.M{}, opts)
|
||||
cursor, err := db.Col("servers").Find(ctx, bson.M{"instance_id": instanceID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,29 +243,99 @@ func ListServers() ([]models.Server, error) {
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func DeleteServer(serverID string) error {
|
||||
func DeleteServer(instanceID, serverID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID})
|
||||
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Also remove assignments
|
||||
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID})
|
||||
|
||||
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
|
||||
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()
|
||||
|
||||
cutoff := time.Now().Add(-threshold)
|
||||
_, err := db.Col("servers").UpdateMany(ctx,
|
||||
bson.M{
|
||||
"status": "active",
|
||||
"last_seen": bson.M{"$lt": cutoff},
|
||||
},
|
||||
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 {
|
||||
instanceIDs, err := ListInstanceIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, instanceID := range instanceIDs {
|
||||
if err := markOfflineForFilter(bson.M{"instance_id": instanceID}, instanceID); err != nil {
|
||||
log.Printf("offline sweep failed for org %s: %v", instanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := markOfflineForFilter(bson.M{"instance_id": bson.M{"$nin": instanceIDs}}, ""); err != nil {
|
||||
log.Printf("offline sweep failed for orphaned servers: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markOfflineForFilter(scope bson.M, instanceID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var settings *models.Settings
|
||||
thresholdMinutes := 5
|
||||
if instanceID != "" {
|
||||
settings, _ = GetSettings(instanceID)
|
||||
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
|
||||
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
|
||||
}
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute)
|
||||
|
||||
filter := bson.M{
|
||||
"status": "active",
|
||||
"last_seen": bson.M{"$lt": cutoff},
|
||||
}
|
||||
for k, v := range scope {
|
||||
filter[k] = v
|
||||
}
|
||||
|
||||
cursor, err := db.Col("servers").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var goingOffline []models.Server
|
||||
err = cursor.All(ctx, &goingOffline)
|
||||
cursor.Close(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(goingOffline) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, s := range goingOffline {
|
||||
LogEvent(s.InstanceID, "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, filter,
|
||||
bson.M{"$set": bson.M{"status": "offline"}},
|
||||
)
|
||||
return err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user