// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package main is the entry point for the Vouch sidecar proxy application.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"github.com/deep-rent/nexus/app"
"github.com/deep-rent/nexus/log"
"github.com/deep-rent/nexus/updater"
"github.com/deep-rent/vouch/internal/bouncer"
"github.com/deep-rent/vouch/internal/config"
"github.com/deep-rent/vouch/internal/gateway"
"github.com/deep-rent/vouch/internal/server"
"github.com/deep-rent/vouch/internal/stamper"
)
// The application version injected via -ldflags during build time.
var version = "v0.0.0"
const (
owner = "deep-rent"
repository = "vouch"
)
func main() {
if err := boot(context.Background(), os.Args, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func boot(ctx context.Context, args []string, stdout io.Writer) error {
flags := flag.NewFlagSet(args[0], flag.ContinueOnError)
flags.SetOutput(stdout)
showVersion := flags.Bool("v", false, "Display version and exit")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if *showVersion {
_, _ = fmt.Fprintf(stdout, "Vouch %s\n", version)
return nil
}
cfg, err := config.Load()
if err != nil {
return err
}
logger := log.New(log.WithLevel(cfg.LogLevel), log.WithFormat(cfg.LogFormat))
ua := fmt.Sprintf("Vouch/%s", version)
// Initialize the Bouncer to handle JWT verification and JWKS caching.
bouncer := bouncer.New(&bouncer.Config{
TokenIssuers: cfg.TokenIssuers,
TokenAudiences: cfg.TokenAudiences,
TokenLeeway: cfg.TokenLeeway,
TokenMaxAge: cfg.TokenMaxAge,
TokenAuthScheme: cfg.TokenAuthScheme,
TokenRolesClaim: cfg.TokenRolesClaim,
KeysURL: cfg.KeysURL,
KeysUserAgent: ua,
KeysTimeout: cfg.KeysTimeout,
KeysMinRefreshInterval: cfg.KeysMinRefreshInterval,
KeysMaxRefreshInterval: cfg.KeysMaxRefreshInterval,
KeysAttemptLimit: cfg.KeysAttemptLimit,
KeysBackoffMinDelay: cfg.KeysBackoffMinDelay,
KeysBackoffMaxDelay: cfg.KeysBackoffMaxDelay,
KeysBackoffGrowthFactor: cfg.KeysBackoffGrowthFactor,
KeysBackoffJitterAmount: cfg.KeysBackoffJitterAmount,
Logger: logger,
})
// Initialize the Stamper to inject proxy authentication headers.
stamper := stamper.New(&stamper.Config{
UserNameHeader: cfg.UserNameHeader,
RolesHeader: cfg.RolesHeader,
})
// Initialize the Gateway to proxy requests to the upstream service.
gateway := gateway.New(&gateway.Config{
Bouncer: bouncer,
Stamper: stamper,
URL: cfg.Target,
FlushInterval: cfg.FlushInterval,
MinBufferSize: cfg.MinBufferSize,
MaxBufferSize: cfg.MaxBufferSize,
MaxIdleConns: cfg.MaxIdleConns,
IdleConnTimeout: cfg.IdleConnTimeout,
Logger: logger,
})
// Initialize the HTTP server.
s := server.New(&server.Config{
Handler: gateway,
Host: cfg.Host,
Port: cfg.Port,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: cfg.MaxHeaderBytes,
Logger: logger,
})
// This task spawns the HTTP server to act as a reverse proxy.
serve := func(ctx context.Context) error {
errCh := make(chan error, 1)
go func() { errCh <- s.Start() }()
select {
case err := <-errCh:
return err
case <-ctx.Done():
// Gracefully stop the server on context cancellation.
if err := s.Stop(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
}
// This task handles the JWKS refresh loop.
fetch := func(ctx context.Context) error {
return bouncer.Start(ctx)
}
// Collect the main tasks to run into a slice.
tasks := []app.Runnable{serve, fetch}
// Conditionally add a task to notify about new releases.
// This check requires network access.
if cfg.UpdaterEnabled {
check := func(ctx context.Context) error {
rel, err := updater.Check(ctx, &updater.Config{
BaseURL: cfg.UpdaterBaseURL,
Owner: owner,
Repository: repository,
Current: version,
UserAgent: ua,
})
if err != nil {
logger.Warn(
"Unable to check for newer release",
slog.Any("error", err),
)
} else if rel != nil {
logger.Info(
"A new release is available",
slog.String("version", rel.Version),
slog.String("url", rel.URL),
)
}
return nil
}
tasks = append(tasks, check)
}
// Spin up the application tasks concurrently and wait for them to finish
// or return an error.
if err := app.RunAll(
tasks,
app.WithContext(ctx), app.WithLogger(logger),
); err != nil {
return err
}
return nil
}
// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package bouncer provides the logic for verifying JWTs against a remote JWKS.
package bouncer
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/deep-rent/nexus/backoff"
"github.com/deep-rent/nexus/cache"
"github.com/deep-rent/nexus/header"
"github.com/deep-rent/nexus/jose/jwk"
"github.com/deep-rent/nexus/jose/jwt"
"github.com/deep-rent/nexus/retry"
"github.com/deep-rent/nexus/scheduler"
)
var (
ErrMissingToken = errors.New("missing access token")
ErrUndefinedUserName = errors.New("undefined subject in access token")
)
// User represents an authenticated CouchDB user.
// This data gets forwarded via proxy authentication headers.
type User struct {
Name string // CouchDB username, taken from the "sub" claim.
Roles []string // List of CouchDB roles, read from a configurable claim.
}
// Config holds the configuration for the Bouncer.
type Config struct {
TokenIssuers []string
TokenAudiences []string
TokenLeeway time.Duration
TokenMaxAge time.Duration
TokenAuthScheme string
TokenRolesClaim string
KeysURL string
KeysUserAgent string
KeysTimeout time.Duration
KeysMinRefreshInterval time.Duration
KeysMaxRefreshInterval time.Duration
KeysAttemptLimit int
KeysBackoffMinDelay time.Duration
KeysBackoffMaxDelay time.Duration
KeysBackoffGrowthFactor float64
KeysBackoffJitterAmount float64
Logger *slog.Logger
}
// Bouncer is responsible for validating incoming HTTP requests by verifying
// their bearer tokens.
type Bouncer struct {
verifier *jwt.Verifier[*jwt.DynamicClaims]
authScheme string
rolesClaim string
tick scheduler.Tick
}
// New creates a new Bouncer instance.
func New(cfg *Config) *Bouncer {
// Create a caching JWK set that automatically refreshes keys from the
// remote endpoint.
set := jwk.NewCacheSet(
cfg.KeysURL,
cache.WithLogger(cfg.Logger),
cache.WithTimeout(cfg.KeysTimeout),
cache.WithMinInterval(cfg.KeysMinRefreshInterval),
cache.WithMaxInterval(cfg.KeysMaxRefreshInterval),
cache.WithHeader("User-Agent", cfg.KeysUserAgent),
cache.WithRetryOptions(
retry.WithLogger(cfg.Logger),
retry.WithAttemptLimit(cfg.KeysAttemptLimit),
retry.WithBackoff(backoff.New(
backoff.WithMinDelay(cfg.KeysBackoffMinDelay),
backoff.WithMaxDelay(cfg.KeysBackoffMaxDelay),
backoff.WithJitterAmount(cfg.KeysBackoffJitterAmount),
backoff.WithGrowthFactor(cfg.KeysBackoffGrowthFactor),
)),
),
)
return &Bouncer{
// Configure the JWT verifier with the key set and validation rules.
verifier: jwt.NewVerifier[*jwt.DynamicClaims](set).
WithIssuers(cfg.TokenIssuers...).
WithAudiences(cfg.TokenAudiences...).
WithLeeway(cfg.TokenLeeway).
WithMaxAge(cfg.TokenMaxAge),
authScheme: cfg.TokenAuthScheme,
rolesClaim: cfg.TokenRolesClaim,
tick: set,
}
}
// Start begins the background process for refreshing the JWK set.
// It blocks until the context is canceled.
func (b *Bouncer) Start(ctx context.Context) error {
sched := scheduler.New(ctx)
sched.Dispatch(b.tick)
<-ctx.Done()
sched.Shutdown()
return nil
}
// Bounce verifies the bearer token in the request and returns the authenticated
// user. It returns an error if the token is missing, invalid, or expired.
func (b *Bouncer) Bounce(req *http.Request) (*User, error) {
token := header.Credentials(req.Header, b.authScheme)
// Strip the token from the request header to prevent it from being forwarded
// to the upstream service.
req.Header.Del("Authorization")
if token == "" {
return nil, ErrMissingToken
}
claims, err := b.verifier.Verify([]byte(token))
if err != nil {
return nil, fmt.Errorf("invalid access token: %w", err)
}
name := claims.Sub
if name == "" {
return nil, ErrUndefinedUserName
}
// Extract roles from the custom claim.
roles, ok := jwt.Get[[]string](claims, b.rolesClaim)
if !ok {
roles = make([]string, 0)
}
return &User{
Name: name,
Roles: roles,
}, nil
}
// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package config provides the configuration structure and loading logic for the
// Vouch application.
package config
import (
"net/url"
"time"
"github.com/deep-rent/nexus/env"
)
// Prefix is the environment variable prefix used by the application.
const Prefix = "VOUCH_"
// Config holds the application configuration. It is populated from environment
// variables prefixed with [Prefix]. The struct tags define the default values
// and parsing rules for each field.
type Config struct {
LogLevel string `env:",default:info"`
LogFormat string `env:",default:json"`
UpdaterEnabled bool `env:",default:false"`
UpdaterBaseURL string `env:""`
Host string `env:",default:0.0.0.0"`
Port string `env:",default:8080"`
ReadHeaderTimeout time.Duration `env:",unit:s,default:5"`
ReadTimeout time.Duration `env:",unit:s,default:30"`
WriteTimeout time.Duration `env:",unit:s,default:0"`
IdleTimeout time.Duration `env:",unit:s,default:120"`
MaxHeaderBytes int `env:",default:0"`
UserNameHeader string `env:",default:X-Auth-CouchDB-UserName"`
RolesHeader string `env:",default:X-Auth-CouchDB-Roles"`
Target *url.URL `env:",default:http://localhost:5984"`
FlushInterval time.Duration `env:",unit:ms,default:-1"`
MinBufferSize int `env:",default:32768"`
MaxBufferSize int `env:",default:262144"`
MaxIdleConns int `env:",default:512"`
IdleConnTimeout time.Duration `env:",unit:s,default:90"`
TokenIssuers []string `env:""`
TokenAudiences []string `env:""`
TokenLeeway time.Duration `env:",unit:s,default:30"`
TokenMaxAge time.Duration `env:",unit:s,default:0"`
TokenAuthScheme string `env:",default:Bearer"`
TokenRolesClaim string `env:",default:_couchdb.roles"`
KeysURL string `env:",required"`
KeysTimeout time.Duration `env:",unit:s,default:10"`
KeysMinRefreshInterval time.Duration `env:",unit:m,default:1"`
KeysMaxRefreshInterval time.Duration `env:",unit:m,default:10080"`
KeysAttemptLimit int `env:",default:0"`
KeysBackoffMinDelay time.Duration `env:",unit:s,default:1"`
KeysBackoffMaxDelay time.Duration `env:",unit:s,default:120"`
KeysBackoffGrowthFactor float64 `env:",default:1.75"`
KeysBackoffJitterAmount float64 `env:",default:0.66"`
}
// Load reads the configuration from environment variables, applying the
// [Prefix] to all lookups. It returns an error if required variables are
// missing or if parsing fails.
func Load() (*Config, error) {
var cfg Config
if err := env.Unmarshal(&cfg, env.WithPrefix(Prefix)); err != nil {
return nil, err
}
return &cfg, nil
}
// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package gateway implements the reverse proxy logic, orchestrating
// authentication and request forwarding.
package gateway
import (
"log/slog"
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/proxy"
"github.com/deep-rent/vouch/internal/bouncer"
"github.com/deep-rent/vouch/internal/stamper"
)
// Config holds the configuration for the Gateway.
type Config struct {
Bouncer *bouncer.Bouncer
Stamper *stamper.Stamper
URL *url.URL
FlushInterval time.Duration
MinBufferSize int
MaxBufferSize int
MaxIdleConns int
IdleConnTimeout time.Duration
Logger *slog.Logger
}
// Gateway is an http.Handler that authenticates requests using a Bouncer,
// stamps them with a Stamper, and proxies them to a backend.
type Gateway struct {
bouncer *bouncer.Bouncer
stamper *stamper.Stamper
backend http.Handler
logger *slog.Logger
}
// New creates a new Gateway handler.
func New(cfg *Config) http.Handler {
handler := proxy.NewHandler(
cfg.URL,
// For long polling, such as CouchDB's _changes feed, we want to flush as
// soon as possible (set to -1 to disable buffering entirely). Otherwise,
// buffering will delay heartbeats (newlines) or event chunks, causing
// client timeouts.
proxy.WithFlushInterval(cfg.FlushInterval),
// Optimize the request buffer to avoid garbage collection pressure under
// heavy load.
proxy.WithMinBufferSize(cfg.MinBufferSize),
proxy.WithMaxBufferSize(cfg.MaxBufferSize),
proxy.WithTransport(&http.Transport{
// For a sidecar, this should match or exceed the expected peak of
// concurrent requests.
MaxIdleConns: cfg.MaxIdleConns,
// Since we are only proxying to one host, this is equal to MaxIdleConns.
MaxIdleConnsPerHost: cfg.MaxIdleConns,
// We wish to reuse connections as much as possible, but we also need to
// eventually prune that CouchDB might have silently dropped.
IdleConnTimeout: cfg.IdleConnTimeout,
DisableCompression: true, // CouchDB compresses responses when requested.
ForceAttemptHTTP2: false, // CouchDB doesn't support HTTP/2.
}),
proxy.WithLogger(cfg.Logger),
)
return &Gateway{
bouncer: cfg.Bouncer,
stamper: cfg.Stamper,
backend: handler,
logger: cfg.Logger,
}
}
// ServeHTTP handles the HTTP request lifecycle: authenticate, stamp, and proxy.
func (h *Gateway) ServeHTTP(res http.ResponseWriter, req *http.Request) {
// Bypass authentication for CouchDB's native readiness endpoint.
if req.URL.Path == "/_up" {
h.backend.ServeHTTP(res, req)
return
}
// Authenticate the request.
pass, err := h.bouncer.Bounce(req)
if err != nil {
h.logger.DebugContext(
req.Context(),
"Request denied",
slog.Any("error", err),
)
res.WriteHeader(http.StatusUnauthorized)
return
}
// Inject identity headers.
h.stamper.Stamp(req, pass)
// Forward to the backend (CouchDB).
h.backend.ServeHTTP(res, req)
}
// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package server provides a wrapper around the standard HTTP server with
// graceful shutdown capabilities and middleware integration.
package server
import (
"context"
"log/slog"
"net"
"net/http"
"time"
"github.com/deep-rent/nexus/middleware"
)
// Config holds the configuration for the Server.
type Config struct {
Handler http.Handler
Host string
Port string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
Logger *slog.Logger
}
// Server wraps http.Server to provide a simplified lifecycle management.
type Server struct {
server *http.Server
logger *slog.Logger
}
// New creates a new Server instance.
func New(cfg *Config) *Server {
// Collect middleware to apply to the handler.
pipes := []middleware.Pipe{middleware.Recover(cfg.Logger)}
// Only add logging middleware if debug logging is enabled, to avoid the
// overhead of logging every request when it's not necessary.
if cfg.Logger.Enabled(context.Background(), slog.LevelDebug) {
pipes = append(pipes, middleware.Log(cfg.Logger))
}
// Create a new multiplexer for routing.
mux := http.NewServeMux()
// Register the health check handler.
// This sits outside the middleware chain to prevent log spam.
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Register the provided handler wrapped in middleware as the default handler.
mux.Handle("/", middleware.Chain(cfg.Handler, pipes...))
return &Server{
server: &http.Server{
Addr: net.JoinHostPort(cfg.Host, cfg.Port),
Handler: mux,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: cfg.MaxHeaderBytes,
ErrorLog: slog.NewLogLogger(
cfg.Logger.Handler(),
slog.LevelError,
),
},
logger: cfg.Logger,
}
}
// Start begins listening for incoming HTTP requests.
// It returns an error if the server fails to start.
func (s *Server) Start() error {
host, port, _ := net.SplitHostPort(s.server.Addr)
s.logger.Info(
"Server listening",
slog.String("host", host),
slog.String("port", port),
)
return s.server.ListenAndServe()
}
// Stop gracefully shuts down the server.
func (s *Server) Stop() error {
return s.server.Shutdown(context.Background())
}
// Copyright (c) 2025-present deep.rent GmbH (https://deep.rent)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package stamper handles the injection of proxy authentication headers
// into HTTP requests.
package stamper
import (
"net/http"
"strings"
"github.com/deep-rent/vouch/internal/bouncer"
)
// Config holds the configuration for the Stamper.
type Config struct {
UserNameHeader string // The header to set with the authenticated user's name.
RolesHeader string // The header to set with the user's roles.
}
// Stamper is responsible for modifying requests to include identity information.
type Stamper struct {
userNameHeader string
rolesHeader string
}
// New creates a new Stamper instance.
func New(cfg *Config) *Stamper {
return &Stamper{
userNameHeader: cfg.UserNameHeader,
rolesHeader: cfg.RolesHeader,
}
}
// Stamp injects the user's name and roles into the request headers.
func (s *Stamper) Stamp(req *http.Request, user *bouncer.User) {
req.Header.Set(s.userNameHeader, user.Name)
if len(user.Roles) == 0 {
// Ensure no stale roles header exists if the user has no roles.
req.Header.Del(s.rolesHeader)
} else {
req.Header.Set(s.rolesHeader, strings.Join(user.Roles, ","))
}
}