Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946a748038 | ||
|
|
481649e03f | ||
|
|
cfdc00552e | ||
|
|
b7221d8111 | ||
|
|
7e6d7074d6 | ||
|
|
268134d821 | ||
|
|
c829cc41d9 | ||
|
|
07a3756b18 | ||
|
|
769839a70d | ||
|
|
480a578deb | ||
|
|
a636a48e07 | ||
|
|
427191b14c | ||
|
|
b8fcf89ee7 | ||
|
|
64eac6dbc7 |
@@ -53,3 +53,10 @@ jobs:
|
||||
# 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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
*.lic
|
||||
@@ -0,0 +1,30 @@
|
||||
# Context is the repository root; admin depends on the shared module.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY admin/go.mod admin/go.sum ./admin/
|
||||
RUN cd admin && go mod download
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY admin/ ./admin/
|
||||
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
addgroup --system --gid 1001 admin && \
|
||||
adduser --system --uid 1001 --ingroup admin admin
|
||||
|
||||
COPY --from=builder /out/admin /usr/local/bin/admin
|
||||
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
|
||||
|
||||
USER admin
|
||||
|
||||
EXPOSE 8083
|
||||
ENV PORT=8083
|
||||
|
||||
CMD ["/usr/local/bin/admin"]
|
||||
@@ -0,0 +1,82 @@
|
||||
// Command adminctl performs the operations that deliberately have no HTTP
|
||||
// surface.
|
||||
//
|
||||
// adminctl staff-add --email=you@example.com --name="You" --password=...
|
||||
//
|
||||
// There is no staff signup endpoint. A licensing authority that can be joined
|
||||
// over the internet is not one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fatal("configuration: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
fatal("database: %v", err)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "staff-add":
|
||||
staffAdd(ctx, os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func staffAdd(ctx context.Context, args []string) {
|
||||
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
|
||||
email := fs.String("email", "", "staff email (required)")
|
||||
name := fs.String("name", "", "display name")
|
||||
password := fs.String("password", "", "password, at least 12 characters (required)")
|
||||
fs.Parse(args)
|
||||
|
||||
if *email == "" || len(*password) < 12 {
|
||||
fatal("--email and a --password of at least 12 characters are required")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
fatal("hash: %v", err)
|
||||
}
|
||||
|
||||
u := models.StaffUser{
|
||||
UserID: uuid.NewString(),
|
||||
Email: strings.ToLower(strings.TrimSpace(*email)),
|
||||
PasswordHash: string(hash),
|
||||
Name: *name,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
|
||||
fatal("create staff user: %v", err)
|
||||
}
|
||||
fmt.Printf("created staff user %s\n", u.Email)
|
||||
}
|
||||
|
||||
func fatal(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/admin/internal/api"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("configuration error: %v", err)
|
||||
}
|
||||
|
||||
licensing.SetSigningKey(cfg.SigningKey)
|
||||
|
||||
mail.Init(mail.Config{
|
||||
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
|
||||
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
|
||||
PublicURL: cfg.PublicURL,
|
||||
})
|
||||
if !mail.Enabled() {
|
||||
log.Println("warning: SMTP not configured; verification and licence emails will fail")
|
||||
}
|
||||
|
||||
auth.InitRedis(cfg.RedisAddr)
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := auth.Ping(pingCtx); err != nil {
|
||||
pingCancel()
|
||||
log.Fatalf("redis: %v", err)
|
||||
}
|
||||
pingCancel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
if err := db.Connect(ctx, cfg); err != nil {
|
||||
cancel()
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
cancel()
|
||||
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
|
||||
|
||||
idxCtx, idxCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := db.EnsureIndexes(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("indexes: %v", err)
|
||||
}
|
||||
if err := models.SeedPlans(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("plan seed: %v", err)
|
||||
}
|
||||
idxCancel()
|
||||
|
||||
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: api.Routes(cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("admin listening on %s", cfg.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-stopCtx.Done()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
log.Println("admin stopped")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
module github.com/mrhid6/vantage/admin
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
//
|
||||
// EVERY customer handler that names an instance must go through this. It returns
|
||||
// 404 for another account's instance rather than 403: a 403 confirms the
|
||||
// instance exists, which is an existence oracle over customer data.
|
||||
func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
|
||||
s := auth.Current(c)
|
||||
if s == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return nil, false
|
||||
}
|
||||
var inst models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&inst)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return nil, false
|
||||
}
|
||||
return &inst, true
|
||||
}
|
||||
|
||||
func getAccount(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
|
||||
}
|
||||
|
||||
func linkInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
inst, err := licensing.LinkInstance(c.Request.Context(), s.AccountID, body.InstanceID, body.Name)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrAlreadyLinked) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
func relinkInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Relink(c.Request.Context(), s.AccountID, inst.InstanceID, body.InstanceID, false)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrRelinkLimit) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
deliver(c, inst, lic)
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func getInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func downloadInstanceLicense(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var lic models.License
|
||||
if err := db.Admin("licenses").FindOne(c.Request.Context(),
|
||||
bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="vantage-%s.lic"`, inst.InstanceID))
|
||||
c.Data(http.StatusOK, "application/octet-stream", []byte(lic.Blob+"\n"))
|
||||
}
|
||||
|
||||
func listSubscriptions(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
cur, err := db.Admin("subscriptions").Find(c.Request.Context(), bson.M{"account_id": s.AccountID})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
subs := []models.Subscription{}
|
||||
if err := cur.All(c.Request.Context(), &subs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
// Delivery failures are logged, never returned: the licence is already recorded,
|
||||
// which is the part that must not be lost.
|
||||
func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
inject.Deliver(c.Request.Context(), lic)
|
||||
return
|
||||
}
|
||||
s := auth.Current(c)
|
||||
if s != nil && mail.Enabled() {
|
||||
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package api mounts admin's HTTP surface.
|
||||
//
|
||||
// The route table is the single place scoping is guaranteed. Customer routes
|
||||
// live behind RequireCustomer and every handler that names an instance calls
|
||||
// ownedInstance. A new customer route that skips that helper is a scoping bug,
|
||||
// so keep them together and review them together.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
)
|
||||
|
||||
func Routes(cfg config.Config) http.Handler {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.Use(cors(cfg.AllowedOrigins))
|
||||
|
||||
if cfg.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
r.POST("/auth/staff/login", auth.HandleStaffLogin)
|
||||
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/verify", auth.HandleVerify)
|
||||
|
||||
cust := r.Group("/api")
|
||||
cust.Use(auth.RequireCustomer())
|
||||
{
|
||||
cust.GET("/account", getAccount)
|
||||
cust.POST("/instances/link", linkInstance)
|
||||
cust.POST("/instances/:id/relink", relinkInstance)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/subscriptions", listSubscriptions)
|
||||
}
|
||||
|
||||
staff := r.Group("/api/staff")
|
||||
staff.Use(auth.RequireStaff())
|
||||
{
|
||||
staff.GET("/accounts", staffListAccounts)
|
||||
staff.POST("/accounts", staffCreateAccount)
|
||||
staff.GET("/accounts/:id", staffGetAccount)
|
||||
staff.GET("/instances", staffListInstances)
|
||||
staff.POST("/instances", staffCreateInstance)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
staff.PUT("/plans/:tier", staffUpdatePlan)
|
||||
staff.GET("/audit", staffAudit)
|
||||
staff.GET("/health/injection", staffInjectionHealth)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func cors(allowed []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && slices.Contains(allowed, origin) {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func staffListAccounts(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if q := c.Query("q"); q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"name": bson.M{"$regex": q, "$options": "i"}},
|
||||
{"billing_email": bson.M{"$regex": q, "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := db.Admin("accounts").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accounts := []models.Account{}
|
||||
if err := cur.All(c.Request.Context(), &accounts); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accounts)
|
||||
}
|
||||
|
||||
func staffCreateAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BillingEmail string `json:"billing_email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" || body.BillingEmail == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and billing_email are required"})
|
||||
return
|
||||
}
|
||||
acct := models.Account{
|
||||
AccountID: uuid.NewString(),
|
||||
Name: body.Name,
|
||||
BillingEmail: body.BillingEmail,
|
||||
Status: models.AccountActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("accounts").InsertOne(c.Request.Context(), acct); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, acct)
|
||||
}
|
||||
|
||||
func staffGetAccount(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx, bson.M{"account_id": c.Param("id")}).Decode(&acct); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID})
|
||||
instances := []models.Instance{}
|
||||
if cur != nil {
|
||||
_ = cur.All(ctx, &instances)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
|
||||
}
|
||||
|
||||
func staffListInstances(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
for param, field := range map[string]string{
|
||||
"account_id": "account_id",
|
||||
"deployment": "deployment",
|
||||
"status": "status",
|
||||
} {
|
||||
if v := c.Query(param); v != "" {
|
||||
filter[field] = v
|
||||
}
|
||||
}
|
||||
if c.Query("expiring") == "true" {
|
||||
// Instances whose licence expires within 14 days, for renewal chasing.
|
||||
var ids []string
|
||||
cur, err := db.Admin("licenses").Find(c.Request.Context(), bson.M{
|
||||
"superseded_by": bson.M{"$exists": false},
|
||||
"expires_at": bson.M{"$lt": time.Now().UTC().Add(14 * 24 * time.Hour)},
|
||||
})
|
||||
if err == nil {
|
||||
var lics []models.License
|
||||
if cur.All(c.Request.Context(), &lics) == nil {
|
||||
for _, l := range lics {
|
||||
ids = append(ids, l.InstanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
filter["instance_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
instances := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &instances); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, instances)
|
||||
}
|
||||
|
||||
// staffCreateInstance attaches an instance to an account.
|
||||
//
|
||||
// For cloud, this ADOPTS an instance that already exists in the control plane —
|
||||
// the control-plane row is the source of truth for its name and slug, and this
|
||||
// refuses if no such instance exists, because an admin row pointing at nothing
|
||||
// would issue licences nobody can use.
|
||||
//
|
||||
// For self-hosted it does the same job as the customer-facing link endpoint, so
|
||||
// staff can link on a customer's behalf during support.
|
||||
//
|
||||
// This is how existing cloud instances get licensed: adopt, then issue.
|
||||
func staffCreateInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Deployment string `json:"deployment"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
|
||||
return
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: body.InstanceID,
|
||||
AccountID: body.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: body.Deployment,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if body.Deployment == license.DeploymentCloud {
|
||||
var remote sharedmodels.Instance
|
||||
if err := db.Control("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
|
||||
return
|
||||
}
|
||||
inst.Name = remote.Name
|
||||
inst.Slug = remote.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
|
||||
c.JSON(http.StatusCreated, inst)
|
||||
}
|
||||
|
||||
func staffIssue(c *gin.Context) {
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
Term string `json:"term"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Tier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tier is required"})
|
||||
return
|
||||
}
|
||||
if body.Reason == "" {
|
||||
body.Reason = models.ReasonManual
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
lic, err := licensing.Issue(c.Request.Context(), licensing.IssueInput{
|
||||
InstanceID: c.Param("id"),
|
||||
Tier: body.Tier,
|
||||
Term: body.Term,
|
||||
Reason: body.Reason,
|
||||
IssuedBy: s.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if db.Admin("admin_instances").FindOne(c.Request.Context(),
|
||||
bson.M{"instance_id": lic.InstanceID}).Decode(&inst) == nil {
|
||||
deliver(c, &inst, lic)
|
||||
}
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// staffRelink has no attempt cap. The customer-facing limit exists to put a
|
||||
// human in front of the fourth attempt; this is that human.
|
||||
func staffRelink(c *gin.Context) {
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
lic, err := licensing.Relink(ctx, inst.AccountID, inst.InstanceID, body.InstanceID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lic)
|
||||
}
|
||||
|
||||
func staffListLicenses(c *gin.Context) {
|
||||
filter := bson.M{}
|
||||
if v := c.Query("instance_id"); v != "" {
|
||||
filter["instance_id"] = v
|
||||
}
|
||||
if v := c.Query("account_id"); v != "" {
|
||||
filter["account_id"] = v
|
||||
}
|
||||
cur, err := db.Admin("licenses").Find(c.Request.Context(), filter,
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "issued_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lics := []models.License{}
|
||||
if err := cur.All(c.Request.Context(), &lics); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
func staffListPlans(c *gin.Context) {
|
||||
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
plans := []models.Plan{}
|
||||
if err := cur.All(c.Request.Context(), &plans); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences
|
||||
// snapshotted their plan at issue time and are unaffected — the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
func staffUpdatePlan(c *gin.Context) {
|
||||
var body models.Plan
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
|
||||
return
|
||||
}
|
||||
set := bson.M{
|
||||
"name": body.Name,
|
||||
"limits": body.Limits,
|
||||
"features": body.Features,
|
||||
"paddle_product_id": body.PaddleProductID,
|
||||
"paddle_price_ids": body.PaddlePriceIDs,
|
||||
"active": body.Active,
|
||||
}
|
||||
if _, err := db.Admin("plans").UpdateOne(c.Request.Context(),
|
||||
bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func staffAudit(c *gin.Context) {
|
||||
cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{},
|
||||
options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entries := []models.AuditEntry{}
|
||||
if err := cur.All(c.Request.Context(), &entries); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// staffInjectionHealth lists instances whose last injection failed. This is the
|
||||
// page to look at when a customer says their cloud instance is read-only.
|
||||
func staffInjectionHealth(c *gin.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(c.Request.Context(),
|
||||
bson.M{"inject_failed_at": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
failed := []models.Instance{}
|
||||
if err := cur.All(c.Request.Context(), &failed); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"failed": failed, "count": len(failed)})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package audit records who did what. Every issuance, link, relink and sign-in
|
||||
// attempt lands here.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
)
|
||||
|
||||
// Write never returns an error: an audit failure must not roll back the action
|
||||
// it describes. It logs instead, loudly enough to notice.
|
||||
func Write(ctx context.Context, e models.AuditEntry) {
|
||||
e.CreatedAt = time.Now().UTC()
|
||||
if _, err := db.Admin("admin_audit").InsertOne(ctx, e); err != nil {
|
||||
log.Printf("AUDIT WRITE FAILED action=%s target=%s: %v", e.Action, e.Target, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
adminmodels "github.com/mrhid6/vantage/admin/internal/models"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's
|
||||
// users collection, with the credentials they already have.
|
||||
//
|
||||
// Two consequences worth stating plainly, because they are real and were
|
||||
// accepted deliberately:
|
||||
//
|
||||
// 1. A cloud user's control-plane password now also unlocks billing. Any
|
||||
// password change or compromise has a wider blast radius than before.
|
||||
// 2. Only control-plane role "owner" may sign in here. admin and member are
|
||||
// refused — billing is an owner concern.
|
||||
//
|
||||
// Mitigations: rate limits, an identical error for every failure, and an audit
|
||||
// entry for every attempt.
|
||||
func HandleCloudLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
// A self-hosted customer_users row wins over a control-plane user with the
|
||||
// same address. Documented so the behaviour is chosen rather than emergent.
|
||||
if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
|
||||
HandleCustomerLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
var u sharedmodels.User
|
||||
if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.Role != sharedmodels.RoleOwner {
|
||||
reject("role " + u.Role + " is not permitted")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the admin-side account that owns this user's instance.
|
||||
var inst adminmodels.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil {
|
||||
reject("no account for instance " + u.InstanceID)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, adminmodels.AuditEntry{
|
||||
Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptCost matches the control plane and sitesvc. Changing it here alone would
|
||||
// make hashes inconsistent across services that may one day compare them.
|
||||
const BcryptCost = 12
|
||||
|
||||
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
|
||||
// SHA-256 hash stored, 24-hour expiry.
|
||||
const VerifyWindow = 24 * time.Hour
|
||||
|
||||
// CreateCustomerUser creates an unverified self-hosted customer login and emails
|
||||
// the verification link. Called during purchase (spec 5) and by staff.
|
||||
func CreateCustomerUser(ctx context.Context, accountID, email, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expiry := time.Now().UTC().Add(VerifyWindow)
|
||||
|
||||
u := models.CustomerUser{
|
||||
UserID: uuid.NewString(),
|
||||
AccountID: accountID,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
PasswordHash: string(hash),
|
||||
VerifyTokenHash: hex.EncodeToString(sum[:]),
|
||||
VerifyTokenExpiry: &expiry,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
return mail.SendVerification(u.Email, token)
|
||||
}
|
||||
|
||||
// HandleVerify consumes a verification token.
|
||||
func HandleVerify(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
|
||||
bson.M{
|
||||
"verify_token_hash": hex.EncodeToString(sum[:]),
|
||||
"verify_token_expiry": bson.M{"$gt": now},
|
||||
},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified_at": now},
|
||||
"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
|
||||
})
|
||||
if err != nil || res.MatchedCount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true})
|
||||
}
|
||||
|
||||
// HandleCustomerLogin authenticates a self-hosted customer.
|
||||
func HandleCustomerLogin(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if !allowAttempt(email, c.ClientIP()) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
reject := func(reason string) {
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
|
||||
}
|
||||
|
||||
var u models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
|
||||
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
|
||||
reject("unknown email")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
|
||||
reject("bad password")
|
||||
return
|
||||
}
|
||||
if u.VerifiedAt == nil {
|
||||
// Distinct from genericAuthError on purpose: the address is already
|
||||
// known to be theirs, so there is nothing to disclose, and "check your
|
||||
// email" is the only useful thing to say.
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := Save(ctx, Session{
|
||||
UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
|
||||
return
|
||||
}
|
||||
SetCookie(c, id)
|
||||
clearAttempts(email)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxSession = "admin_session_obj"
|
||||
|
||||
func load(c *gin.Context) *Session {
|
||||
id, err := c.Cookie(CookieName)
|
||||
if err != nil || id == "" {
|
||||
return nil
|
||||
}
|
||||
s, err := Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Current returns the session, or nil.
|
||||
func Current(c *gin.Context) *Session {
|
||||
if v, ok := c.Get(ctxSession); ok {
|
||||
if s, ok := v.(*Session); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RequireStaff() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := load(c)
|
||||
if s == nil || s.Kind != KindStaff {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireCustomer admits both cloud and self-hosted customers. Every handler
|
||||
// behind it scopes by AccountID via the helper in api/customer.go — never by
|
||||
// remembering to filter.
|
||||
func RequireCustomer() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := load(c)
|
||||
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSession, s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
|
||||
// hour. The email limit stops a targeted attack on one account; the IP limit
|
||||
// stops a spray across many.
|
||||
const (
|
||||
emailLimit = 5
|
||||
emailWindow = 15 * time.Minute
|
||||
ipLimit = 20
|
||||
ipWindow = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
attemptMu sync.Mutex
|
||||
byEmail = map[string][]time.Time{}
|
||||
byIP = map[string][]time.Time{}
|
||||
)
|
||||
|
||||
func prune(in []time.Time, cutoff time.Time) []time.Time {
|
||||
out := in[:0]
|
||||
for _, t := range in {
|
||||
if t.After(cutoff) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowAttempt(email, ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
attemptMu.Lock()
|
||||
defer attemptMu.Unlock()
|
||||
|
||||
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
|
||||
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
|
||||
|
||||
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
|
||||
return false
|
||||
}
|
||||
byEmail[email] = append(byEmail[email], now)
|
||||
byIP[ip] = append(byIP[ip], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func clearAttempts(email string) {
|
||||
attemptMu.Lock()
|
||||
delete(byEmail, email)
|
||||
attemptMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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
|
||||
|
||||
func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) }
|
||||
|
||||
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,110 @@
|
||||
// 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
|
||||
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"),
|
||||
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() }
|
||||
@@ -25,3 +25,29 @@ services:
|
||||
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SITE_SMTP_FROM:-}
|
||||
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
|
||||
|
||||
# The licensing authority. LICENSE_SIGNING_KEY appears in exactly one
|
||||
# service in exactly one compose file: here. It must never be added to
|
||||
# `server`, and docker-compose.yml -- the self-hosted deployment -- must
|
||||
# not mention admin at all.
|
||||
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: ${CONTROL_MONGO_URI:-}
|
||||
REDIS_ADDR: redis:6379
|
||||
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
|
||||
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
|
||||
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
|
||||
TRUST_PROXY: ${ADMIN_TRUST_PROXY:-true}
|
||||
SMTP_HOST: ${SITE_SMTP_HOST:-}
|
||||
SMTP_PORT: ${SITE_SMTP_PORT:-587}
|
||||
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SITE_SMTP_FROM:-}
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
@@ -80,7 +80,7 @@ There is deliberately no automated backfill. Those instances get licensed by han
|
||||
- `config.Config` with `Load() (Config, error)`
|
||||
- `db.Connect(ctx, cfg)`, `db.Admin(name) *mongo.Collection`, `db.Control(name) *mongo.Collection`, `db.EnsureIndexes(ctx)`
|
||||
|
||||
- [ ] **Step 1: Create the module**
|
||||
- [x] **Step 1: Create the module**
|
||||
|
||||
```bash
|
||||
cd c:/Work/Repos/vantage
|
||||
@@ -120,7 +120,7 @@ use (
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the config**
|
||||
- [x] **Step 2: Write the config**
|
||||
|
||||
Create `admin/internal/config/config.go`:
|
||||
|
||||
@@ -237,7 +237,7 @@ func envOr(key, fallback string) string {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the database layer**
|
||||
- [x] **Step 3: Write the database layer**
|
||||
|
||||
Create `admin/internal/db/db.go`:
|
||||
|
||||
@@ -305,7 +305,7 @@ func Ctx() (context.Context, context.CancelFunc) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write main.go**
|
||||
- [x] **Step 4: Write main.go**
|
||||
|
||||
Create `admin/cmd/main.go`:
|
||||
|
||||
@@ -371,7 +371,7 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write the Dockerfile**
|
||||
- [x] **Step 5: Write the Dockerfile**
|
||||
|
||||
Create `admin/Dockerfile` — context is the repo root, exactly like `server/`:
|
||||
|
||||
@@ -415,7 +415,7 @@ Create `admin/.dockerignore`:
|
||||
*.lic
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Resolve dependencies outside the workspace**
|
||||
- [x] **Step 6: Resolve dependencies outside the workspace**
|
||||
|
||||
```bash
|
||||
cd c:/Work/Repos/vantage
|
||||
@@ -425,7 +425,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod
|
||||
|
||||
`GOWORK=off` is required. In workspace mode `tidy` drops the `require` lines the Docker build needs, and the failure only appears at image build time.
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
- [x] **Step 7: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -433,7 +433,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/ go.work
|
||||
@@ -456,7 +456,7 @@ git commit -m "feat(admin): module skeleton, config and two database connections
|
||||
- `models.SeedPlans(ctx) error`
|
||||
- `db.EnsureIndexes(ctx) error`
|
||||
|
||||
- [ ] **Step 1: Write the documents**
|
||||
- [x] **Step 1: Write the documents**
|
||||
|
||||
Create `admin/internal/models/models.go`:
|
||||
|
||||
@@ -628,7 +628,7 @@ type AuditEntry struct {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the plan seed**
|
||||
- [x] **Step 2: Write the plan seed**
|
||||
|
||||
Create `admin/internal/models/plans.go`:
|
||||
|
||||
@@ -685,7 +685,7 @@ func GetPlan(ctx context.Context, tier string) (*Plan, error) {
|
||||
func now() time.Time { return time.Now().UTC() }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the indexes**
|
||||
- [x] **Step 3: Add the indexes**
|
||||
|
||||
Append to `admin/internal/db/db.go`:
|
||||
|
||||
@@ -744,7 +744,7 @@ func EnsureIndexes(ctx context.Context) error {
|
||||
|
||||
Add to that file's imports: `"go.mongodb.org/mongo-driver/v2/bson"` and `"go.mongodb.org/mongo-driver/v2/mongo/options"`.
|
||||
|
||||
- [ ] **Step 4: Call both at boot**
|
||||
- [x] **Step 4: Call both at boot**
|
||||
|
||||
In `admin/cmd/main.go`, after the connect block:
|
||||
|
||||
@@ -763,7 +763,7 @@ In `admin/cmd/main.go`, after the connect block:
|
||||
|
||||
Import `"github.com/mrhid6/vantage/admin/internal/models"`.
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
- [x] **Step 5: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -771,7 +771,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -795,7 +795,7 @@ The core of the service. Everything else exists to call this correctly.
|
||||
- `licensing.ErrFreeLimit`, `ErrDeploymentMismatch`, `ErrUnknownTier`
|
||||
- `audit.Write(ctx, e models.AuditEntry)`
|
||||
|
||||
- [ ] **Step 1: Write the audit helper**
|
||||
- [x] **Step 1: Write the audit helper**
|
||||
|
||||
Create `admin/internal/audit/audit.go`:
|
||||
|
||||
@@ -823,7 +823,7 @@ func Write(ctx context.Context, e models.AuditEntry) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write issuance**
|
||||
- [x] **Step 2: Write issuance**
|
||||
|
||||
Create `admin/internal/licensing/issue.go`:
|
||||
|
||||
@@ -1008,7 +1008,7 @@ func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the signing key at boot**
|
||||
- [x] **Step 3: Wire the signing key at boot**
|
||||
|
||||
In `admin/cmd/main.go`, after config loads:
|
||||
|
||||
@@ -1018,7 +1018,7 @@ In `admin/cmd/main.go`, after config loads:
|
||||
|
||||
Import `"github.com/mrhid6/vantage/admin/internal/licensing"`.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
- [x] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -1026,7 +1026,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -1048,7 +1048,7 @@ git commit -m "feat(admin): licence issuance with plan snapshots and supersessio
|
||||
- `inject.Reconcile(ctx) (checked, repaired int, err error)`
|
||||
- `inject.StartReconciler(ctx)`
|
||||
|
||||
- [ ] **Step 1: Write it**
|
||||
- [x] **Step 1: Write it**
|
||||
|
||||
Create `admin/internal/inject/inject.go`:
|
||||
|
||||
@@ -1205,7 +1205,7 @@ func StartReconciler(ctx context.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Start it at boot**
|
||||
- [x] **Step 2: Start it at boot**
|
||||
|
||||
In `admin/cmd/main.go`, before the HTTP server starts:
|
||||
|
||||
@@ -1217,7 +1217,7 @@ In `admin/cmd/main.go`, before the HTTP server starts:
|
||||
|
||||
Import `"github.com/mrhid6/vantage/admin/internal/inject"`.
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
- [x] **Step 3: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -1225,7 +1225,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Confirm the write surface by inspection**
|
||||
- [x] **Step 4: Confirm the write surface by inspection**
|
||||
|
||||
```bash
|
||||
grep -n "Control(" admin/internal/ -r
|
||||
@@ -1233,7 +1233,7 @@ grep -n "Control(" admin/internal/ -r
|
||||
|
||||
Expected: reads of `instances` and `users`, and writes only to `instances` with the three licence fields. Any other write target is a bug — fix it before continuing.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -1256,7 +1256,7 @@ git commit -m "feat(admin): cloud injection and the 15-minute reconciler"
|
||||
- `auth.HandleStaffLogin`, `auth.HandleLogout`
|
||||
- `adminctl staff-add`
|
||||
|
||||
- [ ] **Step 1: Write sessions**
|
||||
- [x] **Step 1: Write sessions**
|
||||
|
||||
Create `admin/internal/auth/session.go`:
|
||||
|
||||
@@ -1353,7 +1353,7 @@ func ClearCookie(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the middleware**
|
||||
- [x] **Step 2: Write the middleware**
|
||||
|
||||
Create `admin/internal/auth/middleware.go`:
|
||||
|
||||
@@ -1418,7 +1418,7 @@ func RequireCustomer() gin.HandlerFunc {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write staff login**
|
||||
- [x] **Step 3: Write staff login**
|
||||
|
||||
Create `admin/internal/auth/staff.go`:
|
||||
|
||||
@@ -1502,7 +1502,7 @@ func HandleLogout(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the rate limiter**
|
||||
- [x] **Step 4: Write the rate limiter**
|
||||
|
||||
Create `admin/internal/auth/ratelimit.go`:
|
||||
|
||||
@@ -1564,7 +1564,7 @@ func clearAttempts(email string) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write adminctl staff-add**
|
||||
- [x] **Step 5: Write adminctl staff-add**
|
||||
|
||||
Create `admin/cmd/adminctl/main.go`:
|
||||
|
||||
@@ -1654,7 +1654,7 @@ func fatal(format string, a ...any) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Initialise Redis at boot**
|
||||
- [x] **Step 6: Initialise Redis at boot**
|
||||
|
||||
In `admin/cmd/main.go`, after config loads:
|
||||
|
||||
@@ -1668,7 +1668,7 @@ In `admin/cmd/main.go`, after config loads:
|
||||
pingCancel()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
- [x] **Step 7: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -1676,7 +1676,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -1694,7 +1694,7 @@ git commit -m "feat(admin): sessions, staff auth and adminctl"
|
||||
- Consumes: `db.Control("users")`, `db.Control("instances")`, `shared/models`
|
||||
- Produces: `auth.HandleCloudLogin`
|
||||
|
||||
- [ ] **Step 1: Write it**
|
||||
- [x] **Step 1: Write it**
|
||||
|
||||
Create `admin/internal/auth/cloud.go`:
|
||||
|
||||
@@ -1797,7 +1797,7 @@ func HandleCloudLogin(c *gin.Context) {
|
||||
|
||||
Check the field names on `sharedmodels.User` before building — use `UserID`, `Email`, `PasswordHash`, `Role`, `InstanceID` as defined in `shared/models/user.go`, and correct this file if they differ.
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
- [x] **Step 2: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -1805,7 +1805,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output. (`HandleCustomerLogin` arrives in Task 7 — if you are building tasks in order, stub it as a 501 handler in `customer.go` and replace it there.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -1823,7 +1823,7 @@ git commit -m "feat(admin): cloud owner login against the control plane"
|
||||
- Consumes: `db.Admin("customer_users")`, config SMTP
|
||||
- Produces: `auth.HandleCustomerLogin`, `auth.CreateCustomerUser`, `auth.HandleVerify`, `mail.Send`, `mail.SendVerification`, `mail.SendLicense`
|
||||
|
||||
- [ ] **Step 1: Write mail**
|
||||
- [x] **Step 1: Write mail**
|
||||
|
||||
Create `admin/internal/mail/mail.go`:
|
||||
|
||||
@@ -1885,7 +1885,7 @@ func SendLicense(to, instanceName, blob string) error {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write customer auth**
|
||||
- [x] **Step 2: Write customer auth**
|
||||
|
||||
Create `admin/internal/auth/customer.go`:
|
||||
|
||||
@@ -2033,7 +2033,7 @@ func HandleCustomerLogin(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Initialise mail at boot**
|
||||
- [x] **Step 3: Initialise mail at boot**
|
||||
|
||||
In `admin/cmd/main.go`:
|
||||
|
||||
@@ -2048,7 +2048,7 @@ In `admin/cmd/main.go`:
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
- [x] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -2056,7 +2056,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -2079,7 +2079,7 @@ git commit -m "feat(admin): self-hosted customer accounts with email verificatio
|
||||
- `api.Routes(cfg) http.Handler`
|
||||
- `api.ownedInstance(c, instanceID) (*models.Instance, bool)` — the scoping helper
|
||||
|
||||
- [ ] **Step 1: Write linking and relink**
|
||||
- [x] **Step 1: Write linking and relink**
|
||||
|
||||
Create `admin/internal/licensing/link.go`:
|
||||
|
||||
@@ -2207,7 +2207,7 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the customer handlers**
|
||||
- [x] **Step 2: Write the customer handlers**
|
||||
|
||||
Create `admin/internal/api/customer.go`:
|
||||
|
||||
@@ -2386,7 +2386,7 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the route table**
|
||||
- [x] **Step 3: Write the route table**
|
||||
|
||||
Create `admin/internal/api/routes.go`:
|
||||
|
||||
@@ -2473,7 +2473,7 @@ func cors(allowed []string) gin.HandlerFunc {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mount it**
|
||||
- [x] **Step 4: Mount it**
|
||||
|
||||
In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
|
||||
|
||||
@@ -2483,7 +2483,7 @@ In `admin/cmd/main.go`, replace `Handler: http.NotFoundHandler()` with:
|
||||
|
||||
Import `"github.com/mrhid6/vantage/admin/internal/api"`.
|
||||
|
||||
- [ ] **Step 5: Build**
|
||||
- [x] **Step 5: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -2491,7 +2491,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: failures naming the staff handlers, which arrive in Task 9. Build again after Task 9.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -2509,7 +2509,7 @@ git commit -m "feat(admin): linking, relink and the scoped customer API"
|
||||
- Consumes: `licensing.Issue`, `licensing.Relink`, `models`
|
||||
- Produces: the handlers named in `routes.go`
|
||||
|
||||
- [ ] **Step 1: Write the handlers**
|
||||
- [x] **Step 1: Write the handlers**
|
||||
|
||||
Create `admin/internal/api/staff.go`:
|
||||
|
||||
@@ -2857,7 +2857,7 @@ func staffInjectionHealth(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
- [x] **Step 2: Build**
|
||||
|
||||
```bash
|
||||
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
@@ -2865,7 +2865,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
|
||||
|
||||
Expected: no output. This is the first build where the whole service compiles.
|
||||
|
||||
- [ ] **Step 3: Audit customer scoping by hand**
|
||||
- [x] **Step 3: Audit customer scoping by hand**
|
||||
|
||||
With no test suite, this replaces spec 3's table-driven test 20.
|
||||
|
||||
@@ -2876,7 +2876,7 @@ grep -n "func \(getAccount\|linkInstance\|relinkInstance\|getInstanceLicense\|do
|
||||
|
||||
Every customer route must either call `ownedInstance` or filter by `s.AccountID`. A route that does neither is a data leak — fix before continuing.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/
|
||||
@@ -2890,7 +2890,7 @@ git commit -m "feat(admin): staff API"
|
||||
**Files:**
|
||||
- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`
|
||||
|
||||
- [ ] **Step 1: Add the service**
|
||||
- [x] **Step 1: Add the service**
|
||||
|
||||
In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
|
||||
|
||||
@@ -2919,11 +2919,11 @@ In `deploy/docker-compose.site.yml`, alongside `site` and `sitesvc`:
|
||||
|
||||
`LICENSE_SIGNING_KEY` appears in exactly one service in exactly one compose file. It must never be added to `server`, and `deploy/docker-compose.yml` — the self-hosted deployment — must not mention admin at all.
|
||||
|
||||
- [ ] **Step 2: Add the build**
|
||||
- [x] **Step 2: Add the build**
|
||||
|
||||
In `.gitea/workflows/server-deploy.yml`, add a fourth image alongside `server`, `web`, `site` and `sitesvc`, with context `.` and file `admin/Dockerfile`, following the pattern the other Go services already use.
|
||||
|
||||
- [ ] **Step 3: Confirm the self-hosted deployment is untouched**
|
||||
- [x] **Step 3: Confirm the self-hosted deployment is untouched**
|
||||
|
||||
```bash
|
||||
grep -c "admin" deploy/docker-compose.yml
|
||||
@@ -2931,7 +2931,7 @@ grep -c "admin" deploy/docker-compose.yml
|
||||
|
||||
Expected: `0`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/ .gitea/
|
||||
@@ -2946,7 +2946,7 @@ Everything in containers. With no test suite this is the only evidence.
|
||||
|
||||
**Files:** none
|
||||
|
||||
- [ ] **Step 1: Build every module and the image**
|
||||
- [x] **Step 1: Build every module and the image**
|
||||
|
||||
```bash
|
||||
cd c:/Work/Repos/vantage
|
||||
@@ -2960,7 +2960,7 @@ docker build -q -f server/Dockerfile -t vantage-server:test .
|
||||
|
||||
Expected: all succeed. The image build is the one that catches a `go.sum` the workspace was masking — it caught exactly that in plan 2.
|
||||
|
||||
- [ ] **Step 2: Start the supporting containers**
|
||||
- [x] **Step 2: Start the supporting containers**
|
||||
|
||||
```bash
|
||||
docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine
|
||||
@@ -2968,7 +2968,7 @@ docker run -d --name vadmin-redis -p 6399:6379 redis:7-alpine
|
||||
|
||||
MongoDB is expected on the host at `27021`, as in plan 2.
|
||||
|
||||
- [ ] **Step 3: Boot the control plane and bootstrap a cloud instance**
|
||||
- [x] **Step 3: Boot the control plane and bootstrap a cloud instance**
|
||||
|
||||
```bash
|
||||
docker run -d --name vadmin-server -p 8080:8080 \
|
||||
@@ -2984,7 +2984,7 @@ curl -s -X POST localhost:8080/auth/bootstrap -H 'Content-Type: application/json
|
||||
|
||||
Record the `instance_id`.
|
||||
|
||||
- [ ] **Step 4: Boot admin**
|
||||
- [x] **Step 4: Boot admin**
|
||||
|
||||
```bash
|
||||
docker run -d --name vadmin -p 8083:8083 \
|
||||
@@ -3008,7 +3008,7 @@ docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb://
|
||||
|
||||
Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`.
|
||||
|
||||
- [ ] **Step 5: Adopt the cloud instance and issue through the staff API**
|
||||
- [x] **Step 5: Adopt the cloud instance and issue through the staff API**
|
||||
|
||||
This is the flow the admin UI will drive when you licence your existing cloud
|
||||
instances by hand.
|
||||
@@ -3043,7 +3043,7 @@ Expected: adopting the instance returns 201, issuing returns 201, and the contro
|
||||
plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds,
|
||||
with no restart**. This is the whole system working end to end.
|
||||
|
||||
- [ ] **Step 6: Confirm the Free rule and the deployment check**
|
||||
- [x] **Step 6: Confirm the Free rule and the deployment check**
|
||||
|
||||
Reusing the staff session and `$INSTANCE` from step 5:
|
||||
|
||||
@@ -3054,7 +3054,7 @@ curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt
|
||||
|
||||
Expected: `"that plan is not available for this deployment type"` — a self-hosted plan cannot be issued to a cloud instance, and the mirror of that check is what makes Free cloud-only.
|
||||
|
||||
- [ ] **Step 7: Confirm supersession and the snapshot rule**
|
||||
- [x] **Step 7: Confirm supersession and the snapshot rule**
|
||||
|
||||
```bash
|
||||
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
|
||||
@@ -3075,7 +3075,7 @@ curl -s "localhost:8083/api/staff/licenses?instance_id=$INSTANCE" -b /tmp/s.txt
|
||||
|
||||
Expected: the existing licences still report `max_servers: -1`. Editing a plan never rewrites history.
|
||||
|
||||
- [ ] **Step 8: Confirm reconciliation repairs a tampered instance**
|
||||
- [x] **Step 8: Confirm reconciliation repairs a tampered instance**
|
||||
|
||||
```bash
|
||||
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
|
||||
@@ -3093,7 +3093,7 @@ curl -s localhost:8080/api/license -b /tmp/a.txt
|
||||
|
||||
Expected: the control plane reports `valid` again once reconciliation has run — the blob is restored from what admin believes it should be. Confirm `reconcile: repairing instance` appears in `docker logs vadmin`.
|
||||
|
||||
- [ ] **Step 9: Confirm customer scoping returns 404, not 403**
|
||||
- [x] **Step 9: Confirm customer scoping returns 404, not 403**
|
||||
|
||||
Create a second account and instance via the staff API, sign in as the first customer, and request the second account's instance:
|
||||
|
||||
@@ -3104,7 +3104,7 @@ curl -s -o /dev/null -w "other account's instance: %{http_code}\n" \
|
||||
|
||||
Expected: **404**. A 403 would confirm the instance exists.
|
||||
|
||||
- [ ] **Step 10: Confirm admin is not a runtime dependency**
|
||||
- [x] **Step 10: Confirm admin is not a runtime dependency**
|
||||
|
||||
```bash
|
||||
docker stop vadmin
|
||||
@@ -3114,7 +3114,7 @@ curl -s -o /dev/null -w "create server: %{http_code}\n" -X POST localhost:8080/a
|
||||
|
||||
Expected: the licence still reports `valid` and mutations still work. **This is the most important check in the plan.** Instances verify offline and must never call admin.
|
||||
|
||||
- [ ] **Step 11: Confirm the control-plane write surface**
|
||||
- [x] **Step 11: Confirm the control-plane write surface**
|
||||
|
||||
```bash
|
||||
docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
|
||||
@@ -3124,7 +3124,7 @@ docker run --rm --add-host host.docker.internal:host-gateway mongo:7 mongosh \
|
||||
|
||||
Expected: `servers`, `keys`, `secrets` and every other collection are exactly as the control plane left them. Admin touched only `instances`.
|
||||
|
||||
- [ ] **Step 12: Clean up and commit**
|
||||
- [x] **Step 12: Clean up and commit**
|
||||
|
||||
```bash
|
||||
docker rm -f vadmin vadmin-server vadmin-redis
|
||||
|
||||
@@ -8,9 +8,9 @@ Seven specs, designed 2026-07-24. Build in this order.
|
||||
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
|
||||
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** |
|
||||
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start |
|
||||
|
||||
Specs 1 and 2 together give working licensing with licences cut by hand with
|
||||
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
|
||||
@@ -66,5 +66,9 @@ Free is cloud-only by construction: it is only ever signed with
|
||||
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
|
||||
no server-side flag to edit. One Free instance per account.
|
||||
|
||||
**Existing cloud tenants** are grandfathered to Professional, one year out, by
|
||||
migration `0005`.
|
||||
**Existing cloud tenants are not grandfathered.** The migration that would have
|
||||
done it was removed before plan 2 shipped, so every existing cloud instance is
|
||||
read-only until it is licensed by hand through the admin service: attach it to an
|
||||
account with `POST /api/staff/instances`, then `POST /api/staff/instances/:id/issue`.
|
||||
That flow is verified in plan 3, so it works today via the API and is the first
|
||||
job the admin UI is used for.
|
||||
|
||||
+25
-1
@@ -2,17 +2,41 @@ cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//u
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
|
||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/mrhid6/vantage/server
|
||||
|
||||
go 1.26.4
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/mrhid6/vantage/shared
|
||||
|
||||
go 1.26.4
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/mrhid6/vantage/sitesvc
|
||||
|
||||
go 1.26.4
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
Reference in New Issue
Block a user