Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8cd909828 | ||
|
|
6a3e0a29a4 | ||
|
|
c19c11e6eb | ||
|
|
85f6d47024 | ||
|
|
4175608772 | ||
|
|
24060b2c5a | ||
|
|
cefbac625c | ||
|
|
73efb206ac | ||
|
|
7fea321376 | ||
|
|
b583e9803f | ||
|
|
92ac1eeb62 | ||
|
|
242a587340 | ||
|
|
7a8e683d99 | ||
|
|
3e447fd024 | ||
|
|
6953f5e972 | ||
|
|
55526263a0 | ||
|
|
4bb7400b8e | ||
|
|
79afcc2e16 | ||
|
|
8708bd9498 | ||
|
|
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 |
@@ -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,37 @@ 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"
|
||||
|
||||
- name: Build and push adminsite image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/adminsite:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+4
-2
@@ -2,10 +2,12 @@ node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
docs
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
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,84 @@
|
||||
// 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/joho/godotenv"
|
||||
"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() {
|
||||
godotenv.Load()
|
||||
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,204 @@
|
||||
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
|
||||
}
|
||||
|
||||
// getMe reports who the caller is, for route guards in the UI.
|
||||
//
|
||||
// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
|
||||
// truthful 401 to redirect on, not an error page. It reveals nothing a caller
|
||||
// does not already possess, because it only ever describes their own cookie.
|
||||
func getMe(c *gin.Context) {
|
||||
s := auth.Load(c)
|
||||
if s == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"kind": s.Kind,
|
||||
"email": s.Email,
|
||||
"account_id": s.AccountID,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
// Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
|
||||
// second source of truth for a rule the backend enforces.
|
||||
"max_relinks": models.MaxRelinksPerTerm,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// The owner gets the blob itself: it is signed public data bound to their
|
||||
// own instance, and the download endpoint hands over the same bytes. The
|
||||
// struct tag hides it, so the fields are listed explicitly.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier,
|
||||
"deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features,
|
||||
"issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason,
|
||||
"issued_by": lic.IssuedBy, "blob": lic.Blob,
|
||||
})
|
||||
}
|
||||
|
||||
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,84 @@
|
||||
// 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)
|
||||
r.GET("/auth/me", getMe)
|
||||
r.POST("/auth/signup", auth.HandleSignup)
|
||||
|
||||
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.GET("/instances/:id", staffGetInstance)
|
||||
staff.GET("/subscriptions", staffListSubscriptions)
|
||||
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,450 @@
|
||||
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 != "" {
|
||||
or := []bson.M{
|
||||
{"name": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"paddle_customer_id": q},
|
||||
}
|
||||
// A support email often contains an instance UUID and nothing else, so
|
||||
// resolve that to its owning account rather than returning nothing.
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": q}).Decode(&inst); err == nil {
|
||||
or = append(or, bson.M{"account_id": inst.AccountID})
|
||||
}
|
||||
filter["$or"] = or
|
||||
}
|
||||
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
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &instances)
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &subs)
|
||||
}
|
||||
users := []models.CustomerUser{}
|
||||
if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil {
|
||||
_ = cur.All(ctx, &users)
|
||||
}
|
||||
entries := []models.AuditEntry{}
|
||||
if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID},
|
||||
options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil {
|
||||
_ = cur.All(ctx, &entries)
|
||||
}
|
||||
|
||||
// CustomerUser's password hash and both verify-token fields are json:"-",
|
||||
// so no secret leaves here.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"account": acct,
|
||||
"instances": instances,
|
||||
"subscriptions": subs,
|
||||
"users": users,
|
||||
"audit": entries,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// staffGetInstance is the "why did this stop working" screen's data: one
|
||||
// instance, its account, its whole licence history newest first, and whether
|
||||
// the control plane currently holds what we think it holds.
|
||||
func staffGetInstance(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
var acct models.Account
|
||||
_ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct)
|
||||
|
||||
lics := []models.License{}
|
||||
if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID},
|
||||
options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil {
|
||||
_ = cur.All(ctx, &lics)
|
||||
}
|
||||
|
||||
// Injection state is only meaningful for cloud. For self-hosted the
|
||||
// customer holds the blob and there is nothing for us to have written.
|
||||
injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud}
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
var remote sharedmodels.Instance
|
||||
err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}).Decode(&remote)
|
||||
switch {
|
||||
case err != nil:
|
||||
injection["state"] = "missing"
|
||||
case inst.CurrentLicense == "":
|
||||
injection["state"] = "none_issued"
|
||||
default:
|
||||
var current models.License
|
||||
if db.Admin("licenses").FindOne(ctx,
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil &&
|
||||
remote.LicenseBlob == current.Blob {
|
||||
injection["state"] = "current"
|
||||
} else {
|
||||
injection["state"] = "stale"
|
||||
}
|
||||
}
|
||||
injection["failed_at"] = inst.InjectFailedAt
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance": inst, "account": acct, "licenses": lics, "injection": injection,
|
||||
})
|
||||
}
|
||||
|
||||
// staffListSubscriptions backs the past-due queue on the dashboard.
|
||||
func staffListSubscriptions(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("status"); v != "" {
|
||||
filter["status"] = v
|
||||
}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500))
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter,
|
||||
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,217 @@
|
||||
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
|
||||
}
|
||||
|
||||
if err := mail.SendVerification(u.Email, token); err != nil {
|
||||
// Undo the insert. A row whose verification link was never delivered is
|
||||
// worse than no row: it can never be signed in to, and it holds the
|
||||
// unique index on email, so the customer cannot sign up again with the
|
||||
// address they just used.
|
||||
_, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleSignup creates a self-hosted customer: an account, an unverified user,
|
||||
// and a verification email.
|
||||
//
|
||||
// Nothing is usable until the emailed link is opened, the same rule sitesvc
|
||||
// already proves — so an address nobody controls cannot occupy an email or
|
||||
// produce an account that can sign in.
|
||||
func HandleSignup(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"` // honeypot; real users never fill it
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Honeypot: answer exactly as success so a bot learns nothing.
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"})
|
||||
return
|
||||
}
|
||||
if !allowAttempt("signup:"+email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
// Same response as success. Telling a stranger the address is taken
|
||||
// confirms who has an account here.
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
return
|
||||
}
|
||||
|
||||
acct := models.Account{
|
||||
AccountID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
BillingEmail: email,
|
||||
Status: models.AccountActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil {
|
||||
// Roll the account back rather than strand one with no owner.
|
||||
_, _ = db.Admin("accounts").DeleteOne(ctx, bson.M{"account_id": acct.AccountID})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
// 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,60 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
|
||||
// Load returns the caller's session, or nil. Exported because the session probe
|
||||
// in api/ needs to read a session without requiring one.
|
||||
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,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
next-env.d.ts
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM node:26-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Baked in at build time and must be reachable from the BROWSER, and present in
|
||||
# admin's ADMIN_ORIGIN. Wrong here means every request fails at runtime.
|
||||
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083
|
||||
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_ENV=production
|
||||
ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["subscriptions"],
|
||||
queryFn: api.subscriptions,
|
||||
});
|
||||
|
||||
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Billing</h1>
|
||||
|
||||
{!data || data.length === 0 ? (
|
||||
<p className="text-ink-2">
|
||||
You have no subscriptions. Cloud instances and self-hosted licences are both
|
||||
bought from the pricing page.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Plan</th>
|
||||
<th className="px-4 py-2.5">Term</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(s.current_period_end)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="max-w-xl text-[0.82rem] text-ink-3">
|
||||
To change a card, download an invoice or cancel, email support and we will send you
|
||||
a billing link. Self-service billing arrives with card payments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [relinkError, setRelinkError] = useState<string | undefined>();
|
||||
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const licence = useQuery({
|
||||
queryKey: ["license", id],
|
||||
queryFn: () => api.license(id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: (newId: string) => api.relink(id, newId),
|
||||
onSuccess: (lic) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
router.replace(`/instances/${lic.instance_id}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
});
|
||||
|
||||
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const instance = account.data?.instances.find((i) => i.instance_id === id);
|
||||
if (account.isLoading) return <p className="text-ink-3">Loading…</p>;
|
||||
if (!instance) {
|
||||
// Says "not on your account" rather than "does not exist": the backend
|
||||
// answers 404 for another account's instance, and confirming existence
|
||||
// here would undo that.
|
||||
return <p className="text-ink-2">That instance is not on your account.</p>;
|
||||
}
|
||||
|
||||
const lic = licence.data;
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-3xl">{instance.name}</h1>
|
||||
<StatePill state={state} />
|
||||
</div>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
|
||||
{instance.instance_id}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{lic ? (
|
||||
<>
|
||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
|
||||
<Fact label="Expires" value={formatDate(lic.expires_at)} />
|
||||
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
|
||||
<Fact label="Features" value={lic.features.join(", ") || "none"} />
|
||||
</dl>
|
||||
|
||||
{instance.deployment === "self_hosted" && (
|
||||
<>
|
||||
<LicenceDelivery
|
||||
instanceId={instance.instance_id}
|
||||
blob={lic.blob ?? ""}
|
||||
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
|
||||
/>
|
||||
<RelinkPanel
|
||||
instanceId={instance.instance_id}
|
||||
used={instance.relink_count}
|
||||
max={account.data?.max_relinks ?? 3}
|
||||
error={relinkError}
|
||||
onRelink={(newId) => relink.mutate(newId)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-mono tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) {
|
||||
const [id, setId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = id.trim();
|
||||
|
||||
// Checked here so a typo costs nothing and the message is instant.
|
||||
if (!UUID_RE.test(value)) {
|
||||
setError(
|
||||
"That does not look like an instance ID. It should look like the example below.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const inst = await api.link(value, name.trim());
|
||||
onLinked(inst.instance_id);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not link that instance. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-4" noValidate>
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
error={error}
|
||||
hint={
|
||||
<>
|
||||
Find this on your install’s <code>Settings → Licence</code> page, or on
|
||||
the setup screen just after you first sign in. It looks like{" "}
|
||||
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Name it (optional)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint="So you can tell it apart from your other installs."
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { LinkForm } from "./LinkForm";
|
||||
|
||||
export default function LinkPage() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">Link an install</h1>
|
||||
<p className="text-ink-2">
|
||||
Every licence is tied to one install, so we need its ID before we can issue
|
||||
yours. Paste it below and your licence is ready on the next screen.
|
||||
</p>
|
||||
</header>
|
||||
<LinkForm
|
||||
onLinked={(instanceId) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
// Straight to the download, not back to a list: the licence is
|
||||
// the thing they came for.
|
||||
router.push(`/instances/${instanceId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RequireKind } from "@/lib/session";
|
||||
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RequireKind kind="customer">
|
||||
<nav className="border-b border-rule-soft bg-panel-2">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
|
||||
<Link href="/" className="text-accent">
|
||||
Overview
|
||||
</Link>
|
||||
<Link href="/instances/link" className="text-ink-3">
|
||||
Link an install
|
||||
</Link>
|
||||
<Link href="/billing" className="text-ink-3">
|
||||
Billing
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { InstanceCard } from "@/components/InstanceCard";
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
|
||||
const licences = useQueries({
|
||||
queries: (data?.instances ?? [])
|
||||
.filter((i) => i.current_license)
|
||||
.map((i) => ({
|
||||
queryKey: ["license", i.instance_id],
|
||||
queryFn: () => api.license(i.instance_id),
|
||||
})),
|
||||
});
|
||||
|
||||
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading your account…</p>;
|
||||
|
||||
const byInstance = new Map<string, License>();
|
||||
licences.forEach((q) => {
|
||||
if (q.data) byInstance.set(q.data.instance_id, q.data);
|
||||
});
|
||||
|
||||
const unlinked = data.instances.filter((i) => i.status === "awaiting_link");
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">{data.account.name}</h1>
|
||||
<p className="text-ink-2">{data.account.billing_email}</p>
|
||||
</header>
|
||||
|
||||
{unlinked.length > 0 && (
|
||||
<div className="rounded border border-accent bg-accent-wash p-4">
|
||||
<h2 className="text-xl">Finish setting up your licence</h2>
|
||||
<p className="mt-1 text-[0.82rem] text-ink-2">
|
||||
{unlinked.length === 1
|
||||
? "One purchase is"
|
||||
: `${unlinked.length} purchases are`}{" "}
|
||||
not attached to an install yet, so no licence has been issued for{" "}
|
||||
{unlinked.length === 1 ? "it" : "them"}.
|
||||
</p>
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.instances.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
There are two ways to run Vantage. Buy a cloud instance and we host it, and
|
||||
your licence is applied automatically. Or buy a self-hosted licence, install
|
||||
Vantage on your own server, and link it here to get your licence file.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.instances.map((i) => (
|
||||
<InstanceCard
|
||||
key={i.instance_id}
|
||||
instance={i}
|
||||
license={byInstance.get(i.instance_id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Field } from "@/components/Field";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export function AccountSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["staff-accounts", q],
|
||||
queryFn: () => api.staff.accounts(q || undefined),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Field
|
||||
label="Search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
hint="Name, email, Paddle customer ID, or an instance UUID."
|
||||
/>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Account</th>
|
||||
<th className="px-4 py-2.5">Billing email</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((a) => (
|
||||
<tr
|
||||
key={a.account_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/accounts/${a.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[0.82rem]">
|
||||
{a.billing_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">{a.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(a.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isFetching && (data ?? []).length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">
|
||||
No account matches that. Try the instance UUID from the customer’s
|
||||
email.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const id = String(useParams().id);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-account", id],
|
||||
queryFn: () => api.staff.account(id),
|
||||
});
|
||||
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-1">
|
||||
<h1 className="text-3xl">{data.account.name}</h1>
|
||||
<p className="font-mono text-[0.82rem] text-ink-3">
|
||||
{data.account.billing_email} · {data.account.account_id}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Panel title="Instances">
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link
|
||||
href={`/staff/instances/${i.instance_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{i.deployment} · {i.tier ?? "no tier"} · {i.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.instances.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Subscriptions">
|
||||
<ul className="grid gap-2">
|
||||
{data.subscriptions.map((s) => (
|
||||
<li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span>
|
||||
{s.tier.replace("_", " ")} · {s.term}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{s.status} · renews {formatDate(s.current_period_end)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<Panel title="People">
|
||||
<ul className="grid gap-2">
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{u.verified_at
|
||||
? `verified ${formatDate(u.verified_at)}`
|
||||
: "not verified"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && (
|
||||
<li className="text-ink-3">
|
||||
None — this is a cloud account, so its people sign in with their
|
||||
control-plane details.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Audit">
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{data.audit.map((e, n) => (
|
||||
<li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AccountSearch } from "./AccountSearch";
|
||||
|
||||
export default function AccountsPage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Accounts</h1>
|
||||
<AccountSearch />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate, formatStamp } from "@/lib/format";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function AuditPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
|
||||
|
||||
const rows = (data ?? []).filter((e) =>
|
||||
filter
|
||||
? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Audit</h1>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
<li
|
||||
key={n}
|
||||
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
|
||||
>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)} {formatStamp(e.created_at)}
|
||||
</span>
|
||||
<span className="text-ink-2">
|
||||
<b className="text-ink">{e.action}</b> · {e.actor}
|
||||
{e.target && ` · ${e.target}`}
|
||||
{e.detail && ` · ${e.detail}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
deployment,
|
||||
}: {
|
||||
instanceId: string;
|
||||
deployment: string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
|
||||
const [term, setTerm] = useState("annual");
|
||||
const [newId, setNewId] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["staff-instance", instanceId] });
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.staff.issue(instanceId, { tier, term, reason: "manual" }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Issue failed."),
|
||||
});
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: () => api.staff.relink(instanceId, newId.trim()),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Relink failed."),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 border-t border-rule-soft pt-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Tier
|
||||
</span>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="self_hosted">Self Hosted</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Term
|
||||
</span>
|
||||
<select
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button type="button" onClick={() => issue.mutate()} disabled={issue.isPending}>
|
||||
{issue.isPending ? "Issuing…" : "Issue licence"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Relink to instance ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => relink.mutate()}
|
||||
disabled={!newId.trim()}
|
||||
>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.82rem] text-expired">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { api, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
stale: {
|
||||
label: "Control plane holds an older blob — the reconciler will repair it",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
none_issued: { label: "Nothing issued yet, so nothing to inject", tone: "text-ink-3" },
|
||||
};
|
||||
|
||||
export default function StaffInstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-instance", id],
|
||||
queryFn: () => api.staff.instance(id),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
|
||||
{data.instance.instance_id}
|
||||
</p>
|
||||
<p className="text-[0.82rem]">
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</p>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RequireKind } from "@/lib/session";
|
||||
|
||||
const LINKS = [
|
||||
["/staff", "Operations"],
|
||||
["/staff/accounts", "Accounts"],
|
||||
["/staff/licenses", "Licences"],
|
||||
["/staff/plans", "Plans"],
|
||||
["/staff/audit", "Audit"],
|
||||
] as const;
|
||||
|
||||
export default function StaffLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RequireKind kind="staff">
|
||||
<nav className="border-b border-rule-soft bg-panel-2">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
|
||||
{LINKS.map(([href, label]) => (
|
||||
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api, type Tier } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tier, setTier] = useState<"" | Tier>("");
|
||||
const [reason, setReason] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
|
||||
|
||||
// Filtered here rather than server-side: the endpoint caps at 500 rows and
|
||||
// staff are narrowing a list they can already see.
|
||||
const rows = (data ?? []).filter(
|
||||
(l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Licences</h1>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier | "")}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by tier"
|
||||
>
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="self_hosted">Self Hosted</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by reason"
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Issued</th>
|
||||
<th className="px-4 py-2.5">Instance</th>
|
||||
<th className="px-4 py-2.5">Tier</th>
|
||||
<th className="px-4 py-2.5">Reason</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => (
|
||||
<tr
|
||||
key={l.license_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/instances/${l.instance_id}`}
|
||||
className="font-mono text-[0.82rem] text-accent underline"
|
||||
>
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.expires_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-3">
|
||||
{l.superseded_by ? "superseded" : "current"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { API_BASE, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { daysRemaining } from "@/lib/format";
|
||||
|
||||
const HOURS_48 = 48 * 3600_000;
|
||||
|
||||
export default function StaffDashboard() {
|
||||
const injection = useQuery({ queryKey: ["injection"], queryFn: api.staff.injectionHealth });
|
||||
const expiring = useQuery({
|
||||
queryKey: ["instances", "expiring"],
|
||||
queryFn: () => api.staff.instances({ expiring: "true" }),
|
||||
});
|
||||
const pastDue = useQuery({
|
||||
queryKey: ["subs", "past_due"],
|
||||
queryFn: () => api.staff.subscriptions("past_due"),
|
||||
});
|
||||
const unlinked = useQuery({
|
||||
queryKey: ["instances", "awaiting_link"],
|
||||
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
|
||||
});
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const stale = (unlinked.data ?? []).filter(
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Operations</h1>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Queue
|
||||
title="Failed injections"
|
||||
tone="expired"
|
||||
count={injection.data?.count ?? 0}
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.inject_failed_at
|
||||
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
|
||||
: "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Expiring ≤ 14 days"
|
||||
tone="warn"
|
||||
count={expiring.data?.length ?? 0}
|
||||
items={(expiring.data ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.tier ?? "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Past due"
|
||||
tone="expired"
|
||||
count={pastDue.data?.length ?? 0}
|
||||
items={(pastDue.data ?? []).slice(0, 4).map((s) => ({
|
||||
label: s.instance_id || s.account_id,
|
||||
href: `/staff/accounts/${s.account_id}`,
|
||||
meta: `${daysRemaining(s.current_period_end)}d`,
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
title="Unlinked > 48h"
|
||||
tone="accent"
|
||||
count={stale.length}
|
||||
items={stale.slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/accounts/${i.account_id}`,
|
||||
meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Plan } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
|
||||
export default function PlansPage() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
|
||||
const [draft, setDraft] = useState<Plan | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (p: Plan) =>
|
||||
api.staff.updatePlan(p.tier, {
|
||||
name: p.name,
|
||||
limits: p.limits,
|
||||
features: p.features,
|
||||
paddle_product_id: p.paddle_product_id,
|
||||
paddle_price_ids: p.paddle_price_ids,
|
||||
active: p.active,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setDraft(null);
|
||||
},
|
||||
});
|
||||
|
||||
const original = plans.data?.find((p) => p.tier === draft?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Plans</h1>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier).length}
|
||||
onConfirm={() => save.mutate(draft)}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{(plans.data ?? []).map((p) => (
|
||||
<section
|
||||
key={p.tier}
|
||||
className="grid gap-3 rounded border border-rule bg-panel p-5"
|
||||
>
|
||||
<h2 className="text-xl">{p.name}</h2>
|
||||
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>servers</dt>
|
||||
<dd>{limitLabel(p.limits.max_servers)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>secret groups</dt>
|
||||
<dd>{limitLabel(p.limits.max_secret_groups)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>channels</dt>
|
||||
<dd>{limitLabel(p.limits.max_channels)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>features</dt>
|
||||
<dd>{p.features.join(", ") || "none"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Guard rail two: deployment is shown, never edited. */}
|
||||
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span>
|
||||
Deployment is fixed at{" "}
|
||||
<b className="font-mono">{p.deployment}</b>. Moving a tier between
|
||||
cloud and self-hosted is a code change, not a form field.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({ ...p, limits: { ...p.limits, max_servers: 7 } })
|
||||
}
|
||||
>
|
||||
Cap servers at 7
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...p,
|
||||
features: p.features.filter((f) => f !== "oidc"),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove OIDC
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ==========================================================================
|
||||
Vantage admin console design tokens.
|
||||
|
||||
Lines 8-97 below are site/app/globals.css's token blocks, copied verbatim:
|
||||
the marketing site and this console are one visual system. Change them in
|
||||
both apps in the same commit — nothing enforces the match automatically.
|
||||
|
||||
Light is the default because web/ is locked to dark, and telling the two
|
||||
apart at a glance is what stops a Reissue landing in the wrong tab. In dark
|
||||
mode the accent lifts to #5b9be8, which is nearer web/'s indigo, so the
|
||||
distinction leans on the ground rather than the hue.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
|
||||
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
|
||||
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
|
||||
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
|
||||
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
|
||||
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
|
||||
|
||||
--rail: 1200px;
|
||||
}
|
||||
|
||||
/* Dark tokens are defined once and applied through three selectors: the OS
|
||||
preference, and both explicit values of data-theme so the in-page toggle
|
||||
wins in either direction. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #071628;
|
||||
--panel: #0d2138;
|
||||
--panel-2: #102842;
|
||||
--ink: #e4ecf6;
|
||||
--ink-2: #9fb3ca;
|
||||
--ink-3: #71879f;
|
||||
--rule: #1e3855;
|
||||
--rule-soft: #172c44;
|
||||
--accent: #5b9be8;
|
||||
--accent-ink: #04101f;
|
||||
--up: #4fb484;
|
||||
--down: #e2705a;
|
||||
--pend: #d6a63f;
|
||||
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
|
||||
--logo: #7fb2f0;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--ground: #eaedf3;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f4f6fa;
|
||||
--ink: #0a1b33;
|
||||
--ink-2: #41556f;
|
||||
--ink-3: #6c7f96;
|
||||
--rule: #cdd6e2;
|
||||
--rule-soft: #e0e6ef;
|
||||
--accent: #0b2a58;
|
||||
--accent-ink: #ffffff;
|
||||
--up: #2f8a60;
|
||||
--down: #c6462f;
|
||||
--pend: #b0801f;
|
||||
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
|
||||
--logo: #0b2a58;
|
||||
}
|
||||
|
||||
/* Not in site/: the hatched sandbox badge and hover washes need a tinted fill,
|
||||
and deriving it at each use would drift. */
|
||||
:root {
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--accent-wash: rgba(91, 155, 232, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--accent-wash: rgba(11, 42, 88, 0.07);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: var(--s-0);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* site/'s heading treatment, which is what replaces a display face. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
line-height: 1.03;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { EnvBadge } from "@/components/EnvBadge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vantage Licensing",
|
||||
description: "Licences, instances and billing for Vantage.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<header className="border-b border-rule bg-panel">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
Licensing
|
||||
</span>
|
||||
</span>
|
||||
<EnvBadge />
|
||||
</div>
|
||||
</header>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [staff, setStaff] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offline, setOffline] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const s = staff
|
||||
? await api.staffLogin(email, password)
|
||||
: await api.login(email, password);
|
||||
router.replace(s.kind === "staff" ? "/staff" : "/");
|
||||
} catch (err) {
|
||||
if (err instanceof NotConnected) setOffline(true);
|
||||
else if (err instanceof ApiError) setError(err.message);
|
||||
else setError("Sign in failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (offline)
|
||||
return (
|
||||
<Main>
|
||||
<NotConnectedPanel url={API_BASE} />
|
||||
</Main>
|
||||
);
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="text-3xl">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<Link href="/signup" className="text-[0.82rem] text-accent underline">
|
||||
Create an account for a self-hosted licence
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<h1 className="text-3xl">Nothing here</h1>
|
||||
<p className="mt-2 text-ink-2">
|
||||
That page does not exist, or it belongs to an account you are not signed in to.
|
||||
</p>
|
||||
<Link href="/" className="mt-4 inline-block text-accent underline">
|
||||
Back to your account
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
|
||||
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState("busy");
|
||||
setError(null);
|
||||
try {
|
||||
await api.signup(form);
|
||||
setState("sent");
|
||||
} catch (err) {
|
||||
setState("idle");
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not create the account. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Check your email</h1>
|
||||
<p className="text-ink-2">
|
||||
We sent a link to {form.email}. Open it to finish setting up your account —
|
||||
it expires in 24 hours. Nothing is created until you do.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl">Create an account</h1>
|
||||
<p className="mt-2 max-w-xl text-ink-2">
|
||||
For self-hosted licences. If you run on our cloud, sign in with the same
|
||||
details you use for your Vantage instance.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Organisation"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="absolute left-[-9999px] h-0 w-0"
|
||||
/>
|
||||
<Button type="submit" disabled={state === "busy"}>
|
||||
{state === "busy" ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
function Verify() {
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["verify", token],
|
||||
queryFn: () => api.verify(token),
|
||||
enabled: token !== "",
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!token)
|
||||
return (
|
||||
<Message
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the email exactly as sent."
|
||||
/>
|
||||
);
|
||||
if (isLoading) return <Message title="Verifying…" body="One moment." />;
|
||||
if (error || !data?.verified)
|
||||
return (
|
||||
<Message
|
||||
title="That link is invalid or has expired"
|
||||
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Email verified</h1>
|
||||
<p className="text-ink-2">Your account is ready.</p>
|
||||
<Link href="/login" className="justify-self-start text-accent underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">{title}</h1>
|
||||
<p className="text-ink-2">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<Suspense fallback={null}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
|
||||
|
||||
/*
|
||||
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
|
||||
* border on the secondary variant. site/ does not have an accent-outlined
|
||||
* button and this app should not invent one.
|
||||
*/
|
||||
export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Plan } from "@/lib/api";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* Editing a plan changes what every future customer gets, so the confirmation
|
||||
* names each field rather than asking "are you sure". Existing licences
|
||||
* snapshotted their plan at issue time and are genuinely unaffected — saying so
|
||||
* is what stops a well-meaning edit being followed by a panicked reissue.
|
||||
*/
|
||||
export function ConfirmPlanChange({
|
||||
plan,
|
||||
next,
|
||||
issuedCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
plan: Plan;
|
||||
next: Plan;
|
||||
issuedCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
if (plan.limits.max_servers !== next.limits.max_servers)
|
||||
rows.push({
|
||||
field: "max_servers",
|
||||
was: limitLabel(plan.limits.max_servers),
|
||||
now: limitLabel(next.limits.max_servers),
|
||||
});
|
||||
if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
|
||||
rows.push({
|
||||
field: "max_secret_groups",
|
||||
was: limitLabel(plan.limits.max_secret_groups),
|
||||
now: limitLabel(next.limits.max_secret_groups),
|
||||
});
|
||||
if (plan.limits.max_channels !== next.limits.max_channels)
|
||||
rows.push({
|
||||
field: "max_channels",
|
||||
was: limitLabel(plan.limits.max_channels),
|
||||
now: limitLabel(next.limits.max_channels),
|
||||
});
|
||||
if (plan.features.join(",") !== next.features.join(","))
|
||||
rows.push({
|
||||
field: "features",
|
||||
was: plan.features.join(", ") || "none",
|
||||
now: next.features.join(", ") || "none",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
|
||||
<h2 className="text-xl">Change what {plan.name} grants?</h2>
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{rows.map((r) => (
|
||||
<li key={r.field} className="flex flex-wrap gap-2">
|
||||
<span className="text-ink-3">{r.field}</span>
|
||||
<span className="text-ink-3 line-through">{r.was}</span>
|
||||
<span className="font-semibold text-ink">→ {r.now}</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
|
||||
</ul>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
This applies to licences issued from now on. The {issuedCount} licences already
|
||||
issued keep what they were signed with until each is reissued.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
</Button>
|
||||
<Button type="button" variant="line" onClick={onCancel}>
|
||||
Keep as is
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Sandbox is hatched as well as coloured, so it survives a colourblind reader
|
||||
* and a glance. It sits in the same place on every screen: issuing against the
|
||||
* wrong environment should feel wrong before you click.
|
||||
*/
|
||||
const ENV = process.env.NEXT_PUBLIC_ADMIN_ENV === "sandbox" ? "sandbox" : "production";
|
||||
|
||||
export function EnvBadge() {
|
||||
const sandbox = ENV === "sandbox";
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
sandbox
|
||||
? "inline-flex items-center gap-2 rounded-sm border border-warn px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink [background-image:repeating-linear-gradient(-45deg,var(--accent-wash)_0_6px,transparent_6px_12px)]"
|
||||
: "inline-flex items-center gap-2 rounded-sm bg-accent px-2 py-1 font-mono text-[0.72rem] uppercase tracking-[0.1em] text-accent-ink"
|
||||
}
|
||||
>
|
||||
<i className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
|
||||
{sandbox ? "Sandbox" : "Production"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
...input
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
hint?: React.ReactNode;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid max-w-md gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
{...input}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="text-[0.82rem] text-expired">{error}</span>
|
||||
) : hint ? (
|
||||
<span className="text-[0.82rem] text-ink-3">{hint}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import type { Instance, License } from "@/lib/api";
|
||||
import { daysRemaining, formatDate, licenceState } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
|
||||
const STRIPE = {
|
||||
valid: "before:bg-valid",
|
||||
warn: "before:bg-warn",
|
||||
expired: "before:bg-expired",
|
||||
none: "before:bg-accent",
|
||||
} as const;
|
||||
|
||||
export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) {
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
"relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
|
||||
STRIPE[state],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{cloud ? "Cloud" : "Self-hosted"}
|
||||
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatePill state={state} />
|
||||
</div>
|
||||
|
||||
{state === "expired" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Servers and monitors are still running, and your agents keep their keys. Changes
|
||||
are disabled until you renew.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === "none" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
You have paid for this but it is not attached to an install yet, so no licence
|
||||
has been issued. Linking takes a minute.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{ width: `${Math.max(2, Math.min(100, (days / 365) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "none" ? (
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
) : cloud && instance.slug ? (
|
||||
<a
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Open {instance.slug}.vantage.hostxtra.co.uk
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
{state === "expired" ? "Renew and download" : "Licence and download"}
|
||||
</Link>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import clsx from "clsx";
|
||||
import type { License } from "@/lib/api";
|
||||
import { formatDate, formatStamp, limitLabel } from "@/lib/format";
|
||||
|
||||
const REASON: Record<License["reason"], string> = {
|
||||
new: "New",
|
||||
renewal: "Renewal",
|
||||
tier_change: "Tier change",
|
||||
relink: "Relink",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="grid">
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
{dead && (
|
||||
<span className="-rotate-2 rounded-sm border-2 border-archival px-1.5 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.18em] text-archival opacity-75">
|
||||
Superseded
|
||||
</span>
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
blob: string;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(blob);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your
|
||||
install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
|
||||
away — no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<h2 className="text-xl">Your licence</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={`vantage-${instanceId}.lic`}
|
||||
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
|
||||
>
|
||||
Download licence
|
||||
</a>
|
||||
<Button variant="line" type="button" onClick={copy}>
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* The deployment failure this repo makes most often, made legible. It names the
|
||||
* variable, the value baked in, and both reasons it fails — unreachable from
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
The value is baked in when the image is built and has to be reachable from your
|
||||
browser, not just from the server. It also has to appear in the licensing
|
||||
service’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
const TONE = {
|
||||
expired: "border-l-expired text-expired",
|
||||
warn: "border-l-warn text-warn",
|
||||
accent: "border-l-accent text-accent",
|
||||
} as const;
|
||||
|
||||
export function Queue({
|
||||
title,
|
||||
count,
|
||||
tone,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
tone: keyof typeof TONE;
|
||||
items: { label: string; href: string; meta: string }[];
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"grid gap-2 rounded border border-l-4 border-rule bg-panel p-4",
|
||||
TONE[tone],
|
||||
)}
|
||||
>
|
||||
<h2 className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.1em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-3xl font-extrabold leading-none tabular-nums tracking-[-0.03em]">
|
||||
{count}
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[0.72rem] text-ink-3">Nothing to do here.</p>
|
||||
) : (
|
||||
<ul className="grid gap-1">
|
||||
{items.map((i) => (
|
||||
<li
|
||||
key={i.href}
|
||||
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
>
|
||||
<Link href={i.href} className="text-accent underline">
|
||||
{i.label}
|
||||
</Link>
|
||||
<span className="tabular-nums">{i.meta}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function RelinkPanel({
|
||||
used,
|
||||
max,
|
||||
onRelink,
|
||||
error,
|
||||
}: {
|
||||
instanceId: string;
|
||||
used: number;
|
||||
max: number;
|
||||
onRelink: (newId: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = Math.max(0, max - used);
|
||||
const exhausted = remaining === 0;
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
</p>
|
||||
{open && !exhausted && (
|
||||
<Field
|
||||
label="New instance ID"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error}
|
||||
hint="From Settings → Licence on the new install."
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
Relink to a new install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted
|
||||
? "You have used every relink for this term — contact support and we will sort it out."
|
||||
: `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import clsx from "clsx";
|
||||
import type { LicenceState } from "@/lib/format";
|
||||
|
||||
const LABEL: Record<LicenceState, string> = {
|
||||
valid: "Valid",
|
||||
warn: "Expiring",
|
||||
expired: "Expired",
|
||||
none: "Awaiting link",
|
||||
};
|
||||
|
||||
/*
|
||||
* State reads three ways: this pill's colour, the pill's SHAPE, and the label.
|
||||
* Colour alone would fail a colourblind reader on the one screen where getting
|
||||
* it wrong costs money.
|
||||
*/
|
||||
const SHAPE: Record<LicenceState, string> = {
|
||||
valid: "rounded-full",
|
||||
warn: "[clip-path:polygon(50%_0,100%_100%,0_100%)]",
|
||||
expired: "[clip-path:polygon(20%_0,80%_0,100%_20%,100%_80%,80%_100%,20%_100%,0_80%,0_20%)]",
|
||||
none: "rounded-none",
|
||||
};
|
||||
|
||||
const TONE: Record<LicenceState, string> = {
|
||||
valid: "border-valid text-valid",
|
||||
warn: "border-warn text-warn",
|
||||
expired: "border-expired text-expired",
|
||||
none: "border-accent text-accent",
|
||||
};
|
||||
|
||||
export function StatePill({ state }: { state: LicenceState }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-sm border bg-panel px-2 py-0.5 font-mono text-[0.72rem] uppercase tracking-[0.08em]",
|
||||
TONE[state],
|
||||
)}
|
||||
>
|
||||
<i className={clsx("h-1.5 w-1.5 shrink-0 bg-current", SHAPE[state])} />
|
||||
{LABEL[state]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* The typed client for the licensing service.
|
||||
*
|
||||
* The browser calls admin directly, so every request carries credentials and
|
||||
* every failure mode is one of three: the API is unreachable (NotConnected),
|
||||
* the caller is not signed in (ApiError 401, which layouts redirect on), or the
|
||||
* request was refused (ApiError with the backend's own message, which is
|
||||
* customer-facing and should be shown verbatim).
|
||||
*/
|
||||
|
||||
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export class NotConnected extends Error {
|
||||
constructor() {
|
||||
super("not connected");
|
||||
this.name = "NotConnected";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (!API_BASE) throw new NotConnected();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
} catch {
|
||||
// Network-level failure, DNS, or a CORS preflight the browser refused.
|
||||
throw new NotConnected();
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
const post = <T,>(path: string, payload?: unknown) =>
|
||||
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
|
||||
|
||||
// --- types ---------------------------------------------------------------
|
||||
|
||||
export type Deployment = "cloud" | "self_hosted";
|
||||
export type Tier = "free" | "professional" | "self_hosted";
|
||||
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
|
||||
|
||||
export interface Session {
|
||||
kind: "staff" | "customer";
|
||||
email: string;
|
||||
account_id?: string;
|
||||
}
|
||||
|
||||
export interface Limits {
|
||||
max_servers: number;
|
||||
max_secret_groups: number;
|
||||
max_channels: number;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
account_id: string;
|
||||
name: string;
|
||||
billing_email: string;
|
||||
paddle_customer_id?: string;
|
||||
status: "active" | "suspended";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
instance_id: string;
|
||||
account_id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
deployment: Deployment;
|
||||
tier?: Tier;
|
||||
status: InstanceStatus;
|
||||
current_license?: string;
|
||||
relink_count: number;
|
||||
inject_failed_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface License {
|
||||
license_id: string;
|
||||
instance_id: string;
|
||||
account_id: string;
|
||||
tier: Tier;
|
||||
deployment: Deployment;
|
||||
limits: Limits;
|
||||
features: string[];
|
||||
issued_at: string;
|
||||
expires_at: string;
|
||||
superseded_by?: string;
|
||||
issued_by: string;
|
||||
reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
subscription_id: string;
|
||||
account_id: string;
|
||||
instance_id?: string;
|
||||
tier: Tier;
|
||||
term: string;
|
||||
status: string;
|
||||
current_period_end: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
tier: Tier;
|
||||
name: string;
|
||||
deployment: Deployment;
|
||||
limits: Limits;
|
||||
features: string[];
|
||||
paddle_product_id?: string;
|
||||
paddle_price_ids?: Record<string, string>;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerUser {
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
email: string;
|
||||
verified_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
actor: string;
|
||||
action: string;
|
||||
account_id?: string;
|
||||
target?: string;
|
||||
detail?: string;
|
||||
ip?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AccountResponse {
|
||||
account: Account;
|
||||
instances: Instance[];
|
||||
max_relinks: number;
|
||||
}
|
||||
|
||||
export interface StaffAccountResponse {
|
||||
account: Account;
|
||||
instances: Instance[];
|
||||
subscriptions: Subscription[];
|
||||
users: CustomerUser[];
|
||||
audit: AuditEntry[];
|
||||
}
|
||||
|
||||
export type InjectionState = "current" | "stale" | "missing" | "none_issued";
|
||||
|
||||
export interface StaffInstanceResponse {
|
||||
instance: Instance;
|
||||
account: Account;
|
||||
licenses: License[];
|
||||
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
|
||||
}
|
||||
|
||||
// --- calls ---------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
me: () => req<Session>("/auth/me"),
|
||||
login: (email: string, password: string) => post<Session>("/auth/login", { email, password }),
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/auth/staff/login", { email, password }),
|
||||
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
|
||||
|
||||
account: () => req<AccountResponse>("/api/account"),
|
||||
link: (instance_id: string, name: string) =>
|
||||
post<Instance>("/api/instances/link", { instance_id, name }),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/instances/${id}/relink`, { instance_id }),
|
||||
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
|
||||
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
|
||||
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
|
||||
|
||||
staff: {
|
||||
accounts: (q?: string) =>
|
||||
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
account: (id: string) => req<StaffAccountResponse>(`/api/staff/accounts/${id}`),
|
||||
instances: (params?: Record<string, string>) =>
|
||||
req<Instance[]>(
|
||||
`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`,
|
||||
),
|
||||
instance: (id: string) => req<StaffInstanceResponse>(`/api/staff/instances/${id}`),
|
||||
issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
|
||||
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
|
||||
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(plan),
|
||||
}),
|
||||
audit: (accountId?: string) =>
|
||||
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
|
||||
injectionHealth: () =>
|
||||
req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
|
||||
subscriptions: (status?: string) =>
|
||||
req<Subscription[]>(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export type LicenceState = "valid" | "warn" | "expired" | "none";
|
||||
|
||||
/** Amber inside 14 days, matching the window staff chase renewals on. */
|
||||
export const EXPIRY_WARNING_DAYS = 14;
|
||||
|
||||
export function daysRemaining(iso: string): number {
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState {
|
||||
if (!hasLicence || !expiresAt) return "none";
|
||||
const days = daysRemaining(expiresAt);
|
||||
if (days <= 0) return "expired";
|
||||
if (days <= EXPIRY_WARNING_DAYS) return "warn";
|
||||
return "valid";
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatStamp(iso: string): string {
|
||||
return `${new Date(iso).toISOString().slice(11, 19)} UTC`;
|
||||
}
|
||||
|
||||
export function limitLabel(n: number): string {
|
||||
return n === -1 ? "unlimited" : String(n);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ApiError, NotConnected } from "./api";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
// Retrying a 401 or a missing API URL just delays the redirect and
|
||||
// the not-connected panel.
|
||||
retry: (count, error) =>
|
||||
error instanceof NotConnected || error instanceof ApiError ? false : count < 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api, type Session } from "./api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
|
||||
export function useSession() {
|
||||
const { data, error, isLoading } = useQuery<Session>({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return { session: data, error, isLoading };
|
||||
}
|
||||
|
||||
/*
|
||||
* The route-group guard. This is UX, not security: admin enforces the same
|
||||
* boundary with RequireStaff/RequireCustomer and returns 404 rather than 403
|
||||
* for another account's data. A customer hitting a staff route is redirected
|
||||
* rather than shown a refusal, because there is nothing to tell them about.
|
||||
*/
|
||||
export function RequireKind({
|
||||
kind,
|
||||
children,
|
||||
}: {
|
||||
kind: Session["kind"];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { session, error, isLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (session && session.kind !== kind) {
|
||||
router.replace(session.kind === "staff" ? "/staff" : "/");
|
||||
}
|
||||
}, [error, session, kind, router]);
|
||||
|
||||
if (error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading || !session || session.kind !== kind) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/*
|
||||
* Unlike web/, this app does NOT proxy /api through a rewrite. The browser
|
||||
* calls admin directly, so NEXT_PUBLIC_ADMIN_API_URL must be reachable from the
|
||||
* browser and must appear in admin's ADMIN_ORIGIN. lib/api.ts renders an
|
||||
* explicit not-connected state when it is not.
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6868
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "vantage-adminsite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
/*
|
||||
* Tokens are shared with site/ — same names, same values, copied verbatim into
|
||||
* app/globals.css. Nothing here may hold a hex value: if a colour needs to
|
||||
* change it changes in globals.css, in both apps, in one commit.
|
||||
*
|
||||
* The semantic three are aliased rather than renamed. site/ calls them up,
|
||||
* down and pend because it shows monitor state; this app calls them valid,
|
||||
* expired and warn because it shows licence state. Same colours, honest names
|
||||
* on both sides.
|
||||
*/
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ground: "var(--ground)",
|
||||
panel: "var(--panel)",
|
||||
"panel-2": "var(--panel-2)",
|
||||
ink: "var(--ink)",
|
||||
"ink-2": "var(--ink-2)",
|
||||
"ink-3": "var(--ink-3)",
|
||||
rule: "var(--rule)",
|
||||
"rule-soft": "var(--rule-soft)",
|
||||
accent: "var(--accent)",
|
||||
"accent-ink": "var(--accent-ink)",
|
||||
"accent-wash": "var(--accent-wash)",
|
||||
valid: "var(--up)",
|
||||
warn: "var(--pend)",
|
||||
expired: "var(--down)",
|
||||
archival: "var(--ink-3)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
|
||||
mono: ["ui-monospace", "Cascadia Mono", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"],
|
||||
},
|
||||
// site/ uses 4px on panels and buttons, 2px on focus rings.
|
||||
borderRadius: { DEFAULT: "4px" },
|
||||
maxWidth: { rail: "1200px" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
|
||||
// Result. It has no dependency on models or pb so it can be duplicated verbatim
|
||||
// into the agent module (agent-run monitors) — callers map their own monitor
|
||||
// representation onto Spec.
|
||||
package checker
|
||||
|
||||
import (
|
||||
@@ -16,7 +12,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check types (mirror models.Monitor* constants).
|
||||
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
@@ -24,7 +20,7 @@ const (
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
// Spec is a self-contained description of a single check.
|
||||
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
@@ -34,10 +30,11 @@ type Spec struct {
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
// Result is the uniform outcome of running a check.
|
||||
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
@@ -53,7 +50,7 @@ func (s Spec) timeout() time.Duration {
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
// Run executes the check described by s.
|
||||
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
@@ -79,6 +76,9 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
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 {
|
||||
@@ -156,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result {
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
// runICMP sends a single ICMP echo request and waits for the reply. Requires
|
||||
// raw-socket privileges (the agent and server run as root). Returns down with a
|
||||
// descriptive message when the socket cannot be opened or no reply arrives.
|
||||
|
||||
|
||||
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
@@ -188,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
|
||||
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 { // ICMP echo reply type
|
||||
if reply[20] == 0 {
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
|
||||
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+18
-18
@@ -14,9 +14,9 @@ import (
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
|
||||
// Stdout and Stderr so output interleaves in real execution order. The mutex
|
||||
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
|
||||
|
||||
|
||||
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
@@ -35,21 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// WorkspacePath returns the per-run working directory for a workspace id. The
|
||||
// same id always maps to the same path so RunStep and the cleanup command agree.
|
||||
|
||||
|
||||
func WorkspacePath(workspaceID string) string {
|
||||
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
|
||||
}
|
||||
|
||||
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
|
||||
// the script to append KEY=value output to, executes it under the requested
|
||||
// interpreter, and streams output via emit, returning the terminal result
|
||||
// with empty stdout/stderr but populated exit_code/output_env.
|
||||
//
|
||||
// When the command carries a WorkspaceId the step runs with that per-run working
|
||||
// directory as its cwd (created here if missing); the server removes it once the
|
||||
// run finishes. The script and env files always live in a private temp dir so
|
||||
// they never leak into the shared workspace.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
|
||||
|
||||
@@ -102,7 +102,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
}
|
||||
}
|
||||
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
|
||||
default: // "bash"
|
||||
default:
|
||||
scriptPath = filepath.Join(dir, "step.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
|
||||
res.ExitCode = 1
|
||||
@@ -126,7 +126,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
c.Stderr = sw
|
||||
runErr := c.Run()
|
||||
|
||||
// stdout/stderr are streamed via emit, not returned in the result.
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = 124
|
||||
res.Stderr = "[vantage] step timed out"
|
||||
@@ -141,8 +141,8 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
return res
|
||||
}
|
||||
|
||||
// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines
|
||||
// without '=' are ignored.
|
||||
|
||||
|
||||
func parseEnvFile(path string) map[string]string {
|
||||
out := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
|
||||
@@ -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,
|
||||
@@ -150,8 +150,8 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
|
||||
return err
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
|
||||
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
@@ -1,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,7 +60,7 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
// Inventory report message types
|
||||
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
@@ -92,7 +92,7 @@ type InventoryReport struct {
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
// Monitor sync / check report message types
|
||||
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
@@ -104,6 +104,7 @@ type MonitorSpec struct {
|
||||
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"`
|
||||
}
|
||||
@@ -140,8 +141,8 @@ type ServerCommand struct {
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
@@ -185,8 +186,8 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
// WorkspaceId names the per-run working directory the agent creates and uses
|
||||
// as the step's cwd. Empty means run in the agent's default directory.
|
||||
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ type StepOutputChunk struct {
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
// CommandStream client-side interface
|
||||
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
@@ -229,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build linux
|
||||
|
||||
|
||||
package inventory
|
||||
|
||||
@@ -42,11 +42,11 @@ func cpuSample() (idle, total uint64) {
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if sc.Scan() {
|
||||
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
|
||||
fields := strings.Fields(sc.Text())
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 { // idle
|
||||
if i == 3 {
|
||||
idle = n
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +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"
|
||||
|
||||
// collect is a no-op best-effort stub on non-Linux platforms.
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
|
||||
@@ -2,8 +2,8 @@ package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// Collect gathers metrics always and static hardware info when includeStatic.
|
||||
// Platform specifics are provided by collect_linux.go / collect_other.go.
|
||||
|
||||
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package monitors runs agent-side service checks. It polls the server for the
|
||||
// monitors assigned to this agent (SyncMonitors), runs each on its own interval
|
||||
// using the local checker package, and reports results back (ReportChecks).
|
||||
|
||||
|
||||
|
||||
package monitors
|
||||
|
||||
import (
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
// syncInterval controls how often the agent re-fetches its assigned monitors.
|
||||
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
type runner struct {
|
||||
@@ -23,13 +23,13 @@ type runner struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Run starts the agent monitor loop and blocks until ctx is cancelled.
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config) {
|
||||
active := map[string]*runner{}
|
||||
var mu sync.Mutex
|
||||
|
||||
// results is a shared channel every check writes to; a single reporter
|
||||
// goroutine batches and ships them so we make one ReportChecks call per tick.
|
||||
|
||||
|
||||
results := make(chan pb.CheckResult, 64)
|
||||
go reporter(ctx, cfg, results)
|
||||
|
||||
@@ -99,6 +99,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
|
||||
ExpectedStatus: s.ExpectedStatus,
|
||||
Keyword: s.Keyword,
|
||||
TLSWarnDays: s.TLSWarnDays,
|
||||
Insecure: s.Insecure,
|
||||
TimeoutSec: s.IntervalSec,
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// reporter batches results on a short interval and ships each batch in one call.
|
||||
|
||||
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
+31
-32
@@ -34,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()
|
||||
@@ -61,25 +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)
|
||||
|
||||
// Report host inventory: metrics every 30s, full static snapshot every 15 min.
|
||||
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
// Run agent-side service monitors assigned to this server.
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -102,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
|
||||
}
|
||||
@@ -124,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
|
||||
@@ -178,9 +177,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
|
||||
log.Println("command stream connected")
|
||||
|
||||
// grpc streams are not safe for concurrent Send; RunStep results are sent
|
||||
// from per-command goroutines, so all sends on this stream must go through
|
||||
// this mutex-protected helper.
|
||||
|
||||
|
||||
|
||||
var sendMu sync.Mutex
|
||||
send := func(msg *pb.AgentMessage) error {
|
||||
sendMu.Lock()
|
||||
@@ -220,7 +219,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
}
|
||||
res := agentexec.RunStep(rc, emit)
|
||||
res.CommandId = cid
|
||||
// Final eof marker so the server closes the log file.
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
@@ -280,8 +279,8 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// runInventory reports host metrics every 30s and a full static snapshot every
|
||||
// 15 min (and once immediately on startup so static fields populate without delay).
|
||||
|
||||
|
||||
func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
@@ -299,7 +298,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
report(true) // full snapshot on startup
|
||||
report(true)
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tick := 0
|
||||
@@ -309,7 +308,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
tick++
|
||||
report(tick%30 == 0) // every 30th tick = 15 min → include static
|
||||
report(tick%30 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,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
|
||||
@@ -364,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)
|
||||
@@ -403,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
|
||||
@@ -435,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)
|
||||
@@ -445,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
|
||||
}
|
||||
@@ -463,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
|
||||
}
|
||||
@@ -555,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,299 @@ 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
|
||||
├── admin/ # licensing authority: the only signer
|
||||
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
|
||||
│ ├── cmd/adminctl/ # staff-add; deliberately has no HTTP surface
|
||||
│ └── internal/
|
||||
│ ├── api/ # customer + staff handlers, route table
|
||||
│ ├── auth/ # staff, cloud-owner and self-hosted sessions
|
||||
│ ├── inject/ # the ONE write path into the control plane
|
||||
│ ├── licensing/ # Issue, LinkInstance, Relink
|
||||
│ ├── mail/ # verification and licence delivery
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
├── adminsite/ # staff + customer console (vantage-hq)
|
||||
│ ├── app/(customer)/ # overview, instance, link, billing
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── components/ # InstanceCard, Ledger, Queue, EnvBadge
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── shared/ # imported by server, sitesvc and admin
|
||||
│ ├── license/ # payload, sign, verify, trusted keys, plans
|
||||
│ ├── models/ # Instance, User, Settings
|
||||
│ └── cmd/lkctl/ # issue and inspect licences by hand
|
||||
├── 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 `3003`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
|
||||
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable and listed in admin's `ADMIN_ORIGIN`. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
|
||||
`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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin REST API (`admin`, :8083)
|
||||
|
||||
A separate service with its own session cookie (`admin_session`) and its own database. Unauthenticated:
|
||||
|
||||
```
|
||||
GET /healthz
|
||||
GET /auth/me # who am I; 401 drives the UI's redirects
|
||||
POST /auth/staff/login /auth/login /auth/logout
|
||||
POST /auth/signup # self-hosted only; honeypot + rate limited
|
||||
GET /auth/verify?token=…
|
||||
```
|
||||
|
||||
Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
|
||||
```
|
||||
GET /account # account, instances, max_relinks
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
```
|
||||
|
||||
Staff-session (`/api/staff`):
|
||||
|
||||
```
|
||||
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
|
||||
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
|
||||
POST /instances/:id/issue · /instances/:id/relink
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:tier
|
||||
GET /health/injection
|
||||
```
|
||||
|
||||
**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters.
|
||||
|
||||
## 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 +343,133 @@ 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 four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk.
|
||||
|
||||
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
There are **three separate visual identities**, and the split is deliberate:
|
||||
|
||||
| App | Ground | Accent | Themes |
|
||||
| --- | --- | --- | --- |
|
||||
| `web/` | `#0f1117` | indigo `#6366f1` | dark only, locked |
|
||||
| `site/` | token-based | brand navy `#0b2a58` / `#5b9be8` | light + dark |
|
||||
| `adminsite/` | **the same tokens as `site/`** | brand navy | light + dark, light default |
|
||||
|
||||
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. **Change them in both files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in `adminsite/` maps `var(--…)` references only, so no component may carry a hex value. `site/` names the semantic three `--up`/`--pend`/`--down` for monitor state; `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state — same colours.
|
||||
|
||||
`adminsite/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label.
|
||||
|
||||
| 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 +477,53 @@ 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 six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
|
||||
|
||||
### 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 |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into the `adminsite` image. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. Must also be in admin's `ADMIN_ORIGIN`. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
|
||||
---
|
||||
|
||||
## 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,21 @@
|
||||
# Current cloud instance process
|
||||
|
||||
The current processs for creating cloud instances is incorrect.
|
||||
|
||||
At the moment the process is the following:
|
||||
|
||||
- Customer goes to `https://vantage.hostxtra.co.uk/start` then fills in the form.
|
||||
- Customer is then sent and email to verify
|
||||
- Customer clicks the link and the instance is created in the DB.
|
||||
- Customer can then access the instance.
|
||||
|
||||
As the `/start` process is auto creating a new instance this should default to the free tier instance.
|
||||
|
||||
The issue is that this doesn't create an `account` and `admin_instance` on the admin side.
|
||||
|
||||
The process should be the following:
|
||||
|
||||
- Customer goes to `https://vantage.hostxtra.co.uk/start` then fills in the form (including account name).
|
||||
- Customer is then sent and email to verify
|
||||
- Customer clicks the link and the instance is created in the DB.
|
||||
- `account` and `admin_instance` is created
|
||||
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
site:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3003: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: ${REDIS_ADDR:-10.10.10.2:6379}
|
||||
REDIS_USERNAME: ${REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${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:-}
|
||||
|
||||
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
|
||||
# ADMIN_API_URL is baked into the image at build time, not read here, so
|
||||
# changing it needs a rebuild rather than a restart — and it must appear in
|
||||
# admin's ADMIN_ORIGIN above or the browser blocks every request.
|
||||
adminsite:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 3000 is web, 3003 is the marketing site; this takes 3004.
|
||||
- 3004:3000
|
||||
depends_on:
|
||||
- admin
|
||||
networks: {}
|
||||
+49
-53
@@ -1,56 +1,52 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
guacd:
|
||||
image: docker.io/guacamole/guacd:1.6.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 4822:4822
|
||||
server:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 9090:9090
|
||||
environment:
|
||||
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
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- server
|
||||
redis:
|
||||
image: redis:8
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
guacd:
|
||||
image: docker.io/guacamole/guacd:1.6.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 4822:4822
|
||||
server:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 9090:9090
|
||||
environment:
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
APP_ROOT_LABEL: vantage
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data:/data
|
||||
web:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
mongo_data: null
|
||||
redis_data: null
|
||||
mongo_data: null
|
||||
redis_data: null
|
||||
networks: {}
|
||||
|
||||
@@ -1,866 +0,0 @@
|
||||
# Fleet Inventory Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
|
||||
|
||||
**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
|
||||
- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
|
||||
- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
|
||||
- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
|
||||
- Module path `github.com/mrhid6/vantage`.
|
||||
- Do not add heavy dependencies; implement `/proc` parsing directly.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Inventory model + gRPC messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/server.go`
|
||||
- Modify: `proto/vantage/v1/vantage.proto`
|
||||
- Modify: `server/internal/grpc/pb/vantage.pb.go`
|
||||
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
|
||||
|
||||
- [ ] **Step 1: Add model structs**
|
||||
|
||||
In `server/internal/models/server.go` add (keep the existing `import "time"`):
|
||||
|
||||
```go
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
|
||||
|
||||
- [ ] **Step 2: Document RPC in proto**
|
||||
|
||||
In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
|
||||
|
||||
- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
|
||||
|
||||
In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
|
||||
|
||||
```go
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
```
|
||||
|
||||
Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
|
||||
- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
|
||||
- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
|
||||
- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
|
||||
- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
|
||||
- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
|
||||
|
||||
- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
|
||||
|
||||
Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./...`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
|
||||
git commit -m "feat(proto): add ReportInventory RPC and inventory model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Server handler + store service
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/inventory.go`
|
||||
- Modify: `server/internal/grpc/server.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
|
||||
- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
|
||||
|
||||
- [ ] **Step 1: Write the store service**
|
||||
|
||||
```go
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// StoreInventory upserts the latest inventory snapshot onto the server document.
|
||||
// Metrics fields update every call; static fields only when r.IncludeStatic.
|
||||
func StoreInventory(serverID string, r *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
set := bson.M{"inventory.metrics_at": now}
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
|
||||
set["inventory.cpu.load1"] = r.CPU.Load1
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
|
||||
}
|
||||
set["inventory.swap_used_bytes"] = r.SwapUsed
|
||||
|
||||
if r.IncludeStatic {
|
||||
set["inventory.static_at"] = now
|
||||
set["inventory.swap_total_bytes"] = r.SwapTotal
|
||||
set["inventory.kernel"] = r.Kernel
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.model"] = r.CPU.Model
|
||||
set["inventory.cpu.cores"] = r.CPU.Cores
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
|
||||
}
|
||||
parts := make([]bson.M, 0, len(r.Partitions))
|
||||
for _, p := range r.Partitions {
|
||||
parts = append(parts, bson.M{
|
||||
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
|
||||
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
|
||||
})
|
||||
}
|
||||
set["inventory.partitions"] = parts
|
||||
}
|
||||
|
||||
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the gRPC handler**
|
||||
|
||||
In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
|
||||
|
||||
```go
|
||||
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
if err := services.StoreInventory(srv.ServerID, req); err != nil {
|
||||
log.Printf("store inventory for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.InventoryReportResponse{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/inventory.go server/internal/grpc/server.go
|
||||
git commit -m "feat(server): store inventory and handle ReportInventory RPC"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Agent collectors
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/inventory/collect_linux.go`
|
||||
- Create: `agent/internal/inventory/collect_other.go`
|
||||
- Create: `agent/internal/inventory/inventory.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
|
||||
|
||||
- [ ] **Step 1: Common entry (`inventory.go`)**
|
||||
|
||||
```go
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// Collect gathers metrics always and static hardware info when includeStatic.
|
||||
// Platform specifics are provided by collect_linux.go / collect_other.go.
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
return r
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Linux collector (`collect_linux.go`)**
|
||||
|
||||
Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
|
||||
- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
|
||||
- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
|
||||
- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
|
||||
- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
|
||||
|
||||
```go
|
||||
//go:build linux
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
r.CPU.Load1 = load1()
|
||||
memTotal, memAvail, swapTotal, swapFree := meminfo()
|
||||
if memTotal > memAvail {
|
||||
r.Memory.UsedBytes = memTotal - memAvail
|
||||
}
|
||||
if swapTotal > swapFree {
|
||||
r.SwapUsed = swapTotal - swapFree
|
||||
}
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = memTotal
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
|
||||
|
||||
func cpuSample() (idle, total uint64) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if sc.Scan() {
|
||||
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 { // idle
|
||||
idle = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1 := cpuSample()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2 := cpuSample()
|
||||
dt := float64(t2 - t1)
|
||||
if dt <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/dt) * 100
|
||||
}
|
||||
|
||||
func load1() float64 {
|
||||
fields := strings.Fields(readProc("/proc/loadavg"))
|
||||
if len(fields) > 0 {
|
||||
v, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func meminfo() (total, avail, swapTotal, swapFree uint64) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
kb, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
b := kb * 1024
|
||||
switch strings.TrimSuffix(fields[0], ":") {
|
||||
case "MemTotal":
|
||||
total = b
|
||||
case "MemAvailable":
|
||||
avail = b
|
||||
case "SwapTotal":
|
||||
swapTotal = b
|
||||
case "SwapFree":
|
||||
swapFree = b
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
f, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "processor") {
|
||||
cores++
|
||||
} else if strings.HasPrefix(line, "model name") && model == "" {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
model = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []pb.PartitionReport
|
||||
seen := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
|
||||
continue
|
||||
}
|
||||
seen[fields[1]] = true
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
|
||||
|
||||
```go
|
||||
//go:build !linux
|
||||
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// collect is a no-op best-effort stub on non-Linux platforms.
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/inventory/
|
||||
git commit -m "feat(agent): /proc-based inventory collectors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Agent client method + scheduler
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/internal/grpc/client.go`
|
||||
- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `inventory.Collect` (T3), pb (T1).
|
||||
- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
|
||||
|
||||
- [ ] **Step 1: Add client method**
|
||||
|
||||
In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
|
||||
|
||||
```go
|
||||
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportInventory(ctx, report)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
|
||||
|
||||
- [ ] **Step 2: Add the scheduler to the agent loop**
|
||||
|
||||
Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
tick := 0
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
report := func(static bool) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = serverID
|
||||
r.AgentToken = agentToken
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
}
|
||||
report(true) // send a full snapshot on startup
|
||||
for range t.C {
|
||||
tick++
|
||||
report(tick%30 == 0) // every 30th tick = 15 min → include static
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/grpc/client.go agent/
|
||||
git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — inventory panel on server detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
|
||||
- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: server-detail query.
|
||||
|
||||
- [ ] **Step 1: Add the inventory type**
|
||||
|
||||
In `web/lib/api.ts`, add and attach to the server type used by the detail page:
|
||||
|
||||
```ts
|
||||
export interface Inventory {
|
||||
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
|
||||
memory: { total_bytes: number; used_bytes: number };
|
||||
swap_total_bytes: number;
|
||||
swap_used_bytes: number;
|
||||
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
|
||||
kernel?: string;
|
||||
metrics_at?: string;
|
||||
static_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add `inventory?: Inventory;` to the server detail interface.
|
||||
|
||||
- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
|
||||
|
||||
In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
|
||||
|
||||
```tsx
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function UsageBar({ used, total }: { used: number; total: number }) {
|
||||
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
{inv.partitions && inv.partitions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
|
||||
<div className="space-y-3">
|
||||
{inv.partitions.map((p) => (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Render `{server.inventory && <InventoryPanel inv={server.inventory} />}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api.ts web/app/servers/[id]/page.tsx
|
||||
git commit -m "feat(web): inventory panel on server detail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: End-to-end manual verification
|
||||
|
||||
- [ ] **Step 1: Build all**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Smoke (if environment available)**
|
||||
|
||||
With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
|
||||
|
||||
- [ ] **Step 3: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: fleet inventory verification fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Service Monitoring (uptime-kuma replacement)
|
||||
|
||||
Extends the fleet work: in-app service monitors replacing uptime-kuma. Monitors (HTTP/TCP/ICMP/TLS) run **server-side** (public endpoints) or **agent-side** (agent probes its own host). Both runners feed one server-side ingest pipeline: state → incidents → rollups → notifications.
|
||||
|
||||
**Design:** validated in brainstorm 2026-07-21. Hybrid runners, all 4 check types, latest+incidents+rollups history, multi-channel notify (webhook/SMTP/Discord/Slack/Telegram), dedicated `SyncMonitors`/`ReportChecks` RPCs.
|
||||
|
||||
**Build order — 3 phases, each shippable:**
|
||||
- **P1 (Tasks 7–10):** data model, checker pkg, server scheduler, ingest pipeline, `/monitors` UI. Server-run only. No agent, no notify.
|
||||
- **P2 (Tasks 11–12):** `SyncMonitors` + `ReportChecks` RPCs, agent checker + scheduler, agent-run monitors bound to a server.
|
||||
- **P3 (Tasks 13–14):** notification channels + dispatch + settings UI.
|
||||
|
||||
## Monitoring Global Constraints
|
||||
|
||||
- Same as fleet: no tests this iteration; verify with `go build ./...`, `go vet ./...`, `npm run build`. JSON-codec gRPC — edit both pb files identically, mirror `ReportUpdates` wiring. Separate Go modules, so the checker pkg is **duplicated** in `server/` and `agent/` (same convention as pb files).
|
||||
- Reuse existing patterns: REST handlers like `server/internal/api`, services like `server/internal/services/servers.go`, `db.Col(...)`, react-query + Tailwind UI like `web/app/servers`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Monitoring data model + checker package (server)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/models/monitor.go`
|
||||
- Create: `server/internal/checker/checker.go` (+ `http.go`, `tcp.go`, `icmp.go`, `tls.go`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.Monitor` (+ `MonitorState`, `MonitorTarget`), `models.Incident`, `models.Rollup`. `checker.Run(ctx, models.Monitor) checker.Result` where `Result{Up bool; LatencyMs int; Message string; CertExpiry *time.Time}`.
|
||||
|
||||
- [ ] **Step 1: Model**
|
||||
|
||||
```go
|
||||
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"`
|
||||
}
|
||||
type MonitorState struct {
|
||||
Status string `bson:"status" json:"status"` // up|down|pending
|
||||
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"` // consecutive failures
|
||||
}
|
||||
type Monitor struct {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
|
||||
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
|
||||
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 {
|
||||
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 {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
|
||||
Checks int `bson:"checks" json:"checks"`
|
||||
UpCount int `bson:"up_count" json:"up_count"`
|
||||
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Checker package** — `Run(ctx, m)` switches on `m.Type`:
|
||||
- **http**: `http.Client` GET/HEAD `m.Target.URL`, assert status == ExpectedStatus (default 200), optional `Keyword` body contains; capture TLS peer cert expiry when https.
|
||||
- **tcp**: `net.DialTimeout("tcp", host:port)`, latency = dial time.
|
||||
- **icmp**: raw ICMP echo (agent/server run as root). Fall back to `net.Dial("ip4:icmp")`; on permission error return down with message.
|
||||
- **tls**: `tls.Dial`, read `ConnectionState().PeerCertificates[0].NotAfter` → `CertExpiry`; down if within `TLSWarnDays` or expired.
|
||||
- All: wrap with per-check timeout (min(IntervalSec, 10s)); `Result.Message` = short reason on failure.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `cd server && go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): monitor model + checker package`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Ingest pipeline + rollups service
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/monitors.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `IngestResult(monitorID string, res checker.Result) error` — the single entry both runners use. `ListMonitors`, `GetMonitor`, `CreateMonitor`, `UpdateMonitor`, `DeleteMonitor`, `ListIncidents(monitorID)`, `UptimeRollups(monitorID, since)`.
|
||||
|
||||
- [ ] **Step 1: `IngestResult`** — load monitor; compute new status with `Retries` threshold (increment `state.Fails` on failure, flip to `down` only when `Fails >= Retries`; reset + flip `up` on success). On **transition**: open incident (`down`) or resolve open incident (`up`), and enqueue notification (P3 — leave a `// TODO(P3): dispatch` hook now). Always `$set` state fields. Upsert current-hour `Rollup` (`$inc` checks/up_count/sum_latency). Use `db.Col("monitors")`, `db.Col("incidents")`, `db.Col("monitor_rollups")`, `context.WithTimeout`.
|
||||
|
||||
- [ ] **Step 2: CRUD + queries** — standard service funcs mirroring `services/servers.go`. `UptimeRollups` aggregates buckets since a cutoff → uptime % + avg latency series.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): monitor ingest pipeline, incidents, rollups`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Server scheduler + REST API
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/monitorsched/scheduler.go`
|
||||
- Create: `server/internal/api/monitors.go`
|
||||
- Modify: server bootstrap (wherever services/gRPC start) to launch the scheduler; router registration where `api` routes are mounted.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a scheduler that ticks enabled `runner=="server"` monitors on their `IntervalSec` and calls `checker.Run` → `services.IngestResult`. REST: `GET/POST /api/monitors`, `GET/PUT/DELETE /api/monitors/:id`, `GET /api/monitors/:id/incidents`, `GET /api/monitors/:id/uptime`.
|
||||
|
||||
- [ ] **Step 1: Scheduler** — on boot load monitors; per-monitor goroutine or a min-heap wheel keyed on next-run. Only `runner=="server"`. Reload on CRUD (simplest: re-read every N sec, or a reload channel fired by the service). Skip disabled.
|
||||
|
||||
- [ ] **Step 2: REST handlers** — mirror an existing `server/internal/api` handler file for style + auth middleware. JSON in/out of `models.Monitor`.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): server-run monitor scheduler + REST API`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — monitors UI (P1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (Monitor types + bindings)
|
||||
- Create: `web/app/monitors/page.tsx` (list), `web/app/monitors/[id]/page.tsx` (detail), `web/app/monitors/new/page.tsx` (create/edit form)
|
||||
- Modify: main nav to add **Monitors** (same place Steps was added)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/monitors*` (T9).
|
||||
|
||||
- [ ] **Step 1: Types + api bindings** — `Monitor`, `MonitorState`, `Incident`, uptime series; `api.monitors.list/get/create/update/remove/incidents/uptime`.
|
||||
- [ ] **Step 2: List page** — table: name, type, status badge (up/down/pending), uptime % (24h), latency, last check. `refetchInterval: 30000`.
|
||||
- [ ] **Step 3: Detail page** — status header, heartbeat/uptime bars (24h + 30d from rollups), latency chart, incident timeline, cert expiry, assigned channels (read-only until P3).
|
||||
- [ ] **Step 4: Create/edit form** — type-dependent fields (URL vs host/port), interval, retries, runner select (`server` or a registered server for agent-run — server option only wired in P2), enabled.
|
||||
- [ ] **Step 5: Verify build** — `cd web && npm run build`
|
||||
- [ ] **Step 6: Commit** — `feat(web): monitors list/detail/form UI`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: SyncMonitors + ReportChecks RPCs (P2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `proto/vantage/v1/vantage.proto`, `server/internal/grpc/pb/vantage.pb.go`, `agent/internal/grpc/pb/vantage.pb.go`, `server/internal/grpc/server.go`, `agent/internal/grpc/client.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SyncMonitors(server_id, agent_token) -> repeated MonitorSpec`; `ReportChecks(server_id, agent_token, repeated CheckResult) -> ReportChecksResponse`. `MonitorSpec{monitor_id, type, target fields, interval_sec, retries}`. `CheckResult{monitor_id, up, latency_ms, message, cert_expiry_unix}`.
|
||||
|
||||
- [ ] **Step 1: pb structs + proto** — add messages to both pb files + proto doc.
|
||||
- [ ] **Step 2: Wire both RPCs** — mirror `ReportUpdates` plumbing (interface, Unimplemented stub, client method, `Vantage_ServiceDesc.Methods`, `_Vantage_*_Handler`) in both pb files. Server handlers on `vantageServer` (after `ReportUpdates` at server.go:78): `SyncMonitors` returns monitors where `runner==req.ServerId && enabled`; `ReportChecks` validates token then loops `services.IngestResult`. Client methods on `*Client` in client.go (after `ReportUpdates` at client.go:117).
|
||||
- [ ] **Step 3: Verify build** — both modules `go build ./... && go vet ./...`
|
||||
- [ ] **Step 4: Commit** — `feat(proto): SyncMonitors + ReportChecks RPCs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Agent checker + scheduler (P2)
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/checker/` (duplicate of server checker pkg)
|
||||
- Create: `agent/internal/monitors/monitors.go` (poll + run + report loop)
|
||||
- Modify: agent main loop to start it (alongside the sync loop in `agent/internal/sync` / the inventory ticker from Task 4)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `client.SyncMonitors`, `client.ReportChecks`, agent `checker`.
|
||||
|
||||
- [ ] **Step 1: Duplicate checker pkg** into agent module (identical logic; imports agent pb).
|
||||
- [ ] **Step 2: Monitor loop** — poll `SyncMonitors` every 30s for assigned specs; per-spec ticker on `IntervalSec` runs `checker.Run`; batch `CheckResult`s and `ReportChecks`. `serverID`/`agentToken`/`*Client` in scope from the existing loop.
|
||||
- [ ] **Step 3: Verify build** — `cd agent && go build ./... && go vet ./...` (+ `GOOS=windows go build ./...`; icmp may no-op on Windows).
|
||||
- [ ] **Step 4: Commit** — `feat(agent): agent-run monitor scheduler`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Notification channels + dispatch (P3)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/models/channel.go`, `server/internal/services/channels.go`, `server/internal/notify/` (`dispatch.go`, `webhook.go`, `smtp.go`, `discord.go`, `slack.go`, `telegram.go`), `server/internal/api/channels.go`
|
||||
- Modify: `server/internal/services/monitors.go` (replace the P2 `// TODO(P3): dispatch` hook)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.NotificationChannel{channel_id, name, type, config map, enabled}`. `notify.Dispatch(channel, event)` where `event` = monitor + old/new status + message. `notify.Test(channel)`.
|
||||
|
||||
- [ ] **Step 1: Model + CRUD service + REST** (`/api/channels*`, incl. `POST /api/channels/:id/test`).
|
||||
- [ ] **Step 2: Dispatch abstraction** — webhook/discord/slack/telegram are HTTP POST with per-type JSON payload; SMTP via `net/smtp`. Per-monitor routing via `monitor.ChannelIDs`; resend interval so an ongoing `down` re-alerts at most every N min (track `last_notified_at` on monitor state).
|
||||
- [ ] **Step 3: Fire on transition** — in `IngestResult`, on up/down flip resolve channels and `notify.Dispatch` each (goroutine, best-effort, log failures).
|
||||
- [ ] **Step 4: Verify build** — `go build ./... && go vet ./...`
|
||||
- [ ] **Step 5: Commit** — `feat(server): multi-channel monitor notifications`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — notification settings (P3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (channel types + bindings), `web/app/settings/` (add notifications section/page)
|
||||
- Modify: monitor create/edit form (Task 10) to select channels
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/channels*`.
|
||||
|
||||
- [ ] **Step 1: Channel types + api bindings.**
|
||||
- [ ] **Step 2: Settings UI** — list/add/edit channels, type-dependent config fields, **Test** button hitting `/api/channels/:id/test`.
|
||||
- [ ] **Step 3: Wire channel multi-select** into the monitor form.
|
||||
- [ ] **Step 4: Verify build** — `cd web && npm run build`
|
||||
- [ ] **Step 5: Commit** — `feat(web): notification channel settings UI`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
|
||||
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
|
||||
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
|
||||
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
|
||||
- **Monitoring (Tasks 7–14):** hybrid runner service-monitor replacing uptime-kuma, added 2026-07-21. 3 phases — P1 server-run engine+UI (T7–10), P2 agent-run RPCs (T11–12), P3 multi-channel notify (T13–14). Single `IngestResult` pipeline for both runners; checker pkg duplicated per module (pb convention). Design: brainstorm 2026-07-21. Follow-ups out of scope: status pages, maintenance windows, per-check auth headers, ICMP on Windows.
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,142 +0,0 @@
|
||||
# Fleet Inventory — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences:
|
||||
|
||||
- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm).
|
||||
- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**.
|
||||
|
||||
Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Transport | New `ReportInventory` unary RPC. |
|
||||
| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). |
|
||||
| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. |
|
||||
| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. |
|
||||
| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`):
|
||||
|
||||
```go
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add `Inventory *Inventory` field to `Server`.
|
||||
|
||||
Server-side update rules:
|
||||
- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`.
|
||||
- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`.
|
||||
|
||||
---
|
||||
|
||||
## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
|
||||
|
||||
```protobuf
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
|
||||
message InventoryReport {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
bool include_static = 3; // true on the 15-min tick
|
||||
CPUReport cpu = 4;
|
||||
MemReport memory = 5;
|
||||
uint64 swap_total = 6;
|
||||
uint64 swap_used = 7;
|
||||
repeated PartitionReport partitions = 8; // only when include_static
|
||||
string kernel = 9; // only when include_static
|
||||
}
|
||||
message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; }
|
||||
message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; }
|
||||
message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; }
|
||||
message InventoryReportResponse {}
|
||||
```
|
||||
|
||||
Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent collection (`agent/internal/inventory/`)
|
||||
|
||||
- `Collect(includeStatic bool) *pb.InventoryReport` — reads:
|
||||
- CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static).
|
||||
- Memory/swap: `/proc/meminfo`.
|
||||
- Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static).
|
||||
- Kernel: `uname` / `/proc/version` (static).
|
||||
- Windows: best-effort via `wmic`/PS or leave empty.
|
||||
- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`.
|
||||
- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`.
|
||||
|
||||
Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored.
|
||||
|
||||
---
|
||||
|
||||
## 6. Server handler + service
|
||||
|
||||
- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`.
|
||||
- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend
|
||||
|
||||
Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel:
|
||||
- CPU usage gauge + model/cores, load.
|
||||
- RAM used/total bar, swap bar.
|
||||
- Partitions table: device, mount, fstype, used/total with a usage bar.
|
||||
- "Updated Xs ago" from `metrics_at`/`static_at`.
|
||||
|
||||
Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Time-series history / graphs (only latest snapshot stored).
|
||||
- Alerting thresholds on usage (settings/alerts is a separate concern).
|
||||
- Per-process / network / GPU inventory.
|
||||
- Tests (skipped, consistent with the Workflows iteration).
|
||||
@@ -1,142 +0,0 @@
|
||||
# SaaS: Auth + Organizations — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
|
||||
|
||||
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
|
||||
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
|
||||
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
|
||||
|
||||
No billing, no seat/server limits this iteration (schema leaves room).
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
|
||||
| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
|
||||
| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
|
||||
| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
|
||||
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
|
||||
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
|
||||
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### `orgs`
|
||||
```json
|
||||
{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
|
||||
```
|
||||
|
||||
### `users`
|
||||
```json
|
||||
{
|
||||
"_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
|
||||
"email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
|
||||
"auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
|
||||
}
|
||||
```
|
||||
Unique index on `email` (global — email identifies the account and its org).
|
||||
|
||||
### `org_oidc` (per-org provider config)
|
||||
```json
|
||||
{
|
||||
"_id":"ObjectId", "org_id":"uuid",
|
||||
"issuer":"https://id.acme.com", "client_id":"...",
|
||||
"client_secret_enc":"AES...", // encrypted with existing crypto.go
|
||||
"redirect_url":"https://vantage.../auth/oidc/callback",
|
||||
"enabled": true, "updated_at":"ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
### Existing collections — add `org_id`
|
||||
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 4. Auth flows
|
||||
|
||||
### Local
|
||||
- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
|
||||
- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
|
||||
- `POST /auth/logout` — destroy session.
|
||||
- `GET /auth/me` — returns current user + org.
|
||||
|
||||
### Org-admin user management
|
||||
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
|
||||
|
||||
### Per-org OIDC
|
||||
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
|
||||
- `GET /auth/oidc/start?org=<org_id or slug>` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize.
|
||||
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
|
||||
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
|
||||
|
||||
### First-run bootstrap
|
||||
- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty).
|
||||
- Setup page collects org name + owner email/password → creates org + owner → session.
|
||||
|
||||
---
|
||||
|
||||
## 5. Request scoping
|
||||
|
||||
- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
|
||||
- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
|
||||
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
|
||||
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Removing global Authentik
|
||||
|
||||
- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
|
||||
- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
|
||||
- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration
|
||||
|
||||
One-shot migration run at startup (idempotent):
|
||||
1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
|
||||
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
|
||||
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
|
||||
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
|
||||
- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
|
||||
- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security
|
||||
|
||||
- Passwords: bcrypt (cost ≥ 12). Never returned.
|
||||
- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
|
||||
- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
|
||||
- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
|
||||
- Role checks on all org-admin mutations.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Billing, plans, seat/server limits.
|
||||
- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
|
||||
- SCIM / directory sync, SAML.
|
||||
- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
|
||||
- Tests (skipped, consistent with prior iterations).
|
||||
@@ -0,0 +1,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 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user