Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions .github/workflows/google.yml
Original file line number Diff line number Diff line change
@@ -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-<pair>` 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<<EOF\n$TAGS\nEOF" >> $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 }}
1 change: 1 addition & 0 deletions core/jobs/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var InternalServiceAccountNames = []string{
"sentinel-discord",
"sentinel-oauth",
"sentinel-saml",
"sentinel-google",
}

// IsInternalServiceAccountName reports whether name is on the
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,4 +187,5 @@ volumes:
discord_gopath:
oauth_gopath:
saml_gopath:
google_gopath:
web_node_modules:
21 changes: 21 additions & 0 deletions google/.air.toml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions google/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
49 changes: 49 additions & 0 deletions google/api/api.go
Original file line number Diff line number Diff line change
@@ -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()
}
90 changes: 90 additions & 0 deletions google/api/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading