Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b3d623b
feat: implement authentication and configuration management for routa…
samueltuyizere Jun 21, 2026
5881e51
refactor: simplify role and provider checks using slices package
samueltuyizere Jun 21, 2026
fdd7828
feat(config): implement CachedConfigProvider with TTL caching and LRU…
samueltuyizere Jun 21, 2026
cd1b8f3
Add file-based configuration provider tests and enhance provider func…
samueltuyizere Jun 21, 2026
136fbc8
feat: enhance authentication handling and add health check registrati…
samueltuyizere Jun 21, 2026
66424ef
feat(auth): add authentication tests for missing and invalid credentials
samueltuyizere Jun 21, 2026
b845962
style: format code for consistency across multiple files
samueltuyizere Jun 21, 2026
d61d0bc
refactor: simplify conversion functions and clean up unused code
samueltuyizere Jun 21, 2026
a489d80
style: update lint directive for unused function placeholder
samueltuyizere Jun 21, 2026
375f63a
refactor: remove unused setConfig method from mockConfigProvider
samueltuyizere Jun 21, 2026
ce357c3
refactor: rename unused function parameters in validation methods
samueltuyizere Jun 21, 2026
2db6180
style: add lint directive for intentionally unused function in access…
samueltuyizere Jun 21, 2026
ee40b1d
refactor: simplify conversion of ProviderFileConfig to ProviderConfig
samueltuyizere Jun 21, 2026
bb4a663
refactor: handle errors for deferred function calls in auth and confi…
samueltuyizere Jun 21, 2026
1892930
refactor: add isCloudManagedMode function to streamline config valida…
samueltuyizere Jun 21, 2026
d6f9224
refactor: enhance config loading for serverless deployments with envi…
samueltuyizere Jun 21, 2026
d13f5bd
refactor: improve caching mechanism and add cloud endpoint support fo…
samueltuyizere Jun 21, 2026
9615425
refactor: handle context errors in GetEffectiveConfig method
samueltuyizere Jun 21, 2026
9046a5e
refactor: optimize health check request method and improve localhost …
samueltuyizere Jun 21, 2026
99de499
refactor: add static bootstrap config support and enhance runtime con…
samueltuyizere Jun 21, 2026
e173624
refactor: make StaticConfigProvider thread-safe with sync.RWMutex
samueltuyizere Jun 21, 2026
8d4ba61
refactor: optimize HealthCheck method to use HEAD request for improve…
samueltuyizere Jun 21, 2026
c76b473
refactor: enhance config provider initialization with autodetection a…
samueltuyizere Jun 21, 2026
7da1e07
refactor: make StaticConfigProvider methods safe for concurrent use w…
samueltuyizere Jun 21, 2026
a7ebcd1
feat: implement hosted server with cloud-based authentication and con…
samueltuyizere Jun 21, 2026
ce7cfa3
refactor: update GetEffectiveConfig to use auth context for workspace ID
samueltuyizere Jun 21, 2026
1da02e2
refactor: standardize formatting and alignment in main.go
samueltuyizere Jun 21, 2026
15b2bd4
refactor: improve context handling and error logging in hosted server
samueltuyizere Jun 21, 2026
35cc679
refactor: standardize spacing in constant declarations
samueltuyizere Jun 21, 2026
a2bce66
feat: implement GetConfigByRef to fetch configuration by workspace re…
samueltuyizere Jun 21, 2026
1a7a495
refactor: remove health check implementation for hosted mode
samueltuyizere Jun 21, 2026
9fb31ef
feat: add IntrospectionRequest type and update API key validation to …
samueltuyizere Jun 21, 2026
c7655b6
feat: enhance handleProxy to support POST requests and improve error …
samueltuyizere Jun 21, 2026
7cd14c4
feat: enhance API key validation and update HealthCheck documentation
samueltuyizere Jun 21, 2026
980e509
Merge branch 'main' into authprovider-and-configprovider
samueltuyizere Jun 22, 2026
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
359 changes: 264 additions & 95 deletions cmd/routatic-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/routatic/proxy/internal/auth"
"github.com/routatic/proxy/internal/config"
"github.com/routatic/proxy/internal/daemon"
"github.com/routatic/proxy/internal/server"
Expand Down Expand Up @@ -64,119 +66,286 @@ func serveCmd() *cobra.Command {
Use: "serve",
Short: "Start the proxy server",
RunE: func(cmd *cobra.Command, args []string) error {
// Handle background mode: fork and exit parent
if background && !daemonize {
opts := daemon.BackgroundOpts{
ConfigPath: configPath,
Port: port,
}
return daemon.ForkIntoBackground(opts)
}
return runServe(configPath, port, background, daemonize)
},
}

// Override config path if provided.
if configPath != "" {
_ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath)
}
cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
cmd.Flags().IntVarP(&port, "port", "p", 0, "Override listen port")
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run as background daemon")
cmd.Flags().BoolVar(&daemonize, "_daemonize", false, "Internal use only")
_ = cmd.Flags().MarkHidden("_daemonize")

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
return cmd
}

// Override port if provided via flag.
if port != 0 {
cfg.Port = port
}
// runServe contains the actual serve logic, extracted for testability.
func runServe(configPath string, port int, background, daemonize bool) error {
// Handle background mode: fork and exit parent
if background && !daemonize {
opts := daemon.BackgroundOpts{
ConfigPath: configPath,
Port: port,
}
return daemon.ForkIntoBackground(opts)
}

pidPath := getPIDPath()
// Override config path if provided.
if configPath != "" {
_ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath)
}

// Check if already running before writing this process' PID.
if !daemonize {
if pid, err := daemon.GetPID(pidPath); err == nil {
// Check if process is still running.
if daemon.IsProcessRunning(pid) {
return fmt.Errorf("server is already running (PID %d)", pid)
}
// Stale PID file, clean up.
_ = os.Remove(pidPath)
}
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

// Daemonize setup (child process after re-exec).
if daemonize {
paths, err := daemon.DefaultPaths()
if err != nil {
return err
}
if err := paths.EnsureConfigDir(); err != nil {
return err
}
if err := daemon.DaemonizeSetup(paths); err != nil {
return err
}
} else {
// Ensure config directory exists before writing PID file.
paths, err := daemon.DefaultPaths()
if err != nil {
return err
}
if err := paths.EnsureConfigDir(); err != nil {
return err
}
// Write PID file for foreground mode.
if err := daemon.WritePID(pidPath, os.Getpid()); err != nil {
return fmt.Errorf("failed to write PID file: %w", err)
}
}
defer func() { _ = os.Remove(pidPath) }()
// Override port if provided via flag.
if port != 0 {
cfg.Port = port
}

// Create atomic config for hot reload support.
atomicCfg := config.NewAtomicConfig(cfg, config.ResolveConfigPath())
pidPath := getPIDPath()

// Re-apply CLI port override on every reload so it persists.
if port != 0 {
atomicCfg.OnReload(func(newCfg *config.Config) {
newCfg.Port = port
})
// Check if already running before writing this process' PID.
if !daemonize {
if pid, err := daemon.GetPID(pidPath); err == nil {
// Check if process is still running.
if daemon.IsProcessRunning(pid) {
return fmt.Errorf("server is already running (PID %d)", pid)
}
// Stale PID file, clean up.
_ = os.Remove(pidPath)
}
}

// Create and start server.
srv, err := server.NewServer(atomicCfg)
if err != nil {
return fmt.Errorf("failed to create server: %w", err)
// Daemonize setup (child process after re-exec).
if daemonize {
paths, err := daemon.DefaultPaths()
if err != nil {
return err
}
if err := paths.EnsureConfigDir(); err != nil {
return err
}
if err := daemon.DaemonizeSetup(paths); err != nil {
return err
}
} else {
// Ensure config directory exists before writing PID file.
paths, err := daemon.DefaultPaths()
if err != nil {
return err
}
if err := paths.EnsureConfigDir(); err != nil {
return err
}
// Write PID file for foreground mode.
if err := daemon.WritePID(pidPath, os.Getpid()); err != nil {
return fmt.Errorf("failed to write PID file: %w", err)
}
}
defer func() { _ = os.Remove(pidPath) }()

// Initialize providers based on mode.
authProvider, configProvider, err := initProviders(cfg)
if err != nil {
return fmt.Errorf("failed to initialize providers: %w", err)
}

// Close providers on shutdown.
defer func() {
if authProvider != nil {
if c, ok := authProvider.(interface{ Close() error }); ok {
_ = c.Close()
}
}
if configProvider != nil {
// FileConfigProvider has StopWatching method
if c, ok := configProvider.(interface{ StopWatching() error }); ok {
_ = c.StopWatching()
}
}
}()

// Start config watcher for hot reload (only if enabled in config).
if cfg.HotReload {
watchCtx, watchCancel := context.WithCancel(context.Background())
defer watchCancel()
go func() {
if err := config.WatchConfig(watchCtx, atomicCfg); err != nil && err != context.Canceled {
slog.Error("config watcher failed", "error", err)
}
}()
// Create atomic config for hot reload support.
atomicCfg := config.NewAtomicConfig(cfg, config.ResolveConfigPath())

// Re-apply CLI port override on every reload so it persists.
if port != 0 {
atomicCfg.OnReload(func(newCfg *config.Config) {
newCfg.Port = port
})
}

// Create and start server with providers.
// Note: The server currently doesn't use these providers directly (auth/config
// are handled via middleware and RuntimeConfig), but we initialize them here
// for future extensibility and health checks.
srv, err := server.NewServer(atomicCfg)
if err != nil {
return fmt.Errorf("failed to create server: %w", err)
}

// Register providers with server for health checks.
server.RegisterHealthCheckers(srv, authProvider, configProvider)

// Start config watcher for hot reload (only if enabled in config).
if cfg.HotReload {
watchCtx, watchCancel := context.WithCancel(context.Background())
defer watchCancel()
go func() {
if err := config.WatchConfig(watchCtx, atomicCfg); err != nil && err != context.Canceled {
slog.Error("config watcher failed", "error", err)
}
}()
}

fmt.Printf("Starting %s v%s\n", appName, version)
fmt.Printf("Listening on %s:%d\n", cfg.Host, cfg.Port)
fmt.Printf("Forwarding to: %s\n", cfg.OpenCodeGo.BaseURL)
fmt.Println()
fmt.Println("Configure Claude Code with:")
fmt.Printf(" export ANTHROPIC_BASE_URL=http://%s:%d\n", cfg.Host, cfg.Port)
fmt.Println(" export ANTHROPIC_AUTH_TOKEN=unused")
fmt.Println()
fmt.Printf("Starting %s v%s\n", appName, version)
fmt.Printf("Listening on %s:%d\n", cfg.Host, cfg.Port)
fmt.Printf("Forwarding to: %s\n", cfg.OpenCodeGo.BaseURL)
fmt.Println()
fmt.Println("Configure Claude Code with:")
fmt.Printf(" export ANTHROPIC_BASE_URL=http://%s:%d\n", cfg.Host, cfg.Port)
fmt.Println(" export ANTHROPIC_AUTH_TOKEN=unused")
fmt.Println()

return srv.Start()
},
return srv.Start()
}

// initProviders initializes the authentication and configuration providers
// based on the server mode configuration.
//
// Mode "standalone": Uses local file-based config and local key auth (backward compatible).
// Mode "managed": Uses cloud snapshot or DB-based config with cloud auth.
func initProviders(cfg *config.Config) (auth.AuthProvider, config.ConfigProvider, error) {
authProvider, err := initAuthProvider(cfg)
if err != nil {
return nil, nil, fmt.Errorf("auth provider: %w", err)
}

cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
cmd.Flags().IntVarP(&port, "port", "p", 0, "Override listen port")
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run as background daemon")
cmd.Flags().BoolVar(&daemonize, "_daemonize", false, "Internal use only")
_ = cmd.Flags().MarkHidden("_daemonize")
configProvider, err := initConfigProvider(cfg)
if err != nil {
if c, ok := authProvider.(interface{ Close() error }); ok {
_ = c.Close()
}
return nil, nil, fmt.Errorf("config provider: %w", err)
}

return cmd
return authProvider, configProvider, nil
}

// initAuthProvider creates an AuthProvider based on the configuration.
// Supported providers: "local_keys", "cloud", "none".
func initAuthProvider(cfg *config.Config) (auth.AuthProvider, error) {
provider := cfg.Auth.Provider
if provider == "" {
// Default to "none" for backward compatibility
provider = "none"
}

switch provider {
case "local_keys":
if cfg.Auth.ConfigPath == "" {
return nil, fmt.Errorf("local_keys auth requires config_path")
}
return auth.NewLocalKeyAuthProvider(cfg.Auth.ConfigPath)

case "cloud":
if cfg.Auth.IntrospectionURL == "" {
return nil, fmt.Errorf("cloud auth requires introspection_url")
}
ttl := cfg.Auth.CacheTTL
if ttl <= 0 {
ttl = 30 * time.Second
}
// Get optional service token from environment
serviceToken := os.Getenv("ROUTATIC_PROXY_SERVICE_TOKEN")
return auth.NewCloudAuthProvider(cfg.Auth.IntrospectionURL, ttl, serviceToken), nil

case "none":
workspaceID := "local"
if cfg.Mode == "standalone" && cfg.APIKey != "" {
// Use a stable workspace ID based on first API key hash
workspaceID = hashAPIKeyForWorkspace(cfg.APIKey)
}
return auth.NewNoAuthProvider(workspaceID), nil

default:
return nil, fmt.Errorf("unknown auth provider: %s", provider)
}
}

// initConfigProvider creates a ConfigProvider based on the configuration.
// Supported providers: "file", "db", "cloud_snapshot".
func initConfigProvider(cfg *config.Config) (config.ConfigProvider, error) {
provider := cfg.ConfigProv.Provider
if provider == "" {
// Default to "file" for backward compatibility
provider = "file"
}

var underlying config.ConfigProvider
var err error

switch provider {
case "file":
path := cfg.ConfigProv.Path
if path == "" {
// Use default config path
path = config.ResolveConfigPath()
}
underlying, err = config.NewFileConfigProvider(path)
if err != nil {
return nil, err
}

case "db":
if cfg.ConfigProv.DB.Driver == "" {
return nil, fmt.Errorf("db provider requires driver")
}
if cfg.ConfigProv.DB.DSN == "" {
return nil, fmt.Errorf("db provider requires dsn")
}
// Note: DBConfigProvider is not yet implemented in this version
return nil, fmt.Errorf("db config provider not implemented: driver=%s", cfg.ConfigProv.DB.Driver)

case "cloud_snapshot":
if cfg.ConfigProv.SnapshotURL == "" {
return nil, fmt.Errorf("cloud_snapshot provider requires snapshot_url")
}
ttl := cfg.ConfigProv.CacheTTL
if ttl <= 0 {
ttl = 15 * time.Second
}
// Get optional service token from environment
serviceToken := os.Getenv("ROUTATIC_PROXY_SERVICE_TOKEN")
underlying = config.NewCloudSnapshotConfigProvider(cfg.ConfigProv.SnapshotURL, ttl, serviceToken)

default:
return nil, fmt.Errorf("unknown config provider: %s", provider)
}

// Wrap with cache for all providers except file (which has its own caching)
if provider != "file" {
ttl := cfg.ConfigProv.CacheTTL
if ttl <= 0 {
ttl = 15 * time.Second
}
underlying = config.NewCachedConfigProvider(underlying, ttl)
}

return underlying, nil
}

// hashAPIKeyForWorkspace creates a stable workspace ID from an API key.
// This is used for the "none" auth provider in standalone mode to provide
// a stable identity for logging and metrics.
func hashAPIKeyForWorkspace(apiKey string) string {
if len(apiKey) <= 8 {
return apiKey
}
return "wk_" + apiKey[:8]
}

// stopCmd returns the command to stop the proxy server.
Expand Down
Loading
Loading