diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 0000000..d4daee3 --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,2 @@ +.env +*.lic diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..b1d9fdd --- /dev/null +++ b/admin/Dockerfile @@ -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"] diff --git a/admin/cmd/main.go b/admin/cmd/main.go new file mode 100644 index 0000000..23cd0b9 --- /dev/null +++ b/admin/cmd/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/mrhid6/vantage/admin/internal/db" +) + +func main() { + godotenv.Load() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) + } + + 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) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: http.NotFoundHandler(), // replaced in Task 8 + 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) +} diff --git a/admin/go.mod b/admin/go.mod new file mode 100644 index 0000000..0fa1584 --- /dev/null +++ b/admin/go.mod @@ -0,0 +1,21 @@ +module github.com/mrhid6/vantage/admin + +go 1.26.4 + +require ( + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver/v2 v2.8.0 +) + +require ( + github.com/klauspost/compress v1.17.6 // 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 + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) + +replace github.com/mrhid6/vantage/shared => ../shared diff --git a/admin/go.sum b/admin/go.sum new file mode 100644 index 0000000..a428327 --- /dev/null +++ b/admin/go.sum @@ -0,0 +1,48 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/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= +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= +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/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/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= diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go new file mode 100644 index 0000000..91d5043 --- /dev/null +++ b/admin/internal/config/config.go @@ -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 +} diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go new file mode 100644 index 0000000..fedfb90 --- /dev/null +++ b/admin/internal/db/db.go @@ -0,0 +1,61 @@ +// 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/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) +} diff --git a/go.work b/go.work index c525707..9e3bdca 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,7 @@ go 1.26.4 use ( + ./admin ./server ./shared ./sitesvc