Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c37bf81b | ||
|
|
946a748038 | ||
|
|
481649e03f | ||
|
|
cfdc00552e | ||
|
|
b7221d8111 | ||
|
|
7e6d7074d6 | ||
|
|
268134d821 | ||
|
|
c829cc41d9 | ||
|
|
07a3756b18 | ||
|
|
769839a70d | ||
|
|
480a578deb | ||
|
|
a636a48e07 | ||
|
|
427191b14c | ||
|
|
b8fcf89ee7 | ||
|
|
64eac6dbc7 | ||
|
|
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 |
@@ -86,22 +86,6 @@ jobs:
|
||||
$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: Cache nssm
|
||||
id: cache-nssm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: installer/nssm.exe
|
||||
key: nssm-2.24-win64
|
||||
|
||||
- name: Fetch nssm
|
||||
if: steps.cache-nssm.outputs.cache-hit != 'true'
|
||||
working-directory: installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://nssm.cc/release/nssm-2.24.zip -OutFile nssm.zip
|
||||
Expand-Archive -Path nssm.zip -DestinationPath nssm-extract -Force
|
||||
Copy-Item nssm-extract/nssm-2.24/win64/nssm.exe -Destination nssm.exe
|
||||
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.*
|
||||
|
||||
@@ -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,27 @@ 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"
|
||||
|
||||
- name: Build and push admin image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
|
||||
# Root context: admin depends on the shared module.
|
||||
docker build -t "$IMAGE" -f admin/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
+5
-3
@@ -2,10 +2,12 @@ node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
docs
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
installer/*.exe
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,30 @@
|
||||
# Context is the repository root; admin depends on the shared module.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY admin/go.mod admin/go.sum ./admin/
|
||||
RUN cd admin && go mod download
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY admin/ ./admin/
|
||||
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
addgroup --system --gid 1001 admin && \
|
||||
adduser --system --uid 1001 --ingroup admin admin
|
||||
|
||||
COPY --from=builder /out/admin /usr/local/bin/admin
|
||||
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
|
||||
|
||||
USER admin
|
||||
|
||||
EXPOSE 8083
|
||||
ENV PORT=8083
|
||||
|
||||
CMD ["/usr/local/bin/admin"]
|
||||
@@ -0,0 +1,82 @@
|
||||
// Command adminctl performs the operations that deliberately have no HTTP
|
||||
// surface.
|
||||
//
|
||||
// adminctl staff-add --email=you@example.com --name="You" --password=...
|
||||
//
|
||||
// There is no staff signup endpoint. A licensing authority that can be joined
|
||||
// over the internet is not one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fatal("configuration: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
fatal("database: %v", err)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "staff-add":
|
||||
staffAdd(ctx, os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func staffAdd(ctx context.Context, args []string) {
|
||||
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
|
||||
email := fs.String("email", "", "staff email (required)")
|
||||
name := fs.String("name", "", "display name")
|
||||
password := fs.String("password", "", "password, at least 12 characters (required)")
|
||||
fs.Parse(args)
|
||||
|
||||
if *email == "" || len(*password) < 12 {
|
||||
fatal("--email and a --password of at least 12 characters are required")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
fatal("hash: %v", err)
|
||||
}
|
||||
|
||||
u := models.StaffUser{
|
||||
UserID: uuid.NewString(),
|
||||
Email: strings.ToLower(strings.TrimSpace(*email)),
|
||||
PasswordHash: string(hash),
|
||||
Name: *name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
|
||||
fatal("create staff user: %v", err)
|
||||
}
|
||||
fmt.Printf("created staff user %s\n", u.Email)
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/admin/internal/api"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("configuration error: %v", err)
|
||||
}
|
||||
|
||||
licensing.SetSigningKey(cfg.SigningKey)
|
||||
|
||||
mail.Init(mail.Config{
|
||||
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
|
||||
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
|
||||
PublicURL: cfg.PublicURL,
|
||||
})
|
||||
if !mail.Enabled() {
|
||||
log.Println("warning: SMTP not configured; verification and licence emails will fail")
|
||||
}
|
||||
|
||||
auth.InitRedis(cfg.RedisAddr, cfg.RedisUsername, cfg.RedisPassword)
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := auth.Ping(pingCtx); err != nil {
|
||||
pingCancel()
|
||||
log.Fatalf("redis: %v", err)
|
||||
}
|
||||
pingCancel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
cancel()
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
cancel()
|
||||
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
|
||||
|
||||
idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := db.EnsureIndexes(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("indexes: %v", err)
|
||||
}
|
||||
if err := models.SeedPlans(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("plan seed: %v", err)
|
||||
}
|
||||
idxCancel()
|
||||
|
||||
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: api.Routes(cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("admin listening on %s", cfg.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-stopCtx.Done()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
log.Println("admin stopped")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
module github.com/mrhid6/vantage/admin
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // 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.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/protobuf v1.36.10 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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.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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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.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=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.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.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
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.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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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.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-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.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.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=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
//
|
||||
// EVERY customer handler that names an instance must go through this. It returns
|
||||
// 404 for another account's instance rather than 403: a 403 confirms the
|
||||
// instance exists, which is an existence oracle over customer data.
|
||||
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
|
||||
s := auth.Current(c)
|
||||
if s == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return nil, false
|
||||
}
|
||||
var inst models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &inst, true
|
||||
}
|
||||
|
||||
func getAccount(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
|
||||
}
|
||||
|
||||
func linkInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrAlreadyLinked) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
func relinkInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrRelinkLimit) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deliver(c, inst, lic)
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func getInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func downloadInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
|
||||
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
|
||||
}
|
||||
|
||||
func listSubscriptions(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if err := cur.All(c.Request.Context(), &subs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
// Delivery failures are logged, never returned: the licence is already recorded,
|
||||
// which is the part that must not be lost.
|
||||
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
inject.Deliver(c.Request.Context(), lic)
|
||||
return
|
||||
}
|
||||
s := auth.Current(c)
|
||||
if s != nil && mail.Enabled() {
|
||||
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package api mounts admin's HTTP surface.
|
||||
//
|
||||
// The route table is the single place scoping is guaranteed. Customer routes
|
||||
// live behind RequireCustomer and every handler that names an instance calls
|
||||
// ownedInstance. A new customer route that skips that helper is a scoping bug,
|
||||
// so keep them together and review them together.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
)
|
||||
|
||||
func Routes(cfg config.Config) http.Handler {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(cors(cfg.AllowedOrigins))
|
||||
|
||||
if cfg.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
r.POST("/auth/staff/login", auth.HandleStaffLogin)
|
||||
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
|
||||
cust := r.Group("/api")
|
||||
cust.Use(auth.RequireCustomer())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
cust.POST("/instances/link", linkInstance)
|
||||
cust.POST("/instances/:id/relink", relinkInstance)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/subscriptions", listSubscriptions)
|
||||
}
|
||||
|
||||
staff := r.Group("/api/staff")
|
||||
staff.Use(auth.RequireStaff())
|
||||
{
|
||||
staff.GET("/accounts", staffListAccounts)
|
||||
staff.POST("/accounts", staffCreateAccount)
|
||||
staff.GET("/accounts/:id", staffGetAccount)
|
||||
staff.GET("/instances", staffListInstances)
|
||||
staff.POST("/instances", staffCreateInstance)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
staff.PUT("/plans/:tier", staffUpdatePlan)
|
||||
staff.GET("/audit", staffAudit)
|
||||
staff.GET("/health/injection", staffInjectionHealth)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func cors(allowed []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && slices.Contains(allowed, origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func staffListAccounts(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if q := c.Query("q"); q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"name": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accounts := []models.Account{}
|
||||
if err := cur.All(c.Request.Context(), &accounts); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accounts)
|
||||
}
|
||||
|
||||
func staffCreateAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BillingEmail string `json:"billing_email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
|
||||
return
|
||||
}
|
||||
acct := models.Account{
|
||||
AccountID: uuid.NewString(),
|
||||
Name: body.Name,
|
||||
BillingEmail: body.BillingEmail,
|
||||
Status: models.AccountActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, acct)
|
||||
}
|
||||
|
||||
func staffGetAccount(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID})
|
||||
instances := []models.Instance{}
|
||||
if cur != nil {
|
||||
_ = cur.All(ctx, &instances)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
|
||||
}
|
||||
|
||||
func staffListInstances(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
for param, field := range map[string]string{
|
||||
"account_id": "account_id",
|
||||
"deployment": "deployment",
|
||||
"status": "status",
|
||||
} {
|
||||
if v := c.Query(param); v != "" {
|
||||
filter[field] = v
|
||||
}
|
||||
}
|
||||
if c.Query("expiring") == "true" {
|
||||
// Instances whose licence expires within 14 days, for renewal chasing.
|
||||
var ids []string
|
||||
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
|
||||
"superseded_by": bson.M{"$exists": false},
|
||||
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
|
||||
})
|
||||
if err == nil {
|
||||
var lics []models.License
|
||||
if cur.All(c.Request.Context(), &lics) == nil {
|
||||
for _, l := range lics {
|
||||
ids = append(ids, l.InstanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
filter["instance_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instances)
|
||||
}
|
||||
|
||||
// staffCreateInstance attaches an instance to an account.
|
||||
//
|
||||
// For cloud, this ADOPTS an instance that already exists in the control plane —
|
||||
// the control-plane row is the source of truth for its name and slug, and this
|
||||
// refuses if no such instance exists, because an admin row pointing at nothing
|
||||
// would issue licences nobody can use.
|
||||
//
|
||||
// For self-hosted it does the same job as the customer-facing link endpoint, so
|
||||
// staff can link on a customer's behalf during support.
|
||||
//
|
||||
// This is how existing cloud instances get licensed: adopt, then issue.
|
||||
func staffCreateInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Deployment string `json:"deployment"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: body.InstanceID,
|
||||
AccountID: body.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: body.Deployment,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if body.Deployment == license.DeploymentCloud {
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
|
||||
return
|
||||
}
|
||||
inst.Name = remote.Name
|
||||
inst.Slug = remote.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
func staffIssue(c *gin.Context) {
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
Term string `json:"term"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
|
||||
return
|
||||
}
|
||||
if body.Reason == "" {
|
||||
body.Reason = models.ReasonManual
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
|
||||
InstanceID: c.Param("id"),
|
||||
Tier: body.Tier,
|
||||
Term: body.Term,
|
||||
Reason: body.Reason,
|
||||
IssuedBy: s.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
|
||||
deliver(c, &inst, lic)
|
||||
}
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// staffRelink has no attempt cap. The customer-facing limit exists to put a
|
||||
// human in front of the fourth attempt; this is that human.
|
||||
func staffRelink(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func staffListLicenses(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("instance_id"); v != "" {
|
||||
filter["instance_id"] = v
|
||||
}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lics := []models.License{}
|
||||
if err := cur.All(c.Request.Context(), &lics); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
func staffListPlans(c *gin.Context) {
|
||||
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
plans := []models.Plan{}
|
||||
if err := cur.All(c.Request.Context(), &plans); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
|
||||
// snapshotted their plan at issue time and are unaffected — the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
func staffUpdatePlan(c *gin.Context) {
|
||||
var body models.Plan
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
|
||||
return
|
||||
}
|
||||
set := bson.M{
|
||||
"name": body.Name,
|
||||
"limits": body.Limits,
|
||||
"features": body.Features,
|
||||
"paddle_product_id": body.PaddleProductID,
|
||||
"paddle_price_ids": body.PaddlePriceIDs,
|
||||
"active": body.Active,
|
||||
}
|
||||
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
|
||||
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func staffAudit(c *gin.Context) {
|
||||
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{},
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entries := []models.AuditEntry{}
|
||||
if err := cur.All(c.Request.Context(), &entries); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// staffInjectionHealth lists instances whose last injection failed. This is the
|
||||
// page to look at when a customer says their cloud instance is read-only.
|
||||
func staffInjectionHealth(c *gin.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
|
||||
bson.M{"inject_failed_at": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
failed := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &failed); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package audit records who did what. Every issuance, link, relink and sign-in
|
||||
// attempt lands here.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
// Write never returns an error: an audit failure must not roll back the action
|
||||
// it describes. It logs instead, loudly enough to notice.
|
||||
func Write(ctx context.Context, e models.AuditEntry) {
|
||||
e.CreatedAt = time.Now().UTC()
|
||||
if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
|
||||
log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
adminmodels "github.com/mrhid6/vantage/admin/internal/models"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's
|
||||
// users collection, with the credentials they already have.
|
||||
//
|
||||
// Two consequences worth stating plainly, because they are real and were
|
||||
// accepted deliberately:
|
||||
//
|
||||
// 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 control-plane role "owner" may sign in here. admin and member are
|
||||
// refused — billing is an owner concern.
|
||||
//
|
||||
// Mitigations: rate limits, an identical error for every failure, and an audit
|
||||
// entry for every attempt.
|
||||
func HandleCloudLogin(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 are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
// A self-hosted customer_users row wins over a control-plane user with the
|
||||
// same address. Documented so the behaviour is chosen rather than emergent.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
HandleCustomerLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
var u sharedmodels.User
|
||||
if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.Role != sharedmodels.RoleOwner {
|
||||
reject("role " + u.Role + " is not permitted")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the admin-side account that owns this user's instance.
|
||||
var inst adminmodels.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil {
|
||||
reject("no account for instance " + u.InstanceID)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptCost matches the control plane and sitesvc. Changing it here alone would
|
||||
// make hashes inconsistent across services that may one day compare them.
|
||||
const BcryptCost = 12
|
||||
|
||||
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
|
||||
// SHA-256 hash stored, 24-hour expiry.
|
||||
const VerifyWindow = 24 * time.Hour
|
||||
|
||||
// CreateCustomerUser creates an unverified self-hosted customer login and emails
|
||||
// the verification link. Called during purchase (spec 5) and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
PasswordHash: string(hash),
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
return mail.SendVerification(u.Email, token)
|
||||
}
|
||||
|
||||
// HandleVerify consumes a verification token.
|
||||
func HandleVerify(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true})
|
||||
}
|
||||
|
||||
// HandleCustomerLogin authenticates a self-hosted customer.
|
||||
func HandleCustomerLogin(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 are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.VerifiedAt == nil {
|
||||
// Distinct from genericAuthError on purpose: the address is already
|
||||
// known to be theirs, so there is nothing to disclose, and "check your
|
||||
// email" is the only useful thing to say.
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
|
||||
func load(c *gin.Context) *Session {
|
||||
id, err := c.Cookie(CookieName)
|
||||
if err != nil || id == "" {
|
||||
return nil
|
||||
}
|
||||
s, err := Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Current returns the session, or nil.
|
||||
func Current(c *gin.Context) *Session {
|
||||
if v, ok := c.Get(ctxSession); ok {
|
||||
if s, ok := v.(*Session); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RequireStaff() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := load(c)
|
||||
if s == nil || s.Kind != KindStaff {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireCustomer admits both cloud and self-hosted customers. Every handler
|
||||
// behind it scopes by AccountID via the helper in api/customer.go — never by
|
||||
// remembering to filter.
|
||||
func RequireCustomer() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := load(c)
|
||||
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
|
||||
// hour. The email limit stops a targeted attack on one account; the IP limit
|
||||
// stops a spray across many.
|
||||
const (
|
||||
emailLimit = 5
|
||||
emailWindow = 15 * time.Minute
|
||||
ipLimit = 20
|
||||
ipWindow = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
attemptMu sync.Mutex
|
||||
byEmail = map[string][]time.Time{}
|
||||
byIP = map[string][]time.Time{}
|
||||
)
|
||||
|
||||
func prune(in []time.Time, cutoff time.Time) []time.Time {
|
||||
out := in[:0]
|
||||
for _, t := range in {
|
||||
if t.After(cutoff) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowAttempt(email, ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
attemptMu.Lock()
|
||||
defer attemptMu.Unlock()
|
||||
|
||||
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
|
||||
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
|
||||
|
||||
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
|
||||
return false
|
||||
}
|
||||
byEmail[email] = append(byEmail[email], now)
|
||||
byIP[ip] = append(byIP[ip], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func clearAttempts(email string) {
|
||||
attemptMu.Lock()
|
||||
delete(byEmail, email)
|
||||
attemptMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package auth holds admin's three identities: staff, cloud customers and
|
||||
// self-hosted customers. All three share one session store and one cookie.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "admin_session"
|
||||
SessionTTL = 24 * time.Hour
|
||||
KindStaff = "staff"
|
||||
KindCustomer = "customer"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
UserID string `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Email string `json:"email"`
|
||||
AccountID string `json:"account_id,omitempty"` // customers only
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
|
||||
// InitRedis connects the session store.
|
||||
//
|
||||
// Username and password may both be empty for an unauthenticated instance. For
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username —
|
||||
// go-redis then sends AUTH with one argument instead of two.
|
||||
func InitRedis(addr, username, password string) {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
|
||||
func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }
|
||||
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func Save(ctx context.Context, s Session) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func Get(ctx context.Context, id string) (*Session, error) {
|
||||
body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s Session
|
||||
if err := json.Unmarshal(body, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }
|
||||
|
||||
func SetCookie(c *gin.Context, id string) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(SessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearCookie(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// genericAuthError is returned for every failure mode — unknown email, wrong
|
||||
// password, wrong role. Distinguishing them would confirm which addresses have
|
||||
// accounts.
|
||||
const genericAuthError = "email or password is incorrect"
|
||||
|
||||
func HandleStaffLogin(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 are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
var u models.StaffUser
|
||||
err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
|
||||
if err != nil {
|
||||
// Spend the same work as a real comparison so timing does not
|
||||
// distinguish "no such user" from "wrong password".
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
return
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: email, Action: "staff.login", IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
|
||||
}
|
||||
|
||||
// dummyHash is a valid bcrypt hash of a random value, compared against when no
|
||||
// user exists so the timing profile matches.
|
||||
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"
|
||||
|
||||
func HandleLogout(c *gin.Context) {
|
||||
if id, err := c.Cookie(CookieName); err == nil && id != "" {
|
||||
Destroy(c.Request.Context(), id)
|
||||
}
|
||||
ClearCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package config parses and validates admin's environment.
|
||||
//
|
||||
// Everything required is checked at boot and the process refuses to start
|
||||
// without it. A licensing service that cannot sign is worse than one that is
|
||||
// down, because it looks healthy.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminMongoURI string
|
||||
AdminDBName string
|
||||
ControlMongoURI string
|
||||
ControlDBName string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
SigningKey string
|
||||
PublicURL string
|
||||
AllowedOrigins []string
|
||||
TrustProxy bool
|
||||
Addr string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPFrom string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
}
|
||||
|
||||
// dbNameFromURI reads the database from a Mongo URI path.
|
||||
//
|
||||
// Both URIs must name their database inline rather than through a separate
|
||||
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
|
||||
// about which, and guessing wrong means writing licence fields into the wrong
|
||||
// place.
|
||||
func dbNameFromURI(raw, which string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
|
||||
}
|
||||
name := strings.TrimPrefix(u.Path, "/")
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"),
|
||||
ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
// Optional: an unauthenticated Redis needs neither. Redis 6+ ACL auth
|
||||
// takes both; a legacy `requirepass` instance takes the password alone
|
||||
// and must leave the username empty.
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
|
||||
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
|
||||
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
|
||||
Addr: ":" + envOr("PORT", "8083"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: envOr("SMTP_PORT", "587"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
SMTPUsername: os.Getenv("SMTP_USERNAME"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for name, v := range map[string]string{
|
||||
"ADMIN_MONGO_URI": c.AdminMongoURI,
|
||||
"CONTROL_MONGO_URI": c.ControlMongoURI,
|
||||
"REDIS_ADDR": c.RedisAddr,
|
||||
"LICENSE_SIGNING_KEY": c.SigningKey,
|
||||
"PUBLIC_URL": c.PublicURL,
|
||||
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
|
||||
} {
|
||||
if v == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.AdminMongoURI == c.ControlMongoURI {
|
||||
return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
|
||||
}
|
||||
|
||||
for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package db holds admin's two MongoDB connections.
|
||||
//
|
||||
// Admin() is its own database and it owns every collection there. Control() is
|
||||
// the control plane's database, and admin's access to it is deliberately narrow:
|
||||
// it reads `instances` and `users`, and writes exactly three licence fields on
|
||||
// `instances`. Nothing here should ever grow a write path to another collection.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
adminDB *mongo.Database
|
||||
controlDB *mongo.Database
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, cfg config.Config) error {
|
||||
ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect admin mongo: %w", err)
|
||||
}
|
||||
if err := ac.Ping(ctx, nil); err != nil {
|
||||
return fmt.Errorf("ping admin mongo: %w", err)
|
||||
}
|
||||
adminDB = ac.Database(cfg.AdminDBName)
|
||||
|
||||
cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect control mongo: %w", err)
|
||||
}
|
||||
if err := cc.Ping(ctx, nil); err != nil {
|
||||
return fmt.Errorf("ping control mongo: %w", err)
|
||||
}
|
||||
controlDB = cc.Database(cfg.ControlDBName)
|
||||
|
||||
// The control plane must already be deployed and migrated. Without the
|
||||
// instances collection, injection would silently create it and write
|
||||
// licence fields into a collection nothing reads.
|
||||
names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect control database: %w", err)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
|
||||
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
|
||||
|
||||
func Ctx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
// EnsureIndexes creates admin's unique indexes.
|
||||
//
|
||||
// These are a correctness property, not an optimisation. In particular
|
||||
// admin_instances.instance_id unique is what stops the same self-hosted UUID
|
||||
// being linked to two accounts — without it, two customers could both claim one
|
||||
// instance and both be issued licences for it.
|
||||
func EnsureIndexes(ctx context.Context) error {
|
||||
unique := []struct {
|
||||
coll string
|
||||
field string
|
||||
}{
|
||||
{"accounts", "account_id"},
|
||||
{"admin_instances", "instance_id"},
|
||||
{"licenses", "license_id"},
|
||||
{"plans", "tier"},
|
||||
{"staff_users", "email"},
|
||||
{"customer_users", "email"},
|
||||
}
|
||||
for _, u := range unique {
|
||||
if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: u.field, Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetName(u.field + "_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("index %s.%s: %w", u.coll, u.field, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse: a subscription exists before Paddle assigns an ID, so empty must
|
||||
// not collide with empty.
|
||||
if _, err := Admin("subscriptions").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "paddle_subscription_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetSparse(true).SetName("paddle_subscription_id_unique"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err)
|
||||
}
|
||||
|
||||
for _, idx := range []struct {
|
||||
coll string
|
||||
keys bson.D
|
||||
}{
|
||||
{"licenses", bson.D{{Key: "instance_id", Value: 1}, {Key: "issued_at", Value: -1}}},
|
||||
{"admin_instances", bson.D{{Key: "account_id", Value: 1}}},
|
||||
{"admin_audit", bson.D{{Key: "created_at", Value: -1}}},
|
||||
} {
|
||||
if _, err := Admin(idx.coll).Indexes().CreateOne(ctx, mongo.IndexModel{Keys: idx.keys}); err != nil {
|
||||
return fmt.Errorf("index %s: %w", idx.coll, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package inject writes licences onto control-plane instance documents.
|
||||
//
|
||||
// This is admin's ONLY write path into the control plane, and it touches exactly
|
||||
// three fields on one collection. If this package ever grows a second write
|
||||
// target, that is a design change and not a refactor.
|
||||
package inject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ReconcileInterval is how often every cloud instance is compared against what
|
||||
// admin believes it should hold.
|
||||
//
|
||||
// This job, not the issuance path, is what guarantees eventual consistency.
|
||||
// Injection at issue time is best-effort; this is the backstop.
|
||||
const ReconcileInterval = 15 * time.Minute
|
||||
|
||||
// Cloud writes the licence onto the control-plane instance document.
|
||||
//
|
||||
// Idempotent and safe to re-run: it is a single UpdateOne of three fields with
|
||||
// no read-modify-write. Retries three times with backoff.
|
||||
//
|
||||
// The control plane caches licence state for 60 seconds, so this takes effect
|
||||
// within a minute with no restart.
|
||||
func Cloud(ctx context.Context, lic *models.License) error {
|
||||
set := bson.M{"$set": bson.M{
|
||||
"license_blob": lic.Blob,
|
||||
"license_tier": lic.Tier,
|
||||
"license_expiry": lic.ExpiresAt,
|
||||
}}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
res, err := db.Control("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID}, set)
|
||||
if err == nil {
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("no control-plane instance %s", lic.InstanceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("inject after 3 attempts: %w", lastErr)
|
||||
}
|
||||
|
||||
// Deliver injects and records the outcome without ever failing the caller.
|
||||
//
|
||||
// A licence that is recorded but not injected is recoverable — the reconciler
|
||||
// will fix it within 15 minutes, and staff can see it on the health endpoint.
|
||||
// Failing the purchase because one write failed would be worse.
|
||||
func Deliver(ctx context.Context, lic *models.License) {
|
||||
if err := Cloud(ctx, lic); err != nil {
|
||||
log.Printf("INJECTION FAILED instance=%s licence=%s: %v", lic.InstanceID, lic.LicenseID, err)
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID},
|
||||
bson.M{"$set": bson.M{"inject_failed_at": now}})
|
||||
return
|
||||
}
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": lic.InstanceID},
|
||||
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
|
||||
}
|
||||
|
||||
// Reconcile compares every active cloud instance's current licence against the
|
||||
// blob actually stored in the control plane, and re-injects on mismatch.
|
||||
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentCloud,
|
||||
"status": models.StatusActive,
|
||||
"current_license": bson.M{"$ne": ""},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
checked++
|
||||
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
log.Printf("reconcile: instance %s references unknown licence %s", inst.InstanceID, inst.CurrentLicense)
|
||||
continue
|
||||
}
|
||||
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
|
||||
log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if remote.LicenseBlob == lic.Blob {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("reconcile: repairing instance %s (licence %s)", inst.InstanceID, lic.LicenseID)
|
||||
if err := Cloud(ctx, &lic); err != nil {
|
||||
log.Printf("reconcile: repair failed for %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
repaired++
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$unset": bson.M{"inject_failed_at": ""}})
|
||||
}
|
||||
return checked, repaired, nil
|
||||
}
|
||||
|
||||
// StartReconciler reconciles once at boot, then on a ticker until ctx is
|
||||
// cancelled.
|
||||
//
|
||||
// The pass at boot matters: injection failures are most likely around a deploy
|
||||
// or a crash, and waiting a full interval to notice would leave a paying
|
||||
// customer read-only for that long. It also means a restart is a supported way
|
||||
// to force reconciliation.
|
||||
func StartReconciler(ctx context.Context) {
|
||||
go func() {
|
||||
runOnce(ctx)
|
||||
|
||||
t := time.NewTicker(ReconcileInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runOnce(ctx context.Context) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
checked, repaired, err := Reconcile(runCtx)
|
||||
if err != nil {
|
||||
log.Printf("reconcile: %v", err)
|
||||
return
|
||||
}
|
||||
if repaired > 0 {
|
||||
log.Printf("reconcile: checked %d, repaired %d", checked, repaired)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package licensing issues licences. It is the only place that signs.
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownTier = errors.New("unknown tier")
|
||||
ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type")
|
||||
ErrFreeLimit = errors.New("this account already has a Free instance")
|
||||
ErrUnknownInstance = errors.New("instance not found")
|
||||
)
|
||||
|
||||
type IssueInput struct {
|
||||
InstanceID string
|
||||
Tier string
|
||||
Term string // "monthly" or "annual"; ignored when ExpiresAt is set
|
||||
ExpiresAt time.Time // explicit expiry, used by relink to preserve the remaining term
|
||||
Reason string
|
||||
IssuedBy string // staff email, "system", or "paddle:<event id>"
|
||||
}
|
||||
|
||||
// signingKey is set once at boot from LICENSE_SIGNING_KEY.
|
||||
var signingKey string
|
||||
|
||||
func SetSigningKey(k string) { signingKey = k }
|
||||
|
||||
// Issue signs a licence, records it, supersedes its predecessor and updates the
|
||||
// instance.
|
||||
//
|
||||
// It does NOT deliver. Recording and delivery are deliberately separate and
|
||||
// ordered: a licence recorded but not delivered is recoverable, because the
|
||||
// customer can download it. A licence delivered but not recorded is a support
|
||||
// mystery with no paper trail. Callers deliver after this returns.
|
||||
func Issue(ctx context.Context, in IssueInput) (*models.License, error) {
|
||||
if signingKey == "" {
|
||||
return nil, errors.New("no signing key configured")
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": in.InstanceID}).Decode(&inst); err != nil {
|
||||
return nil, ErrUnknownInstance
|
||||
}
|
||||
|
||||
plan, err := models.GetPlan(ctx, in.Tier)
|
||||
if err != nil {
|
||||
return nil, ErrUnknownTier
|
||||
}
|
||||
|
||||
// This single comparison is what makes Free cloud-only. Free's plan is
|
||||
// deployment "cloud", so it can never be issued against a self-hosted
|
||||
// instance, and verification on the instance would reject it anyway.
|
||||
if plan.Deployment != inst.Deployment {
|
||||
return nil, fmt.Errorf("%w: %s is %s only", ErrDeploymentMismatch, plan.Name, plan.Deployment)
|
||||
}
|
||||
|
||||
if plan.Tier == license.TierFree {
|
||||
if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expires := in.ExpiresAt
|
||||
if expires.IsZero() {
|
||||
switch in.Term {
|
||||
case "monthly":
|
||||
expires = now.AddDate(0, 1, 0).Add(models.GracePeriod)
|
||||
case "annual", "":
|
||||
expires = now.AddDate(1, 0, 0).Add(models.GracePeriod)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown term %q", in.Term)
|
||||
}
|
||||
}
|
||||
|
||||
payload := license.License{
|
||||
ID: uuid.NewString(),
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
InstanceName: inst.Name,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
// Snapshotted, not referenced: editing a plan tomorrow must not change
|
||||
// what this licence grants.
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features,
|
||||
}
|
||||
|
||||
blob, err := license.Sign(payload, signingKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
|
||||
rec := models.License{
|
||||
LicenseID: payload.ID,
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
Limits: plan.Limits,
|
||||
Features: plan.Features,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expires,
|
||||
Blob: blob,
|
||||
IssuedBy: in.IssuedBy,
|
||||
Reason: in.Reason,
|
||||
}
|
||||
if _, err := db.Admin("licenses").InsertOne(ctx, rec); err != nil {
|
||||
return nil, fmt.Errorf("record licence: %w", err)
|
||||
}
|
||||
|
||||
// Supersede rather than delete. The history is the support tool.
|
||||
if inst.CurrentLicense != "" {
|
||||
if _, err := db.Admin("licenses").UpdateOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense},
|
||||
bson.M{"$set": bson.M{"superseded_by": rec.LicenseID}}); err != nil {
|
||||
return nil, fmt.Errorf("supersede previous licence: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"current_license": rec.LicenseID,
|
||||
"tier": plan.Tier,
|
||||
"status": models.StatusActive,
|
||||
}
|
||||
if in.Reason == models.ReasonRenewal {
|
||||
set["relink_count"] = 0 // the cap is per term
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
|
||||
return nil, fmt.Errorf("update instance: %w", err)
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: in.IssuedBy,
|
||||
Action: "license.issued",
|
||||
AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID,
|
||||
Detail: fmt.Sprintf("tier=%s reason=%s expires=%s licence=%s",
|
||||
plan.Tier, in.Reason, expires.Format(time.RFC3339), rec.LicenseID),
|
||||
})
|
||||
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// checkFreeLimit enforces one Free instance per account.
|
||||
//
|
||||
// Cancelled instances do not count: a customer who cancelled their Free instance
|
||||
// is allowed another one.
|
||||
func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error {
|
||||
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
|
||||
"account_id": accountID,
|
||||
"tier": license.TierFree,
|
||||
"status": bson.M{"$ne": models.StatusCancelled},
|
||||
"instance_id": bson.M{"$ne": exceptInstanceID},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrFreeLimit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadUUID = errors.New("that does not look like an instance ID")
|
||||
ErrAlreadyLinked = errors.New("that instance ID is already linked to an account")
|
||||
ErrRelinkLimit = errors.New("relink limit reached for this term; contact support")
|
||||
)
|
||||
|
||||
// LinkInstance attaches a self-hosted instance UUID to an account.
|
||||
//
|
||||
// The duplicate error deliberately does not say WHICH account holds it. It is a
|
||||
// small enumeration surface, but there is no reason to leave it open.
|
||||
func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*models.Instance, error) {
|
||||
if _, err := uuid.Parse(instanceID); err != nil {
|
||||
return nil, ErrBadUUID
|
||||
}
|
||||
|
||||
// A self-hosted UUID must not collide with a cloud instance either.
|
||||
if n, err := db.Control("instances").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil && n > 0 {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: instanceID,
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
// The unique index is what actually prevents two accounts owning
|
||||
// one instance. The check above is a nicety; this is the guarantee.
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: accountID, Action: "instance.linked", AccountID: accountID, Target: instanceID})
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// Relink moves a licence to a rebuilt server's new UUID.
|
||||
//
|
||||
// The replacement covers the REMAINING term, not a fresh one — relinking is not
|
||||
// a way to extend a subscription.
|
||||
//
|
||||
// The old licence is not revoked, because offline verification has no
|
||||
// revocation. It simply no longer matches any UUID the customer controls, and
|
||||
// its binding stops it working on another machine anyway.
|
||||
func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*models.License, error) {
|
||||
if _, err := uuid.Parse(newID); err != nil {
|
||||
return nil, ErrBadUUID
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": oldID, "account_id": accountID}).Decode(&inst); err != nil {
|
||||
return nil, ErrUnknownInstance
|
||||
}
|
||||
|
||||
// The cap is a signal, not a defence. Its job is to put a human in front of
|
||||
// the fourth attempt, so staff bypass it.
|
||||
if !staff && inst.RelinkCount >= models.MaxRelinksPerTerm {
|
||||
return nil, ErrRelinkLimit
|
||||
}
|
||||
|
||||
if n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": newID}); err == nil && n > 0 {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
|
||||
// Preserve the remaining term from the current licence.
|
||||
remaining := time.Now().UTC().Add(models.GracePeriod)
|
||||
var current models.License
|
||||
if err := db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t); err == nil {
|
||||
remaining = current.ExpiresAt
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": oldID},
|
||||
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
|
||||
actor := accountID
|
||||
if staff {
|
||||
actor = "staff"
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: actor, Action: "instance.relinked", AccountID: accountID,
|
||||
Target: newID, Detail: "was " + oldID})
|
||||
|
||||
return Issue(ctx, IssueInput{
|
||||
InstanceID: newID,
|
||||
Tier: inst.Tier,
|
||||
ExpiresAt: remaining,
|
||||
Reason: models.ReasonRelink,
|
||||
IssuedBy: actor,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package mail delivers verification links and licence files.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host, Port, From, Username, Password string
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
|
||||
func Init(c Config) { cfg = c }
|
||||
|
||||
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
|
||||
|
||||
func send(to, subject, body string) error {
|
||||
if !Enabled() {
|
||||
return fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
msg := strings.Join([]string{
|
||||
"From: " + cfg.From,
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"", body,
|
||||
}, "\r\n")
|
||||
|
||||
var auth smtp.Auth
|
||||
if cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
func SendVerification(to, token string) error {
|
||||
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
|
||||
return send(to, "Verify your Vantage account",
|
||||
"Confirm your email address to finish setting up your Vantage account:\n\n"+
|
||||
link+"\n\nThis link expires in 24 hours.\n")
|
||||
}
|
||||
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret —
|
||||
// it is useless on any instance other than the one it names.
|
||||
func SendLicense(to, instanceName, blob string) error {
|
||||
return send(to, "Your Vantage licence key",
|
||||
fmt.Sprintf("Your licence for %s is below.\n\n"+
|
||||
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
|
||||
instanceName, blob))
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Package models holds admin's own documents.
|
||||
//
|
||||
// These are admin-owned and never shared with the control plane. The two
|
||||
// structs that ARE shared — Instance and User on the control-plane side — come
|
||||
// from shared/models, so there is no second copy of those shapes to drift.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Instance statuses.
|
||||
const (
|
||||
StatusAwaitingLink = "awaiting_link"
|
||||
StatusActive = "active"
|
||||
StatusLapsed = "lapsed"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Account statuses.
|
||||
const (
|
||||
AccountActive = "active"
|
||||
AccountSuspended = "suspended"
|
||||
)
|
||||
|
||||
// Licence issuance reasons. These end up in support conversations, so they are
|
||||
// stable identifiers rather than prose.
|
||||
const (
|
||||
ReasonNew = "new"
|
||||
ReasonRenewal = "renewal"
|
||||
ReasonTierChange = "tier_change"
|
||||
ReasonRelink = "relink"
|
||||
ReasonManual = "manual"
|
||||
)
|
||||
|
||||
// MaxRelinksPerTerm is the customer-facing relink cap.
|
||||
//
|
||||
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
|
||||
// revoked, so a determined customer is not stopped by a counter. Its real job is
|
||||
// to put a human in front of the fourth attempt.
|
||||
const MaxRelinksPerTerm = 3
|
||||
|
||||
// GracePeriod is added to every licence expiry beyond the billing period end,
|
||||
// so a renewal webhook arriving slightly late does not create a gap in which a
|
||||
// paying customer's instance goes read-only.
|
||||
const GracePeriod = 3 * 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
BillingEmail string `bson:"billing_email" json:"billing_email"`
|
||||
PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Instance is admin's record of one deployment.
|
||||
//
|
||||
// For cloud, InstanceID equals the control-plane instance_id. For self-hosted it
|
||||
// is the UUID the customer pasted — their database is theirs, and we cannot see
|
||||
// it, so this row is the only thing that exists on our side.
|
||||
type Instance struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier,omitempty" json:"tier,omitempty"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// License is append-only. A renewal writes a new row and sets SupersededBy on
|
||||
// the old one. Nothing here is ever edited or deleted: when a support question
|
||||
// arrives about why an instance stopped working on a given date, the answer has
|
||||
// to still be in the table.
|
||||
type License struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
LicenseID string `bson:"license_id" json:"license_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
Features []string `bson:"features" json:"features"`
|
||||
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
|
||||
Blob string `bson:"blob" json:"-"`
|
||||
SupersededBy string `bson:"superseded_by,omitempty" json:"superseded_by,omitempty"`
|
||||
IssuedBy string `bson:"issued_by" json:"issued_by"`
|
||||
Reason string `bson:"reason" json:"reason"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
SubscriptionID string `bson:"subscription_id" json:"subscription_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
|
||||
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
|
||||
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Term string `bson:"term" json:"term"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
|
||||
}
|
||||
|
||||
// Plan is the authoritative tier definition, seeded from shared/license.
|
||||
//
|
||||
// It lives in the database so tier contents change without a deploy. Every
|
||||
// issued licence snapshots it, so editing a plan never rewrites an existing
|
||||
// licence — the same rule as workflow_runs.steps_snapshot.
|
||||
type Plan struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
Features []string `bson:"features" json:"features"`
|
||||
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
|
||||
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
}
|
||||
|
||||
type StaffUser struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash" json:"-"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerUser is a self-hosted customer's login. Cloud customers do not have
|
||||
// one — they authenticate against the control plane with credentials they
|
||||
// already hold.
|
||||
type CustomerUser struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash" json:"-"`
|
||||
VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
|
||||
VerifyTokenHash string `bson:"verify_token_hash,omitempty" json:"-"`
|
||||
VerifyTokenExpiry *time.Time `bson:"verify_token_expiry,omitempty" json:"-"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Actor string `bson:"actor" json:"actor"`
|
||||
Action string `bson:"action" json:"action"`
|
||||
AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"`
|
||||
Target string `bson:"target,omitempty" json:"target,omitempty"`
|
||||
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
|
||||
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// SeedPlans inserts the tier table from shared/license on first boot.
|
||||
//
|
||||
// It uses $setOnInsert only: once a plan exists, staff edits to limits, features
|
||||
// and Paddle IDs are authoritative and a redeploy must not stamp over them.
|
||||
func SeedPlans(ctx context.Context) error {
|
||||
for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} {
|
||||
p, ok := license.PlanFor(tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := db.Admin("plans").UpdateOne(ctx,
|
||||
bson.M{"tier": tier},
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"tier": p.Tier,
|
||||
"name": p.Name,
|
||||
"deployment": p.Deployment,
|
||||
"limits": p.Limits,
|
||||
"features": p.Features,
|
||||
"active": true,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlan reads a tier's authoritative definition.
|
||||
func GetPlan(ctx context.Context, tier string) (*Plan, error) {
|
||||
var p Plan
|
||||
if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now().UTC() }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigDir returns the platform-specific config directory.
|
||||
|
||||
func ConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
base := os.Getenv("ProgramData")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,8 +27,8 @@ func New(serverURL string, useTLS bool) (*Client, error) {
|
||||
serverURL = strings.TrimPrefix(serverURL, "https://")
|
||||
serverURL = strings.TrimPrefix(serverURL, "http://")
|
||||
|
||||
// Send a ping every 30s so proxies with a 60s idle timeout don't kill the
|
||||
// long-lived CommandStream when no commands are flowing.
|
||||
|
||||
|
||||
dialOpts := []grpc.DialOption{
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 30 * time.Second,
|
||||
@@ -126,8 +126,32 @@ func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.Package
|
||||
return err
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
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,7 +44,7 @@ type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
@@ -60,14 +60,91 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
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"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,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 {
|
||||
@@ -88,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{}
|
||||
@@ -102,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
|
||||
@@ -126,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
|
||||
@@ -155,6 +259,9 @@ type VantageClient interface {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -210,6 +317,30 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
-22
@@ -14,12 +14,16 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -30,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()
|
||||
@@ -57,19 +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)
|
||||
|
||||
// Check for OS updates on startup and then hourly.
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -92,7 +102,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
// Windows agents register and heartbeat only — no authorized_keys management.
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
@@ -114,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
|
||||
@@ -168,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 {
|
||||
@@ -186,6 +205,34 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +279,40 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 {
|
||||
@@ -240,7 +321,7 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
}
|
||||
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
|
||||
|
||||
// Re-report the (now empty) update list so the server reflects the new state.
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -249,6 +330,16 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
_ = 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, " ", "_"))
|
||||
@@ -272,21 +363,21 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -311,11 +402,11 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
exec.Command("systemctl", "restart", "vantage-agent").Run()
|
||||
}
|
||||
|
||||
// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to
|
||||
// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so
|
||||
// that when the upgrade stops the VantageAgent service, nssm's process-tree
|
||||
// kill of this agent does not also kill the installer mid-flight. Config
|
||||
// (server_id, agent_token) is preserved by setup.ps1 on upgrade.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
tag := "agent%2Fv" + u.Version
|
||||
@@ -343,8 +434,8 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
|
||||
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
|
||||
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
// "start" detaches msiexec from this process tree so the service stop
|
||||
// during the upgrade does not terminate the installer.
|
||||
|
||||
|
||||
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
|
||||
if err := up.Start(); err != nil {
|
||||
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
|
||||
@@ -353,7 +444,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -371,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
|
||||
}
|
||||
@@ -463,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 {
|
||||
|
||||
@@ -27,8 +27,8 @@ func detectPM() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckAvailable returns the list of packages with available upgrades.
|
||||
// Returns nil, nil when no supported package manager is found.
|
||||
|
||||
|
||||
func CheckAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
@@ -48,11 +48,11 @@ func CheckAvailable() ([]PackageUpdate, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyAll runs a full non-interactive upgrade using the detected package manager.
|
||||
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
// Refresh lists first, then upgrade.
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
@@ -77,8 +77,8 @@ func ApplyAll() error {
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
// Best-effort refresh; ignore errors (cached data is fine).
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
@@ -88,7 +88,7 @@ func checkApt() ([]PackageUpdate, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Format: package/suite version arch [upgradable from: old-ver]
|
||||
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
@@ -111,7 +111,6 @@ func checkApt() ([]PackageUpdate, error) {
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
// Exit code 100 means updates are available — not an error.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
@@ -133,7 +132,7 @@ func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
// name.arch new-version repo
|
||||
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
@@ -146,7 +145,7 @@ func checkPacman() ([]PackageUpdate, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
// Format: package old-version -> new-version
|
||||
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
@@ -164,7 +163,7 @@ func checkZypper() ([]PackageUpdate, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Data rows start with "v |" (available) or "i |" (installed but updatable).
|
||||
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
site:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3002: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: ${PUBLIC_URL:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
SITE_ORIGIN: ${SITE_ORIGIN:-}
|
||||
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
SMTP_TO: ${SMTP_TO:-support@hostxtra.co.uk}
|
||||
admin:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/admin:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8083:8083
|
||||
environment:
|
||||
PORT: "8083"
|
||||
ADMIN_MONGO_URI: ${ADMIN_MONGO_URI:-}
|
||||
CONTROL_MONGO_URI: ${MONGO_URI:-}
|
||||
REDIS_ADDR: ${ADMIN_REDIS_ADDR:-10.10.10.2:6379}
|
||||
REDIS_USERNAME: ${ADMIN_REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${ADMIN_REDIS_PASSWORD:-}
|
||||
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
|
||||
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
|
||||
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-true}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
networks: {}
|
||||
@@ -27,19 +27,17 @@ services:
|
||||
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:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data:/data
|
||||
web:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
|
||||
restart: unless-stopped
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,241 +0,0 @@
|
||||
# Vantage Web Console (Guacamole Replacement) — Design
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Status:** Approved design, pre-implementation
|
||||
|
||||
## Goal
|
||||
|
||||
Add a browser-based remote-access console to Vantage — SSH, RDP, and VNC into
|
||||
managed servers — as a self-hosted Guacamole replacement. Users select an SSH
|
||||
key to connect over SSH. RDP targets are reachable from a new Windows agent that
|
||||
registers the host and reports status. Windows agent ships as an MSI installer
|
||||
produced by CI.
|
||||
|
||||
## Non-Goals (YAGNI)
|
||||
|
||||
- Session recording / replay (may be added later).
|
||||
- Native Go RDP implementation (guacd handles protocol translation).
|
||||
- Per-user Linux/Windows account management from the agent.
|
||||
- Tunneling console traffic through the agent (direct network path assumed).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (guacamole-common-js, vendored — no CDN)
|
||||
│ Guacamole protocol over WebSocket
|
||||
▼
|
||||
Go server: /api/console/tunnel (github.com/wwt/guac)
|
||||
│ Guacamole protocol over TCP :4822
|
||||
▼
|
||||
guacd container (Apache Guacamole daemon)
|
||||
│ SSH :22 / RDP :3389 / VNC :5900 — direct to target IP
|
||||
▼
|
||||
Target host (LAN / VPN line-of-sight from server)
|
||||
```
|
||||
|
||||
- **Browser:** loads vendored `guacamole-common-js`, renders RDP/VNC display and
|
||||
SSH terminal. No external CDN (matches existing infra rules).
|
||||
- **Go server:** exposes a WebSocket tunnel endpoint using `github.com/wwt/guac`
|
||||
(Go Guacamole tunnel library). No Java `guacamole-client` required.
|
||||
- **guacd:** new container in `deploy/docker-compose.yml`, bound to the internal
|
||||
docker network only, reachable by the server on `:4822`.
|
||||
- **Network path:** guacd connects **directly** to the target IP. Requires the
|
||||
central server to have network line-of-sight to hosts (homelab LAN / VPN). The
|
||||
agent's outbound-only guarantee is unchanged — the console path is
|
||||
server→target, not agent-mediated.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### `keys` — extend to hold private material
|
||||
|
||||
```json
|
||||
{
|
||||
"key_id": "uuid",
|
||||
"label": "dom-macbook",
|
||||
"public_key": "ssh-ed25519 AAAA...",
|
||||
"private_key_enc": "<AES-256-GCM ciphertext | null>",
|
||||
"has_private": true,
|
||||
"passphrase_enc": "<AES-256-GCM ciphertext | null>",
|
||||
"fingerprint": "SHA256:...",
|
||||
"source": "uploaded|generated",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
- A key may be created from an uploaded **private+public** pair, upload of a
|
||||
public key only, or agent generation.
|
||||
- Agent key generation now also uploads `private_key_enc` (reuses the existing
|
||||
AES-256 key used for at-rest encryption). Private key no longer stays local
|
||||
only — it is stored encrypted so the console can reuse it.
|
||||
- Optional `passphrase_enc` for passphrase-protected private keys.
|
||||
- Console lists only keys where `has_private = true`.
|
||||
|
||||
### `servers` — extend with console metadata
|
||||
|
||||
```json
|
||||
{
|
||||
"...": "...existing fields...",
|
||||
"os_type": "linux|windows",
|
||||
"console_protocols": ["ssh"],
|
||||
"ssh_port": 22,
|
||||
"rdp_port": 3389
|
||||
}
|
||||
```
|
||||
|
||||
- `os_type` set at registration from the agent.
|
||||
- `console_protocols` lists enabled protocols per server (`ssh`, `rdp`, `vnc`).
|
||||
- Port fields default to standard ports, overridable in the UI.
|
||||
|
||||
### `console_sessions` — new collection (audit)
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"server_id": "uuid",
|
||||
"protocol": "ssh|rdp|vnc",
|
||||
"key_id": "uuid | null",
|
||||
"user": "who opened it",
|
||||
"started_at": "ISODate",
|
||||
"ended_at": "ISODate | null",
|
||||
"client_ip": "string"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Broker + Connection Flow
|
||||
|
||||
New service: `server/internal/services/console.go`.
|
||||
|
||||
1. Browser `POST /api/console/connect`
|
||||
`{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`.
|
||||
2. Broker validates request, loads the server (host IP, port for protocol),
|
||||
loads the key and **decrypts `private_key_enc` in memory only**.
|
||||
3. Builds the guacd connection parameter map:
|
||||
- **SSH:** `hostname`, `port`, `username`, `private-key` (decrypted),
|
||||
`passphrase` (if any).
|
||||
- **RDP:** `hostname`, `port`, `username`, `password`, `security=any`,
|
||||
`ignore-cert=true`.
|
||||
- **VNC:** `hostname`, `port`, `password`.
|
||||
4. Creates a `console_sessions` document, returns a short-lived signed session
|
||||
token.
|
||||
5. Browser opens WebSocket `/api/console/tunnel?token=…`. The `wwt/guac` handler
|
||||
validates the token, dials guacd `:4822`, and pipes bytes in both directions.
|
||||
6. On socket close, the broker sets `ended_at` on the session doc.
|
||||
|
||||
### Security
|
||||
|
||||
- Decrypted private keys and RDP passwords are **never persisted, never logged,
|
||||
never sent to the browser** — passed only to guacd.
|
||||
- Session token: short TTL (~60s to open the WebSocket), single-use,
|
||||
HMAC-signed, bound to the authenticated user.
|
||||
- guacd is bound to the internal docker network only; not exposed publicly.
|
||||
- At-rest encryption (`private_key_enc`, `passphrase_enc`) reuses the existing
|
||||
AES-256 key already used for agent-generated private keys.
|
||||
|
||||
---
|
||||
|
||||
## Windows Agent
|
||||
|
||||
Same Go codebase as the Linux agent, with a reduced role: **register +
|
||||
heartbeat + status only**. No `authorized_keys` management (meaningless on
|
||||
Windows).
|
||||
|
||||
- Build target: `GOOS=windows GOARCH=amd64` → `vantage-agent-windows-amd64.exe`.
|
||||
- Agent detects OS at registration and sends `os_type=windows`.
|
||||
- The key-sync loop is disabled on Windows via a runtime OS check (or build tag)
|
||||
— no `authorized_keys` writes are ever attempted.
|
||||
- Config file: `C:\ProgramData\vantage\config.yaml`, locked down via ACL to the
|
||||
equivalent of `0600`.
|
||||
- Runs as a Windows service via **nssm**.
|
||||
|
||||
---
|
||||
|
||||
## Windows Installer (MSI)
|
||||
|
||||
Agent ships as a WiX v4 MSI produced in CI.
|
||||
|
||||
- **WiX v4** chosen because it is a dotnet tool that builds MSIs
|
||||
**cross-platform** — runs on the Linux Gitea act_runner. (Inno Setup is
|
||||
Windows-only and does not fit the runner.)
|
||||
- MSI bundles `vantage-agent.exe`, installs it to `C:\Program Files\Vantage\`,
|
||||
and registers the nssm service (ships nssm or uses a CustomAction).
|
||||
- Accepts install parameters as MSI properties for silent/headless install:
|
||||
```
|
||||
msiexec /i vantage-agent.msi /qn SERVERID=<id> TOKEN=<token> SERVERURL=vantage..:9090
|
||||
```
|
||||
- GUI install (double-click) prompts for server-id / token / server-url via a
|
||||
dialog.
|
||||
|
||||
### Two install paths
|
||||
|
||||
1. **Installer direct** — user downloads `vantage-agent.msi`, double-clicks,
|
||||
fills the dialog. No script required.
|
||||
2. **PowerShell one-liner** — served dynamically (like the existing bash
|
||||
`/install`). Script downloads the `.msi`, verifies SHA-256, then runs
|
||||
`msiexec /qn` with injected `SERVERID` / `TOKEN` / `SERVERURL`. Used by the
|
||||
copy-paste "Add Server" flow.
|
||||
|
||||
The PowerShell script (`/install.ps1`) steps:
|
||||
1. Detect arch.
|
||||
2. Download `vantage-agent.msi` from the latest Gitea `agent/v*` release.
|
||||
3. Verify SHA-256 against `checksums.txt`.
|
||||
4. Run `msiexec /i vantage-agent.msi /qn SERVERID=.. TOKEN=.. SERVERURL=..`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route | Change |
|
||||
| ------------------------- | ------------------------------------------------------------- |
|
||||
| `/servers` | Show `os_type` badge, enabled console protocols |
|
||||
| `/servers/[id]` | Add **Connect** button(s) per enabled protocol |
|
||||
| `/servers/[id]/console` | New — full-screen console (guacamole-common-js), key picker |
|
||||
| `/servers/new` | Offer Windows (MSI) vs Linux (bash) install instructions |
|
||||
|
||||
Console page: select protocol + SSH key (SSH) or enter RDP credentials, call
|
||||
`/api/console/connect`, open the tunnel WebSocket, mount the Guacamole client.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Changes
|
||||
|
||||
### `agent-release.yml`
|
||||
|
||||
- Add `windows/amd64` build: `vantage-agent-windows-amd64.exe`.
|
||||
- Add WiX v4 MSI build job → `vantage-agent.msi`.
|
||||
- Add both to `checksums.txt` and release assets.
|
||||
|
||||
Release assets become:
|
||||
- `vantage-agent-linux-amd64`
|
||||
- `vantage-agent-linux-arm64`
|
||||
- `vantage-agent-windows-amd64.exe`
|
||||
- `vantage-agent.msi`
|
||||
- `checksums.txt`
|
||||
|
||||
### `server-deploy.yml`
|
||||
|
||||
- Add guacd service to `deploy/docker-compose.yml` (deployed alongside server).
|
||||
|
||||
---
|
||||
|
||||
## New Dependencies
|
||||
|
||||
- **Go:** `github.com/wwt/guac` (Guacamole tunnel/WebSocket in Go).
|
||||
- **Container:** `guacamole/guacd` official image.
|
||||
- **Frontend:** vendored `guacamole-common-js` (no CDN).
|
||||
- **CI:** WiX v4 dotnet tool; nssm binary bundled for the MSI.
|
||||
|
||||
---
|
||||
|
||||
## Open Implementation Notes
|
||||
|
||||
- Confirm `wwt/guac` API surface for connection-parameter passing and token auth
|
||||
binding during implementation.
|
||||
- nssm packaging inside MSI: bundle the nssm binary as a payload + CustomAction,
|
||||
or run `sc.exe`-based service install if nssm proves awkward in WiX.
|
||||
- ACL hardening of `C:\ProgramData\vantage\config.yaml` in the MSI CustomAction.
|
||||
@@ -0,0 +1,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,74 @@
|
||||
# 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) | **shipped**, verified end to end against scratch databases |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start |
|
||||
|
||||
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 not grandfathered.** The migration that would have
|
||||
done it was removed before plan 2 shipped, so every existing cloud instance is
|
||||
read-only until it is licensed by hand through the admin service: attach it to an
|
||||
account with `POST /api/staff/instances`, then `POST /api/staff/instances/:id/issue`.
|
||||
That flow is verified in plan 3, so it works today via the API and is the first
|
||||
job the admin UI is used for.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
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=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
Binary file not shown.
+8
-4
@@ -27,11 +27,13 @@ function Invoke-Native {
|
||||
}
|
||||
}
|
||||
|
||||
# Like Invoke-Native but never throws — for teardown, where a missing/stopped
|
||||
# service must not abort the uninstall.
|
||||
function Invoke-NativeSoft {
|
||||
param([string]$File, [string[]]$Arguments)
|
||||
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
|
||||
# Native stderr merged via 2>&1 becomes terminating errors under
|
||||
# ErrorActionPreference=Stop; force Continue in this scope so a benign nssm
|
||||
# message (e.g. "service has not been started") never aborts setup.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$out = & $File @Arguments 2>&1
|
||||
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
|
||||
Write-Log ("EXIT: {0}" -f $LASTEXITCODE)
|
||||
@@ -121,8 +123,10 @@ tls: true
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1")
|
||||
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576")
|
||||
|
||||
# restart (not just start) so an upgrade picks up the new binary
|
||||
Invoke-NativeSoft -File $nssm -Arguments @("restart", "VantageAgent")
|
||||
# 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
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<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." />
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed."
|
||||
Schedule="afterInstallInitialize" />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<!-- Public properties settable via msiexec: SERVERID, TOKEN, SERVERURL -->
|
||||
|
||||
@@ -9,6 +9,9 @@ service Vantage {
|
||||
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);
|
||||
}
|
||||
@@ -55,6 +58,8 @@ message AgentMessage {
|
||||
oneof payload {
|
||||
AgentReady ready = 3;
|
||||
CommandResult result = 4;
|
||||
StepResult step_result = 5;
|
||||
StepOutputChunk step_output = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +85,80 @@ message ReportUpdatesRequest {
|
||||
|
||||
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 {
|
||||
@@ -89,9 +168,17 @@ message ServerCommand {
|
||||
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;
|
||||
}
|
||||
@@ -108,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
|
||||
|
||||
+64
-7
@@ -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,26 +19,82 @@ 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()
|
||||
@@ -48,14 +105,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start gRPC server
|
||||
go func() {
|
||||
if err := grpcserver.StartGRPC(9090); err != nil {
|
||||
log.Fatalf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start REST server
|
||||
monitorsched.Start(context.Background())
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
|
||||
+13
-9
@@ -8,11 +8,14 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||
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
|
||||
@@ -26,32 +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
|
||||
|
||||
+20
-19
@@ -35,8 +35,6 @@ 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=
|
||||
@@ -45,10 +43,12 @@ 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=
|
||||
@@ -84,8 +84,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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=
|
||||
@@ -94,8 +95,8 @@ 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=
|
||||
@@ -103,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=
|
||||
@@ -112,20 +113,20 @@ 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=
|
||||
@@ -133,16 +134,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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"})
|
||||
}
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
// POST /api/console/connect
|
||||
// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? }
|
||||
// Returns: { session_id, token, ws_path }
|
||||
func consoleConnect(c *gin.Context) {
|
||||
var body struct {
|
||||
ServerID string `json:"server_id" binding:"required"`
|
||||
@@ -29,13 +27,13 @@ func consoleConnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(body.ServerID)
|
||||
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(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
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
|
||||
@@ -47,20 +45,20 @@ func consoleConnect(c *gin.Context) {
|
||||
}
|
||||
|
||||
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
|
||||
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
|
||||
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(sess.SessionID, body.SSHUsername); err != nil {
|
||||
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("console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+")")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -70,8 +68,6 @@ func consoleConnect(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// queryIntDefault reads a positive integer query param, falling back to def
|
||||
// when absent, unparseable, or non-positive.
|
||||
func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
v, err := strconv.Atoi(r.URL.Query().Get(key))
|
||||
if err != nil || v <= 0 {
|
||||
@@ -80,7 +76,6 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// GET /api/console/tunnel?token=... (WebSocket upgrade)
|
||||
func consoleTunnel(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
sessionID, err := services.VerifySessionToken(token)
|
||||
@@ -88,35 +83,32 @@ func consoleTunnel(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
sess, err := services.GetConsoleSession(sessionID)
|
||||
instanceID := auth.InstanceID(c)
|
||||
sess, err := services.GetConsoleSession(instanceID, sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// User-bound: the caller (authenticated via session cookie) must be the same
|
||||
// user who opened the session. Blocks a leaked token being used by someone else.
|
||||
if actor := actorFromCtx(c); actor != sess.User {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Single-use: atomically spend the token so a replay within its TTL is rejected.
|
||||
if err := services.ConsumeSessionToken(sessionID); err != nil {
|
||||
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(sess.ServerID)
|
||||
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt private key + passphrase in-memory only (ssh).
|
||||
var privKey, passphrase string
|
||||
if sess.Protocol == "ssh" && sess.KeyID != "" {
|
||||
privKey, err = services.GetPrivateKey(sess.KeyID)
|
||||
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
|
||||
@@ -126,7 +118,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
|
||||
var rdpUser, rdpPass string
|
||||
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
|
||||
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
|
||||
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
|
||||
return
|
||||
@@ -143,7 +135,6 @@ func consoleTunnel(c *gin.Context) {
|
||||
guacdAddr = "guacd:4822"
|
||||
}
|
||||
|
||||
// Build a guac tunnel config from our params.
|
||||
connect := func(r *http.Request) (guac.Tunnel, error) {
|
||||
config := guac.NewGuacamoleConfiguration()
|
||||
config.Protocol = gp.Protocol
|
||||
@@ -171,7 +162,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
|
||||
wsServer := guac.NewWebsocketServer(connect)
|
||||
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
|
||||
_ = services.EndConsoleSession(sessionID)
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
@@ -25,23 +25,26 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/update", handleUpdateScript)
|
||||
r.GET("/update.ps1", handleUpdateScriptWindows)
|
||||
|
||||
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
|
||||
// External Secrets Operator can call it. Lives under /api (so the reverse
|
||||
// proxy routes it to the backend) but on a distinct subpath to avoid
|
||||
// colliding with the session-authed GET /api/secrets/:group. Returns a
|
||||
// group as flat JSON.
|
||||
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
|
||||
|
||||
// Auth endpoints (no session required)
|
||||
r.GET("/auth/login", auth.HandleLogin)
|
||||
r.GET("/auth/callback", auth.HandleCallback)
|
||||
r.GET("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/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)
|
||||
@@ -56,9 +59,13 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup.GET("/audit", listAuditEvents)
|
||||
|
||||
apiGroup.GET("/settings", getSettings)
|
||||
apiGroup.PUT("/settings", saveSettings)
|
||||
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
|
||||
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)
|
||||
@@ -76,13 +83,28 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||
|
||||
apiGroup.POST("/console/connect", consoleConnect)
|
||||
apiGroup.GET("/console/tunnel", consoleTunnel)
|
||||
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
|
||||
@@ -91,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
|
||||
}
|
||||
@@ -104,21 +129,22 @@ 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("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
|
||||
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`,
|
||||
@@ -140,15 +166,14 @@ func newServer(c *gin.Context) {
|
||||
|
||||
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"`
|
||||
@@ -161,8 +186,8 @@ func getServer(c *gin.Context) {
|
||||
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, _ := services.GetServer(id)
|
||||
if err := services.DeleteServer(id); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -170,7 +195,7 @@ func deleteServer(c *gin.Context) {
|
||||
if s != nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", 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})
|
||||
}
|
||||
|
||||
@@ -189,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
|
||||
@@ -207,7 +232,7 @@ func generateKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
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,
|
||||
@@ -216,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
|
||||
@@ -236,18 +261,18 @@ func createKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
|
||||
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("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
|
||||
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
|
||||
@@ -257,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
|
||||
@@ -277,8 +302,8 @@ func getKey(c *gin.Context) {
|
||||
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
k, _ := services.GetKey(id)
|
||||
if err := services.DeleteKey(id); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -286,7 +311,7 @@ func deleteKey(c *gin.Context) {
|
||||
if k != nil {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", 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})
|
||||
}
|
||||
|
||||
@@ -300,12 +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("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -313,11 +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("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -332,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
|
||||
@@ -343,7 +368,7 @@ func updateAgent(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
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,
|
||||
@@ -352,7 +377,7 @@ func updateAgent(c *gin.Context) {
|
||||
|
||||
func applyUpdates(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
|
||||
@@ -362,7 +387,7 @@ func applyUpdates(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -394,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"
|
||||
|
||||
@@ -429,7 +454,7 @@ func listAuditEvents(c *gin.Context) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
events, err := services.ListAuditEvents(limit)
|
||||
events, err := services.ListAuditEvents(auth.InstanceID(c), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -438,7 +463,7 @@ func listAuditEvents(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getSettings(c *gin.Context) {
|
||||
s, err := services.GetSettings()
|
||||
s, err := services.GetSettings(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -448,18 +473,19 @@ func getSettings(c *gin.Context) {
|
||||
|
||||
func saveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
Email models.EmailSettings `json:"email"`
|
||||
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(body.Alerts, body.Email); err != nil {
|
||||
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("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
}
|
||||
|
||||
@@ -471,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
|
||||
@@ -486,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://}"
|
||||
@@ -510,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"
|
||||
|
||||
@@ -561,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)
|
||||
|
||||
@@ -16,13 +16,8 @@ func handleInstallScriptWindows(c *gin.Context) {
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
if grpcHost == "" {
|
||||
grpcHost = os.Getenv("PUBLIC_HOST")
|
||||
}
|
||||
if grpcHost == "" {
|
||||
grpcHost = "vantage.example.com"
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(
|
||||
"#Requires -RunAsAdministrator\n"+
|
||||
@@ -55,9 +50,6 @@ func handleInstallScriptWindows(c *gin.Context) {
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an
|
||||
// already-installed Windows agent. No server_id/token needed: the MSI is a
|
||||
// MajorUpgrade and setup.ps1 preserves the existing config on upgrade.
|
||||
func handleUpdateScriptWindows(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -7,40 +7,46 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// groupNamePattern restricts group and key names to characters that are safe
|
||||
// in URLs and Kubernetes/env contexts.
|
||||
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func validName(s string) bool {
|
||||
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
|
||||
}
|
||||
|
||||
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
|
||||
const ctxSecretsInstanceKey = "km_secrets_instance"
|
||||
|
||||
func secretsReadAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
const prefix = "Bearer "
|
||||
auth := c.GetHeader("Authorization")
|
||||
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
|
||||
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
|
||||
}
|
||||
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
|
||||
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
|
||||
// (ESO treats 404 as "deleted").
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
values, err := services.GetSecretGroupDecrypted(group)
|
||||
|
||||
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
|
||||
@@ -53,7 +59,7 @@ func esoGetGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func listSecretGroups(c *gin.Context) {
|
||||
groups, err := services.ListSecretGroups()
|
||||
groups, err := services.ListSecretGroups(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -61,8 +67,6 @@ func listSecretGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
|
||||
// be created with at least one key/value pair.
|
||||
func createSecretGroup(c *gin.Context) {
|
||||
var body struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
@@ -86,17 +90,20 @@ func createSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
|
||||
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("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
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(group)
|
||||
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -108,7 +115,6 @@ func getSecretGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
|
||||
}
|
||||
|
||||
// putSecretGroup upserts one or more keys into an existing (or new) group.
|
||||
func putSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if !validName(group) {
|
||||
@@ -130,11 +136,14 @@ func putSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(group, values); err != nil {
|
||||
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("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -147,42 +156,42 @@ func revealSecret(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
value, err := services.RevealSecret(group, body.Key)
|
||||
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("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
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(group, key); err != nil {
|
||||
if err := services.DeleteSecret(auth.InstanceID(c), group, key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
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(group); err != nil {
|
||||
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
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()
|
||||
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
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,8 +42,6 @@ type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
@@ -63,14 +56,85 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
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"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,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 {
|
||||
@@ -91,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{}
|
||||
@@ -105,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
|
||||
@@ -129,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)
|
||||
@@ -153,13 +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
|
||||
}
|
||||
|
||||
@@ -181,17 +268,30 @@ func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesR
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -235,6 +335,30 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
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 {
|
||||
@@ -243,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)
|
||||
}
|
||||
@@ -257,6 +379,9 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
{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{
|
||||
{
|
||||
@@ -329,6 +454,51 @@ func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec fu
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
@@ -25,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 {
|
||||
@@ -61,14 +72,12 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
// Agent-generated keys carry no passphrase over the wire (proto has no field).
|
||||
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -95,8 +104,66 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
|
||||
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)
|
||||
@@ -117,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()
|
||||
@@ -129,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -155,15 +230,13 @@ func StartGRPC(port int) error {
|
||||
}
|
||||
|
||||
s := grpc.NewServer(
|
||||
// Accept client keepalive pings as fast as every 20s so the 30s agent
|
||||
// ping interval is always within the allowed window.
|
||||
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
// Server also pings the client after 45s of inactivity so both
|
||||
// sides can detect a dead connection without waiting for a timeout.
|
||||
|
||||
Time: 45 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
)
|
||||
|
||||
type AuditEvent struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
EventType string `bson:"event_type" json:"event_type"`
|
||||
Actor string `bson:"actor" json:"actor"`
|
||||
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
Details string `bson:"details" json:"details"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
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"`
|
||||
}
|
||||
@@ -7,18 +7,17 @@ import (
|
||||
)
|
||||
|
||||
type ConsoleSession struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
SessionID string `bson:"session_id" json:"session_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
User string `bson:"user" json:"user"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
|
||||
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
|
||||
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 marks the one-time session token as spent. Set atomically
|
||||
// when the tunnel opens; a second open with the same token is rejected.
|
||||
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
|
||||
|
||||
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
|
||||
|
||||
@@ -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,11 +8,12 @@ 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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -6,17 +6,15 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Secret is a single key/value pair within a group. The value is stored
|
||||
// encrypted (AES-256-GCM) and is never serialized to JSON.
|
||||
type Secret struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// GroupSummary describes a group in the list view.
|
||||
type GroupSummary struct {
|
||||
Group string `json:"group"`
|
||||
KeyCount int `json:"key_count"`
|
||||
|
||||
@@ -12,23 +12,56 @@ type PackageUpdate struct {
|
||||
NewVersion string `bson:"new_version" json:"new_version"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
|
||||
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
|
||||
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
|
||||
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
type (
|
||||
Settings = shared.Settings
|
||||
AlertSettings = shared.AlertSettings
|
||||
EmailSettings = shared.EmailSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
type AlertSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
|
||||
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
|
||||
}
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
|
||||
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Password string `bson:"password" json:"password"`
|
||||
FromAddr string `bson:"from_addr" json:"from_addr"`
|
||||
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
|
||||
UseTLS bool `bson:"use_tls" json:"use_tls"`
|
||||
}
|
||||
|
||||
// SecretsSettings holds configuration for the secrets vault / ESO integration.
|
||||
// The read token is stored as a SHA-256 hash and never returned to clients.
|
||||
type SecretsSettings struct {
|
||||
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
|
||||
ReadTokenSet bool `bson:"-" json:"read_token_set"`
|
||||
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Alerts AlertSettings `bson:"alerts" json:"alerts"`
|
||||
Email EmailSettings `bson:"email" json:"email"`
|
||||
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user