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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Server
GAP_SERVER_ADDR=:8080
GAP_SERVER_ADMIN_KEY=change-me-admin-key
# Slowloris guards and the cap on a proxied client request body (413 above it).
GAP_SERVER_READ_TIMEOUT=60s
GAP_SERVER_IDLE_TIMEOUT=120s
GAP_SERVER_MAX_REQUEST_BYTES=10485760

# Auth (Grok CLI auth.json)
GAP_AUTH_FILE=/config/auth.json
Expand All @@ -25,6 +29,7 @@ GAP_LOG_LEVEL=info
GAP_LOG_REDACT=true

# Audit (request/response bodies stored in DB; access via /admin/audit)
# max_body_bytes caps only what is STORED; it never truncates the proxied request.
GAP_AUDIT_ENABLED=true
GAP_AUDIT_MAX_BODY_BYTES=65536

Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Build and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Check formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-formatted:"
echo "$unformatted"
gofmt -d .
exit 1
fi

- name: Verify go.mod is tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...

- name: Test with race detector
run: go test -race ./...
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ Priority: **flags → env (`GAP_*`) → config file → defaults**.
|----------|---------|-------------|
| `GAP_SERVER_ADDR` | `:8080` | Listen address |
| `GAP_SERVER_ADMIN_KEY` | **required** | Admin API secret |
| `GAP_SERVER_READ_TIMEOUT` | `60s` | Max time to read a client request (slowloris guard) |
| `GAP_SERVER_IDLE_TIMEOUT` | `120s` | Max idle keep-alive connection lifetime |
| `GAP_SERVER_MAX_REQUEST_BYTES` | `10485760` | Largest proxied request body; larger gets `413` |
| `GAP_AUTH_FILE` | `./auth.json` | Path to Grok `auth.json` |
| `GAP_AUTH_UPSTREAM_BASE` | `https://api.x.ai/v1` | Upstream API base |
| `GAP_AUTH_REFRESH_SKEW` | `5m` | Refresh before expiry |
Expand All @@ -110,7 +113,7 @@ Priority: **flags → env (`GAP_*`) → config file → defaults**.
| `GAP_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` |
| `GAP_LOG_REDACT` | `true` | Redact secrets in logs |
| `GAP_AUDIT_ENABLED` | `true` | Store request/response bodies in DB |
| `GAP_AUDIT_MAX_BODY_BYTES` | `65536` | Max body size stored per side |
| `GAP_AUDIT_MAX_BODY_BYTES` | `65536` | Max body size **stored** per side (does not truncate what is proxied) |
| `GAP_METRICS_ENABLED` | `true` | Prometheus metrics |
| `GAP_CONFIG` | | Optional config file path |

Expand Down
8 changes: 8 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ server:
addr: ":8080"
admin_key: "change-me-admin-key"
shutdown_timeout: 15s
# Bound how long a client may take to send a request and how long an idle
# keep-alive connection is held; without these a slow client can pin sockets.
read_timeout: 60s
idle_timeout: 120s
# Largest proxied client request body. Bigger requests get 413.
max_request_bytes: 10485760

auth:
file: "./auth.json"
Expand Down Expand Up @@ -33,6 +39,8 @@ log:
# Proxied request/response bodies stored in DB (admin /admin/audit).
audit:
enabled: true
# Caps only what is stored per side. It does NOT truncate the request that is
# forwarded upstream — see server.max_request_bytes for that.
max_body_bytes: 65536

metrics:
Expand Down
3 changes: 3 additions & 0 deletions deploy/helm/grok-auth-proxy/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ metadata:
{{- include "grok-auth-proxy.labels" . | nindent 4 }}
data:
GAP_SERVER_ADDR: {{ .Values.config.serverAddr | quote }}
GAP_SERVER_READ_TIMEOUT: {{ .Values.config.serverReadTimeout | default "60s" | quote }}
GAP_SERVER_IDLE_TIMEOUT: {{ .Values.config.serverIdleTimeout | default "120s" | quote }}
GAP_SERVER_MAX_REQUEST_BYTES: {{ .Values.config.serverMaxRequestBytes | default "10485760" | quote }}
GAP_AUTH_FILE: {{ .Values.config.authFile | quote }}
GAP_AUTH_UPSTREAM_BASE: {{ .Values.config.upstreamBase | quote }}
GAP_AUTH_REFRESH_SKEW: {{ .Values.config.refreshSkew | quote }}
Expand Down
6 changes: 6 additions & 0 deletions deploy/helm/grok-auth-proxy/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ resources:
# Non-secret configuration (rendered into ConfigMap / env)
config:
serverAddr: ":8080"
# Request read / idle connection timeouts (slowloris guards).
serverReadTimeout: 60s
serverIdleTimeout: 120s
# Largest proxied client request body; bigger requests get 413.
serverMaxRequestBytes: "10485760"
# When seedToDataVolume=false, mount auth secret here (read-only).
authFile: /config/auth.json
upstreamBase: https://api.x.ai/v1
Expand All @@ -66,6 +71,7 @@ config:
logRedact: "true"
metricsEnabled: "true"
auditEnabled: "true"
# Caps only what is stored in the audit log, not what is proxied.
auditMaxBodyBytes: "65536"

# External Postgres DSN (recommended). Key should contain the full libpq/GORM DSN.
Expand Down
8 changes: 7 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ Upstream (xAI) errors are forwarded as-is (status code and body), for example:
| `400` | Bad client JSON (admin) or upstream validation (e.g. unknown model) |
| `401` | Missing/invalid API key or admin key |
| `404` | Unknown path (e.g. `/v1/v1/chat/completions`) or unknown key id |
| `413` | Request body larger than `GAP_SERVER_MAX_REQUEST_BYTES` (default 10 MiB) |
| `429` | Per-key rate limit exceeded |
| `500` | Internal (DB, reload failure, …) |
| `502` | Upstream request failed / unauthorized after refresh |
Expand Down Expand Up @@ -577,7 +578,12 @@ curl -sS "http://localhost:8080/admin/audit?limit=20&path=/v1/chat/completions"
}
```

Bodies are truncated at `GAP_AUDIT_MAX_BODY_BYTES` (default 64 KiB) per side. Streaming responses store the first N bytes only.
Stored bodies are truncated at `GAP_AUDIT_MAX_BODY_BYTES` (default 64 KiB) per side, and the
`request_truncated` / `response_truncated` flags say so. This limit applies only to what is
persisted — the request forwarded to xAI is never truncated. The proxied request body is
bounded separately by `GAP_SERVER_MAX_REQUEST_BYTES` (default 10 MiB); larger requests are
rejected with `413 Request Entity Too Large` and never reach the upstream. Streaming responses
store the first N bytes only.

Disable with `GAP_AUDIT_ENABLED=false`.

Expand Down
80 changes: 80 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ package config

import (
"fmt"
"net"
"net/url"
"strings"
"time"

"github.com/spf13/pflag"
"github.com/spf13/viper"
)

const (
defaultReadTimeout = 60 * time.Second
defaultIdleTimeout = 120 * time.Second
// 10 MiB is well above any OpenAI-compatible chat payload while still
// bounding how much memory one client request can pin.
defaultMaxRequestBytes = 10 << 20
)

// Config holds all application settings.
type Config struct {
Server ServerConfig `mapstructure:"server"`
Expand All @@ -31,6 +41,14 @@ type ServerConfig struct {
Addr string `mapstructure:"addr"`
AdminKey string `mapstructure:"admin_key"`
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
// ReadTimeout bounds how long a client may take to send headers plus body.
// Without it a single idle socket can pin a connection forever (slowloris).
ReadTimeout time.Duration `mapstructure:"read_timeout"`
// IdleTimeout bounds how long an idle keep-alive connection is kept open.
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
// MaxRequestBytes caps the proxied client request body. Requests above it
// are rejected with 413 rather than silently truncated.
MaxRequestBytes int `mapstructure:"max_request_bytes"`
}

type AuthConfig struct {
Expand Down Expand Up @@ -96,6 +114,9 @@ func Load() (*Config, error) {
func setDefaults(v *viper.Viper) {
v.SetDefault("server.addr", ":8080")
v.SetDefault("server.shutdown_timeout", 15*time.Second)
v.SetDefault("server.read_timeout", 60*time.Second)
v.SetDefault("server.idle_timeout", 120*time.Second)
v.SetDefault("server.max_request_bytes", defaultMaxRequestBytes)
v.SetDefault("auth.file", "./auth.json")
v.SetDefault("auth.upstream_base", "https://api.x.ai/v1")
v.SetDefault("auth.refresh_skew", 5*time.Minute)
Expand Down Expand Up @@ -135,6 +156,9 @@ func bindEnv(v *viper.Viper) {
_ = v.BindEnv("server.addr", "GAP_SERVER_ADDR")
_ = v.BindEnv("server.admin_key", "GAP_SERVER_ADMIN_KEY")
_ = v.BindEnv("server.shutdown_timeout", "GAP_SERVER_SHUTDOWN_TIMEOUT")
_ = v.BindEnv("server.read_timeout", "GAP_SERVER_READ_TIMEOUT")
_ = v.BindEnv("server.idle_timeout", "GAP_SERVER_IDLE_TIMEOUT")
_ = v.BindEnv("server.max_request_bytes", "GAP_SERVER_MAX_REQUEST_BYTES")
_ = v.BindEnv("auth.file", "GAP_AUTH_FILE")
_ = v.BindEnv("auth.upstream_base", "GAP_AUTH_UPSTREAM_BASE")
_ = v.BindEnv("auth.refresh_skew", "GAP_AUTH_REFRESH_SKEW")
Expand Down Expand Up @@ -165,6 +189,17 @@ func (c *Config) Validate() error {
if strings.TrimSpace(c.Auth.UpstreamBase) == "" {
return fmt.Errorf("auth.upstream_base is required")
}
// The upstream request carries the Grok access token in an Authorization
// header, so plaintext HTTP to a remote host would leak it on the wire.
if err := requireSecureURL("auth.upstream_base", c.Auth.UpstreamBase); err != nil {
return err
}
// The issuer receives the refresh_token during token exchange.
if strings.TrimSpace(c.Auth.Issuer) != "" {
if err := requireSecureURL("auth.issuer", c.Auth.Issuer); err != nil {
return err
}
}
driver := strings.ToLower(c.DB.Driver)
if driver != "sqlite" && driver != "postgres" {
return fmt.Errorf("db.driver must be sqlite or postgres, got %q", c.DB.Driver)
Expand All @@ -185,7 +220,52 @@ func (c *Config) Validate() error {
if c.Audit.MaxBodyBytes <= 0 {
c.Audit.MaxBodyBytes = 65536
}
if c.Server.ReadTimeout <= 0 {
c.Server.ReadTimeout = defaultReadTimeout
}
if c.Server.IdleTimeout <= 0 {
c.Server.IdleTimeout = defaultIdleTimeout
}
if c.Server.MaxRequestBytes <= 0 {
c.Server.MaxRequestBytes = defaultMaxRequestBytes
}
// Strip trailing slash from upstream base for consistent path join.
c.Auth.UpstreamBase = strings.TrimRight(c.Auth.UpstreamBase, "/")
return nil
}

// requireSecureURL rejects URLs that would carry credentials in cleartext.
// Plain HTTP is tolerated only for loopback hosts, which never leave the machine
// and are the common case for local mock upstreams in tests and development.
func requireSecureURL(field, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%s is not a valid URL: %w", field, err)
}
switch strings.ToLower(u.Scheme) {
case "https":
return nil
case "http":
if isLoopbackHost(u.Hostname()) {
return nil
}
return fmt.Errorf("%s must use https (got %q): credentials would be sent in cleartext", field, raw)
case "":
return fmt.Errorf("%s must be an absolute http(s) URL, got %q", field, raw)
default:
return fmt.Errorf("%s must use http or https, got scheme %q", field, u.Scheme)
}
}

func isLoopbackHost(host string) bool {
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
75 changes: 75 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,81 @@ func TestValidateTrimsUpstreamBase(t *testing.T) {
}
}

func baseConfig() *Config {
return &Config{
Server: ServerConfig{AdminKey: "k", Addr: ":8080"},
Auth: AuthConfig{
File: "./auth.json",
UpstreamBase: "https://api.x.ai/v1",
RefreshSkew: time.Minute,
},
DB: DBConfig{Driver: "sqlite", DSN: "x"},
RateLimit: RateLimitConfig{RPS: 1, Burst: 1},
}
}

// The upstream call carries the Grok access token, and the issuer call carries
// the refresh token. Neither may go out over plaintext HTTP to a remote host.
func TestValidateRejectsCleartextCredentialEndpoints(t *testing.T) {
cases := []struct {
name string
mutate func(*Config)
wantErr bool
}{
{"https upstream", func(c *Config) { c.Auth.UpstreamBase = "https://api.x.ai/v1" }, false},
{"http upstream remote", func(c *Config) { c.Auth.UpstreamBase = "http://api.x.ai/v1" }, true},
{"http upstream localhost", func(c *Config) { c.Auth.UpstreamBase = "http://localhost:9999/v1" }, false},
{"http upstream 127.0.0.1", func(c *Config) { c.Auth.UpstreamBase = "http://127.0.0.1:9999/v1" }, false},
{"http upstream ::1", func(c *Config) { c.Auth.UpstreamBase = "http://[::1]:9999/v1" }, false},
{"bad scheme", func(c *Config) { c.Auth.UpstreamBase = "ftp://api.x.ai/v1" }, true},
{"relative", func(c *Config) { c.Auth.UpstreamBase = "api.x.ai/v1" }, true},
{"https issuer", func(c *Config) { c.Auth.Issuer = "https://auth.x.ai" }, false},
{"http issuer remote", func(c *Config) { c.Auth.Issuer = "http://auth.x.ai" }, true},
{"empty issuer defaults later", func(c *Config) { c.Auth.Issuer = "" }, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := baseConfig()
tc.mutate(cfg)
err := cfg.Validate()
if tc.wantErr && err == nil {
t.Fatal("expected a validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

func TestValidateFillsServerHardeningDefaults(t *testing.T) {
cfg := baseConfig()
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
if cfg.Server.ReadTimeout <= 0 {
t.Fatal("read_timeout must default to a non-zero value (slowloris guard)")
}
if cfg.Server.IdleTimeout <= 0 {
t.Fatal("idle_timeout must default to a non-zero value")
}
if cfg.Server.MaxRequestBytes <= 0 {
t.Fatal("max_request_bytes must default to a non-zero value")
}

// Explicit values are preserved.
cfg = baseConfig()
cfg.Server.ReadTimeout = 5 * time.Second
cfg.Server.IdleTimeout = 7 * time.Second
cfg.Server.MaxRequestBytes = 123
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.IdleTimeout != 7*time.Second || cfg.Server.MaxRequestBytes != 123 {
t.Fatalf("explicit server settings were overwritten: %+v", cfg.Server)
}
}

func TestLoadFromEnv(t *testing.T) {
t.Setenv("GAP_SERVER_ADMIN_KEY", "env-admin")
t.Setenv("GAP_AUTH_FILE", "/tmp/auth.json")
Expand Down
Loading
Loading