diff --git a/.github/workflows/google.yml b/.github/workflows/google.yml new file mode 100644 index 0000000..bfb238d --- /dev/null +++ b/.github/workflows/google.yml @@ -0,0 +1,162 @@ +name: google +run-name: Triggered by ${{ github.event_name }} to ${{ github.ref }} by @${{ github.actor }} + +on: + push: + branches: + - "**" + tags: + - "**" + +jobs: + build: + runs-on: ${{ matrix.runner }} + name: Build ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate platform pair + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}" >> $GITHUB_OUTPUT + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v5 + with: + context: google + platforms: ${{ matrix.platform }} + outputs: type=image,name=ghcr.io/gaucho-racing/sentinel-google,push-by-digest=true,name-canonical=true,push=true + # Per-workflow + per-ref cache scopes. Without github.workflow, + # core/oauth/discord would all race on `build-` since they + # fire on every push. Without github.ref_name, simultaneous + # main+tag pushes from a release commit race on the same scope + # and can poison each other's layers (the same class of bug hit + # mapache on v1.2.x: :latest ended up with a 0-byte binary). + # Tag/feature builds fall back to main's cache to stay warm. + cache-from: | + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }} + type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-main + cache-to: type=gha,scope=build-${{ github.workflow }}-${{ steps.platform.outputs.pair }}-${{ github.ref_name }},mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + name: Merge manifests + needs: build + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if this commit has a release tag + id: release + run: | + tag=$(git tag --points-at HEAD | grep '^v' | head -n1) + if [ -n "$tag" ]; then + echo "Found tag: $tag" + if gh release view "$tag" --json tagName > /dev/null 2>&1; then + echo "release_tag=$tag" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + exit 0 + fi + fi + echo "is_release=false" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate tag list + id: tags + shell: bash + run: | + TAGS="type=sha" + + if [ "${GITHUB_REF_TYPE}" = "branch" ] && [ "${GITHUB_REF_NAME}" = "main" ]; then + TAGS="${TAGS}\ntype=raw,value=latest" + fi + + if [ "${{ steps.release.outputs.is_release }}" = "true" ]; then + CLEAN_TAG=$(echo "${{ steps.release.outputs.release_tag }}" | sed 's/^v//') + TAGS="${TAGS}\ntype=raw,value=${CLEAN_TAG}" + fi + + echo -e "tags<> $GITHUB_OUTPUT + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/gaucho-racing/sentinel-google + tags: ${{ steps.tags.outputs.tags }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/gaucho-racing/sentinel-google@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ghcr.io/gaucho-racing/sentinel-google:${{ steps.meta.outputs.version }} diff --git a/core/jobs/init.go b/core/jobs/init.go index af15eee..4a4366c 100644 --- a/core/jobs/init.go +++ b/core/jobs/init.go @@ -30,6 +30,7 @@ var InternalServiceAccountNames = []string{ "sentinel-discord", "sentinel-oauth", "sentinel-saml", + "sentinel-google", } // IsInternalServiceAccountName reports whether name is on the diff --git a/docker-compose.yml b/docker-compose.yml index 2a87f6c..53332e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,6 +127,31 @@ services: ISSUER: http://localhost:10310 INTERNAL_BOOTSTRAP_SECRET: ${INTERNAL_BOOTSTRAP_SECRET} + google: + container_name: sentinel-google + image: golang:1.26-alpine + restart: always + working_dir: /app + command: > + sh -c "go install github.com/air-verse/air@latest && air" + volumes: + - ./google:/app + - google_gopath:/go + depends_on: + - db + environment: + ENV: DEV + PORT: 9995 + DATABASE_HOST: db + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_NAME: sentinel + KERBECS_ENDPOINT: http://kerbecs:10300 + KERBECS_USER: admin + KERBECS_PASSWORD: admin + INTERNAL_BOOTSTRAP_SECRET: ${INTERNAL_BOOTSTRAP_SECRET} + web: container_name: sentinel-web image: node:22-alpine @@ -162,4 +187,5 @@ volumes: discord_gopath: oauth_gopath: saml_gopath: + google_gopath: web_node_modules: diff --git a/google/.air.toml b/google/.air.toml new file mode 100644 index 0000000..3d5c83f --- /dev/null +++ b/google/.air.toml @@ -0,0 +1,21 @@ +root = "." +tmp_dir = "tmp" + +[build] + bin = "./tmp/main" + cmd = "go mod tidy && go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["tmp", "vendor"] + exclude_regex = ["_test.go"] + include_ext = ["go", "toml"] + kill_delay = "0s" + send_interrupt = false + poll = true + poll_interval = 500 + stop_on_error = true + +[log] + time = false + +[misc] + clean_on_exit = true diff --git a/google/Dockerfile b/google/Dockerfile new file mode 100644 index 0000000..435e6e9 --- /dev/null +++ b/google/Dockerfile @@ -0,0 +1,27 @@ +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +RUN apk --no-cache add ca-certificates +RUN apk add --no-cache tzdata + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +COPY . ./ +ARG TARGETOS +ARG TARGETARCH +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /server + +## +## Deploy +## +FROM alpine:3.19 + +COPY --from=builder /server /server + +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +ENV TZ=America/Los_Angeles + +ENTRYPOINT ["/server"] diff --git a/google/api/api.go b/google/api/api.go new file mode 100644 index 0000000..3ed67e8 --- /dev/null +++ b/google/api/api.go @@ -0,0 +1,49 @@ +package api + +import ( + "time" + + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func Run() { + api := InitializeRouter() + InitializeRoutes(api) + err := api.Run(":" + config.Port) + if err != nil { + logger.SugarLogger.Fatalf("Failed to start server: %v", err) + } +} + +func InitializeRouter() *gin.Engine { + if config.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + MaxAge: 12 * time.Hour, + AllowCredentials: true, + })) + r.Use(AuthChecker()) + r.Use(UnauthorizedPanicHandler()) + return r +} + +func InitializeRoutes(router *gin.Engine) { + router.GET("/google/ping", Ping) +} + +// GetClientIP returns the originating client IP, preferring Cloudflare's +// unspoofable CF-Connecting-IP and falling back to gin's c.ClientIP(). +func GetClientIP(c *gin.Context) string { + if ip := c.GetHeader("CF-Connecting-IP"); ip != "" { + return ip + } + return c.ClientIP() +} diff --git a/google/api/auth.go b/google/api/auth.go new file mode 100644 index 0000000..bce7b43 --- /dev/null +++ b/google/api/auth.go @@ -0,0 +1,90 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "github.com/gaucho-racing/sentinel/google/pkg/sentinel" + "github.com/gin-gonic/gin" +) + +// AuthChecker is a soft middleware: when Authorization: Bearer is +// present it validates the JWT against core's /core/token/validate and +// stashes (sub, scope) on the context. Handlers that need auth call +// Require(...) themselves; public endpoints (ping) keep working without +// a bearer. +// +// Mirrors core/api/AuthChecker so handlers can use the same Require / +// RequestTokenHas* helpers core does. +func AuthChecker() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + var claims map[string]interface{} + if err := sentinel.Post("/api/core/token/validate", map[string]string{"token": token}, &claims); err != nil { + logger.SugarLogger.Errorf("Failed to validate token: %v", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + c.Set("Auth-Token", token) + if sub, ok := claims["sub"].(string); ok { + c.Set("Auth-EntityID", sub) + } + if scope, ok := claims["scope"].(string); ok { + c.Set("Auth-Scope", scope) + } + } + c.Next() + } +} + +// UnauthorizedPanicHandler converts Require()'s panic into a 401. Any +// other panic is logged and returned as a 500. Same shape as core. +func UnauthorizedPanicHandler() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + if err == "Unauthorized" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "you are not authorized to access this resource"}) + return + } + logger.SugarLogger.Errorf("Unexpected panic: %v", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + } + }() + c.Next() + } +} + +func Require(c *gin.Context, condition bool) { + if !condition { + panic("Unauthorized") + } +} + +func GetRequestTokenEntityID(c *gin.Context) string { + id, ok := c.Get("Auth-EntityID") + if !ok { + return "" + } + return id.(string) +} + +func RequestTokenHasEntityID(c *gin.Context, entityID string) bool { + return GetRequestTokenEntityID(c) == entityID +} + +func RequestTokenHasScope(c *gin.Context, scope string) bool { + scopes, ok := c.Get("Auth-Scope") + if !ok { + return false + } + for _, s := range strings.Split(scopes.(string), " ") { + if s == scope { + return true + } + } + return false +} diff --git a/google/api/ping.go b/google/api/ping.go new file mode 100644 index 0000000..b841160 --- /dev/null +++ b/google/api/ping.go @@ -0,0 +1,12 @@ +package api + +import ( + "net/http" + + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gin-gonic/gin" +) + +func Ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": config.FormattedNameWithVersion() + " is online!"}) +} diff --git a/google/config/banner.go b/google/config/banner.go new file mode 100644 index 0000000..8a081dd --- /dev/null +++ b/google/config/banner.go @@ -0,0 +1,20 @@ +package config + +import "github.com/fatih/color" + +var Banner = ` +███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗ +██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║ +███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║ +╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║ +███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗ +╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ +` + +func PrintStartupBanner() { + banner := color.New(color.Bold, color.FgHiMagenta).PrintlnFunc() + banner(Banner) + version := color.New(color.Bold, color.FgMagenta).PrintlnFunc() + version("Running " + FormattedNameWithVersion() + " [ENV: " + Env + "]") + println() +} diff --git a/google/config/config.go b/google/config/config.go new file mode 100644 index 0000000..a193cf3 --- /dev/null +++ b/google/config/config.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" +) + +const Name = "sentinel-google" +const Version = "5.6.4" + +func FormattedNameWithVersion() string { + return Name + ":v" + Version +} + +var Env = os.Getenv("ENV") +var Port = os.Getenv("PORT") + +// Kerbecs admin API — the gateway doubles as the service registry. The sentinel +// client resolves gateway-form paths (/api/core/...) to upstream URLs via its +// /admin-gw/resolve endpoint, which sits behind basic auth. +var KerbecsEndpoint = os.Getenv("KERBECS_ENDPOINT") +var KerbecsUser = os.Getenv("KERBECS_USER") +var KerbecsPassword = os.Getenv("KERBECS_PASSWORD") + +var DatabaseHost = os.Getenv("DATABASE_HOST") +var DatabasePort = os.Getenv("DATABASE_PORT") +var DatabaseUser = os.Getenv("DATABASE_USER") +var DatabasePassword = os.Getenv("DATABASE_PASSWORD") +var DatabaseName = os.Getenv("DATABASE_NAME") + +// InternalBootstrapSecret is the shared secret this service uses at +// startup to exchange for its pre-seeded bearer JWT from core. Must +// match core's INTERNAL_BOOTSTRAP_SECRET. +var InternalBootstrapSecret = os.Getenv("INTERNAL_BOOTSTRAP_SECRET") + +// InternalServiceName is the SA name on core that this service exchanges +// the bootstrap secret for. Must match a value in +// core/jobs/init.go::InternalServiceAccountNames. +const InternalServiceName = "sentinel-google" + +func IsProduction() bool { + return Env == "PROD" +} diff --git a/google/config/verify.go b/google/config/verify.go new file mode 100644 index 0000000..5af1936 --- /dev/null +++ b/google/config/verify.go @@ -0,0 +1,48 @@ +package config + +import ( + "github.com/gaucho-racing/sentinel/google/pkg/logger" +) + +func Verify() { + if Env == "" { + Env = "PROD" + logger.SugarLogger.Infof("ENV is not set, defaulting to %s", Env) + } + if Port == "" { + Port = "9995" + logger.SugarLogger.Infof("PORT is not set, defaulting to %s", Port) + } + if DatabaseHost == "" { + DatabaseHost = "localhost" + logger.SugarLogger.Infof("DATABASE_HOST is not set, defaulting to %s", DatabaseHost) + } + if DatabasePort == "" { + DatabasePort = "5432" + logger.SugarLogger.Infof("DATABASE_PORT is not set, defaulting to %s", DatabasePort) + } + if DatabaseUser == "" { + DatabaseUser = "postgres" + logger.SugarLogger.Infof("DATABASE_USER is not set, defaulting to %s", DatabaseUser) + } + if DatabasePassword == "" { + DatabasePassword = "password" + logger.SugarLogger.Infof("DATABASE_PASSWORD is not set, defaulting to %s", DatabasePassword) + } + if DatabaseName == "" { + DatabaseName = "sentinel" + logger.SugarLogger.Infof("DATABASE_NAME is not set, defaulting to %s", DatabaseName) + } + if KerbecsEndpoint == "" { + KerbecsEndpoint = "http://localhost:10300" + logger.SugarLogger.Infof("KERBECS_ENDPOINT is not set, defaulting to %s", KerbecsEndpoint) + } + if KerbecsUser == "" { + KerbecsUser = "admin" + logger.SugarLogger.Infof("KERBECS_USER is not set, defaulting to %s", KerbecsUser) + } + if KerbecsPassword == "" { + KerbecsPassword = "admin" + logger.SugarLogger.Infoln("KERBECS_PASSWORD is not set, defaulting to \"admin\" — DO NOT USE IN PRODUCTION") + } +} diff --git a/google/database/db.go b/google/database/db.go new file mode 100644 index 0000000..1a77abf --- /dev/null +++ b/google/database/db.go @@ -0,0 +1,34 @@ +package database + +import ( + "fmt" + "time" + + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +var dbRetries = 0 + +func Init() { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC", config.DatabaseHost, config.DatabaseUser, config.DatabasePassword, config.DatabaseName, config.DatabasePort) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + if dbRetries < 5 { + dbRetries++ + logger.SugarLogger.Errorln("failed to connect database, retrying in 5s... ") + time.Sleep(time.Second * 5) + Init() + } else { + logger.SugarLogger.Fatalf("failed to connect database after 5 attempts") + } + } else { + logger.SugarLogger.Infoln("Connected to database") + // Models and AutoMigration are added with the group-binding work. + DB = db + } +} diff --git a/google/go.mod b/google/go.mod new file mode 100644 index 0000000..ce72023 --- /dev/null +++ b/google/go.mod @@ -0,0 +1,54 @@ +module github.com/gaucho-racing/sentinel/google + +go 1.25.6 + +require ( + github.com/fatih/color v1.19.0 + github.com/gin-contrib/cors v1.7.7 + github.com/gin-gonic/gin v1.12.0 + github.com/go-resty/resty/v2 v2.17.2 + go.uber.org/zap v1.28.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +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/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/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // 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/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/google/go.sum b/google/go.sum new file mode 100644 index 0000000..923c34c --- /dev/null +++ b/google/go.sum @@ -0,0 +1,126 @@ +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +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/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= +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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.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= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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/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= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/google/main.go b/google/main.go new file mode 100644 index 0000000..9fab0cb --- /dev/null +++ b/google/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/gaucho-racing/sentinel/google/api" + "github.com/gaucho-racing/sentinel/google/config" + "github.com/gaucho-racing/sentinel/google/database" + "github.com/gaucho-racing/sentinel/google/pkg/kerbecs" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "github.com/gaucho-racing/sentinel/google/pkg/sentinel" +) + +func main() { + logger.Init(config.IsProduction()) + defer logger.Logger.Sync() + + config.Verify() + config.PrintStartupBanner() + kerbecs.Init(config.KerbecsEndpoint, config.KerbecsUser, config.KerbecsPassword) + + // Exchange the shared bootstrap secret for this service's pre-seeded + // bearer JWT. From here on, every outbound sentinel-client call + // carries Authorization: Bearer . + if err := sentinel.Bootstrap(config.InternalServiceName, config.InternalBootstrapSecret); err != nil { + logger.SugarLogger.Fatalf("Failed to bootstrap service token: %v", err) + } + + database.Init() + + api.Run() +} diff --git a/google/pkg/kerbecs/kerbecs.go b/google/pkg/kerbecs/kerbecs.go new file mode 100644 index 0000000..7411fc6 --- /dev/null +++ b/google/pkg/kerbecs/kerbecs.go @@ -0,0 +1,113 @@ +// Package kerbecs resolves gateway-form paths (e.g. /api/core/entity/1) to the +// concrete upstream URL to call, by asking the kerbecs gateway's admin resolve +// endpoint. It replaces external service-registry route matching: kerbecs is +// already the routing source of truth, so we ask it where a request should go +// and cache the answer locally. +package kerbecs + +import ( + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" +) + +const cacheTTL = 5 * time.Minute + +var ( + endpoint string + user string + password string +) + +type entry struct { + url string + exp time.Time +} + +var ( + mu sync.RWMutex + cache = map[string]entry{} +) + +// resolve is a GET, so retries are safe. +var client = resty.New(). + SetTimeout(5 * time.Second). + SetRetryCount(2). + SetRetryWaitTime(100 * time.Millisecond). + AddRetryCondition(func(r *resty.Response, err error) bool { + return err != nil || (r != nil && r.StatusCode() >= 500) + }) + +// Init configures the resolver against the kerbecs admin API. No connection is +// made here — lookups happen lazily on first Resolve — and a background sweeper +// is started to evict expired cache entries. +func Init(adminEndpoint, adminUser, adminPassword string) { + endpoint = strings.TrimRight(adminEndpoint, "/") + user = adminUser + password = adminPassword + go sweep() +} + +type resolveResponse struct { + Matched bool `json:"matched"` + URL string `json:"url"` + RewrittenPath string `json:"rewritten_path"` +} + +// Resolve maps a gateway-form path (e.g. /api/core/entity/1) and HTTP method to +// the full upstream URL to call. Answers are cached for cacheTTL. +func Resolve(method, path string) (string, error) { + if endpoint == "" { + return "", fmt.Errorf("kerbecs resolver not initialized") + } + key := method + " " + path + + mu.RLock() + if e, ok := cache[key]; ok && time.Now().Before(e.exp) { + mu.RUnlock() + return e.url, nil + } + mu.RUnlock() + + var rr resolveResponse + resp, err := client.R(). + SetBasicAuth(user, password). + SetQueryParam("path", path). + SetQueryParam("method", method). + SetResult(&rr). + Get(endpoint + "/admin-gw/resolve") + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + if resp.StatusCode() == http.StatusNotFound || !rr.Matched { + return "", fmt.Errorf("no upstream registered for %s", path) + } + if resp.IsError() { + return "", fmt.Errorf("resolve %s: kerbecs returned %d", path, resp.StatusCode()) + } + + full := strings.TrimRight(rr.URL, "/") + rr.RewrittenPath + mu.Lock() + cache[key] = entry{url: full, exp: time.Now().Add(cacheTTL)} + mu.Unlock() + return full, nil +} + +// sweep periodically evicts expired entries so high-cardinality paths (entity +// and token IDs) don't grow the cache without bound. +func sweep() { + for range time.Tick(cacheTTL) { + now := time.Now() + mu.Lock() + for k, e := range cache { + if now.After(e.exp) { + delete(cache, k) + } + } + mu.Unlock() + } +} diff --git a/google/pkg/logger/logger.go b/google/pkg/logger/logger.go new file mode 100644 index 0000000..2722d06 --- /dev/null +++ b/google/pkg/logger/logger.go @@ -0,0 +1,16 @@ +package logger + +import ( + "go.uber.org/zap" +) + +var Logger *zap.Logger +var SugarLogger *zap.SugaredLogger + +func Init(production bool) { + Logger = zap.Must(zap.NewProduction()) + if !production { + Logger = zap.Must(zap.NewDevelopment(zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))) + } + SugarLogger = Logger.Sugar() +} diff --git a/google/pkg/sentinel/sentinel.go b/google/pkg/sentinel/sentinel.go new file mode 100644 index 0000000..cc486fd --- /dev/null +++ b/google/pkg/sentinel/sentinel.go @@ -0,0 +1,204 @@ +package sentinel + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gaucho-racing/sentinel/google/pkg/kerbecs" + "github.com/gaucho-racing/sentinel/google/pkg/logger" + "github.com/go-resty/resty/v2" +) + +// bearer is the service-account JWT this process uses to authenticate +// to other Sentinel services. Configured once at startup via Bootstrap. +// Reads via RLock so the request hot path doesn't serialize on writes. +var ( + bearer string + bearerMu sync.RWMutex +) + +// SetBearer wires a bearer token into the client. Subsequent Get/Post/... +// calls send it as Authorization: Bearer. Empty string clears the +// header — useful for tests that want to exercise the unauth'd path. +func SetBearer(token string) { + bearerMu.Lock() + defer bearerMu.Unlock() + bearer = token +} + +func getBearer() string { + bearerMu.RLock() + defer bearerMu.RUnlock() + return bearer +} + +// Bootstrap exchanges INTERNAL_BOOTSTRAP_SECRET for this service's +// pre-seeded bearer JWT and configures the client. Call once at +// startup, before any other sentinel request. The bootstrap call +// itself goes out without a bearer; core's /core/internal/bootstrap-token +// validates the shared secret in the X-Bootstrap-Secret header instead. +// +// Retries with linear backoff (~10s total) to absorb the docker-compose +// boot race — sentinel-core's HTTP listener may not be up the moment +// this service starts, even with depends_on. +func Bootstrap(serviceName, secret string) error { + if secret == "" { + return errors.New("INTERNAL_BOOTSTRAP_SECRET is not configured") + } + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * time.Second) + } + var out struct { + Token string `json:"token"` + } + err := Post( + "/api/core/internal/bootstrap-token", + map[string]string{"name": serviceName}, + &out, + map[string]string{"X-Bootstrap-Secret": secret}, + ) + if err == nil && out.Token != "" { + SetBearer(out.Token) + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = errors.New("bootstrap exchange returned empty token") + } + logger.SugarLogger.Warnf("bootstrap attempt %d failed: %v", attempt+1, lastErr) + } + return fmt.Errorf("bootstrap failed after retries: %w", lastErr) +} + +// Sentinel-side error categories — wrapped into APIError.Err so callers +// can errors.Is on them and pick the right user-facing message. +var ( + ErrRouteResolution = errors.New("could not resolve route via kerbecs") +) + +// A short per-request timeout plus a couple of retries softens transient core +// blips so authz-relevant reads (group links, entity groups) don't fail closed +// over a momentary hiccup. Retries are limited to idempotent GETs — retrying a +// POST (token mint, login record) could double-issue. +var client = resty.New(). + SetTimeout(5 * time.Second). + SetRetryCount(2). + SetRetryWaitTime(100 * time.Millisecond). + AddRetryCondition(func(r *resty.Response, err error) bool { + if r == nil || r.Request == nil || r.Request.Method != http.MethodGet { + return false + } + return err != nil || r.StatusCode() >= 500 + }) + +// APIError is returned by every method in this package. Status == 0 means no +// HTTP response was received (route resolution failure or transport error). +// Status > 0 means the upstream replied with that status code. Callers should +// use errors.As to inspect it and decide how to surface to their own +// response — most importantly, a 4xx from upstream should NOT collapse to a +// generic "service unavailable" on the user-facing side. +type APIError struct { + Method string + Route string + Status int // 0 when no HTTP response was received + Body string // raw response body + Message string // parsed "error" field from a JSON body, when present + Err error // underlying transport or resolution error +} + +func (e *APIError) Error() string { + if e.Status == 0 { + if e.Err != nil { + return fmt.Sprintf("%s %s: %v", e.Method, e.Route, e.Err) + } + return fmt.Sprintf("%s %s: no response", e.Method, e.Route) + } + if e.Message != "" { + return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Route, e.Status, e.Message) + } + return fmt.Sprintf("%s %s returned %d", e.Method, e.Route, e.Status) +} + +func (e *APIError) Unwrap() error { return e.Err } + +func resolveURL(route string, method string) (string, error) { + url, err := kerbecs.Resolve(method, route) + if err != nil { + return "", fmt.Errorf("%w: %s: %v", ErrRouteResolution, route, err) + } + return url, nil +} + +// do executes the request and converts any failure path into an *APIError. +// success returns nil; resty unmarshals the response body into result for us. +func do(method, route string, body, result interface{}, headers []map[string]string) error { + url, err := resolveURL(route, method) + if err != nil { + return &APIError{Method: method, Route: route, Err: err} + } + req := client.R() + // Attach the service's bearer when one is set — Bootstrap installs + // it at startup. The explicit `headers` param (used by Bootstrap + // itself for the X-Bootstrap-Secret header) is additive, applied + // after SetAuthToken. + if b := getBearer(); b != "" { + req = req.SetAuthToken(b) + } + if body != nil { + req = req.SetBody(body) + } + if result != nil { + req = req.SetResult(result) + } + if len(headers) > 0 { + req = req.SetHeaders(headers[0]) + } + resp, err := req.Execute(method, url) + if err != nil { + return &APIError{Method: method, Route: route, Err: err} + } + if resp.IsError() { + ae := &APIError{ + Method: method, + Route: route, + Status: resp.StatusCode(), + Body: resp.String(), + } + var parsed struct { + Error string `json:"error"` + } + if json.Unmarshal(resp.Body(), &parsed) == nil && parsed.Error != "" { + ae.Message = parsed.Error + } + logger.SugarLogger.Errorf("%s %s returned %d: %s", method, route, resp.StatusCode(), resp.String()) + return ae + } + return nil +} + +func Get(route string, result interface{}, headers ...map[string]string) error { + return do("GET", route, nil, result, headers) +} + +func Post(route string, body interface{}, result interface{}, headers ...map[string]string) error { + return do("POST", route, body, result, headers) +} + +func Put(route string, body interface{}, result interface{}, headers ...map[string]string) error { + return do("PUT", route, body, result, headers) +} + +func Patch(route string, body interface{}, result interface{}, headers ...map[string]string) error { + return do("PATCH", route, body, result, headers) +} + +func Delete(route string, result interface{}, headers ...map[string]string) error { + return do("DELETE", route, nil, result, headers) +} diff --git a/scripts/release.sh b/scripts/release.sh index 365ce97..f905ab0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -91,8 +91,8 @@ fi # Go services have a Version constant in config/config.go that needs bumping. # IMAGES is the full set whose workflows publish on a tag push — web is # tag-triggered too but has no Go version constant to bump. -GO_SERVICES=("core" "oauth" "discord" "saml") -IMAGES=("core" "oauth" "discord" "saml" "web") +GO_SERVICES=("core" "oauth" "discord" "saml" "google") +IMAGES=("core" "oauth" "discord" "saml" "google" "web") echo "" echo "=== Release Summary ==="