using go http reverseproxy with OIDC auth

This commit is contained in:
pascal
2026-01-14 23:53:55 +01:00
parent 626e892e3b
commit 12b38e25da
17 changed files with 1627 additions and 2290 deletions

View File

@@ -5,15 +5,47 @@ import (
"errors"
"fmt"
"os"
"reflect"
"time"
"github.com/caarlos0/env/v11"
"github.com/netbirdio/netbird/proxy/internal/reverseproxy"
)
var (
ErrFailedToParseConfig = errors.New("failed to parse config from env")
)
// Duration is a time.Duration that can be unmarshaled from JSON as a string
type Duration time.Duration
// UnmarshalJSON implements json.Unmarshaler for Duration
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(parsed)
return nil
}
// MarshalJSON implements json.Marshaler for Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// ToDuration converts Duration to time.Duration
func (d Duration) ToDuration() time.Duration {
return time.Duration(d)
}
// Config holds the configuration for the reverse proxy server
type Config struct {
// ListenAddress is the address the proxy server will listen on (e.g., ":443" or "0.0.0.0:443")
@@ -42,6 +74,22 @@ type Config struct {
// EnableGRPC enables the gRPC control server
EnableGRPC bool `env:"NB_PROXY_ENABLE_GRPC" envDefault:"false" json:"enable_grpc"`
// Reverse Proxy Configuration
// HTTPListenAddress is the address for HTTP (default ":80")
HTTPListenAddress string `json:"http_listen_address"`
// EnableHTTPS enables automatic HTTPS with Let's Encrypt
EnableHTTPS bool `json:"enable_https"`
// TLSEmail is the email for Let's Encrypt registration
TLSEmail string `json:"tls_email"`
// CertCacheDir is the directory to cache certificates (default "./certs")
CertCacheDir string `json:"cert_cache_dir"`
// OIDCConfig is the global OIDC/OAuth configuration for authentication
OIDCConfig *reverseproxy.OIDCConfig `json:"oidc_config,omitempty"`
}
// ParseAndLoad parses configuration from environment variables
@@ -104,6 +152,80 @@ func LoadFromFileOrEnv(configPath string) (Config, error) {
return cfg, nil
}
// UnmarshalJSON implements custom JSON unmarshaling with automatic duration parsing
// Uses reflection to find all time.Duration fields and parse them from string
func (c *Config) UnmarshalJSON(data []byte) error {
// First unmarshal into a map to get raw values
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Get reflection value and type
val := reflect.ValueOf(c).Elem()
typ := val.Type()
// Iterate through all fields
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Get JSON tag name
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Parse tag to get field name (handle omitempty, etc.)
jsonFieldName := jsonTag
if idx := len(jsonTag); idx > 0 {
for j, c := range jsonTag {
if c == ',' {
jsonFieldName = jsonTag[:j]
break
}
}
}
// Get raw value from JSON
rawValue, exists := raw[jsonFieldName]
if !exists {
continue
}
// Check if this field is a time.Duration
if field.Type() == reflect.TypeOf(time.Duration(0)) {
// Try to parse as string duration
if strValue, ok := rawValue.(string); ok {
duration, err := time.ParseDuration(strValue)
if err != nil {
return fmt.Errorf("invalid duration for field %s: %w", jsonFieldName, err)
}
field.Set(reflect.ValueOf(duration))
} else {
return fmt.Errorf("field %s must be a duration string", jsonFieldName)
}
} else {
// For non-duration fields, unmarshal normally
fieldData, err := json.Marshal(rawValue)
if err != nil {
return fmt.Errorf("failed to marshal field %s: %w", jsonFieldName, err)
}
// Create a new instance of the field type
if field.CanSet() {
newVal := reflect.New(field.Type())
if err := json.Unmarshal(fieldData, newVal.Interface()); err != nil {
return fmt.Errorf("failed to unmarshal field %s: %w", jsonFieldName, err)
}
field.Set(newVal.Elem())
}
}
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.ListenAddress == "" {

View File

@@ -18,7 +18,7 @@ import (
type Server struct {
config Config
grpcServer *grpcpkg.Server
caddyProxy *reverseproxy.CaddyProxy
proxy *reverseproxy.Proxy
mu sync.RWMutex
isRunning bool
@@ -84,30 +84,37 @@ func NewServer(config Config) (*Server, error) {
exposedServices: make(map[string]*ExposedServiceConfig),
}
// Create Caddy reverse proxy with request callback
caddyConfig := reverseproxy.Config{
ListenAddress: ":54321", // Use port 54321 for local testing
EnableHTTPS: false, // TODO: Add HTTPS support
RequestDataCallback: func(data *reverseproxy.RequestData) {
// This is where access log data arrives - SET BREAKPOINT HERE
// Set defaults for reverse proxy config if not provided
httpListenAddr := config.HTTPListenAddress
if httpListenAddr == "" {
httpListenAddr = ":54321" // Use port 54321 for local testing
}
// Create reverse proxy with request callback
proxyConfig := reverseproxy.Config{
HTTPListenAddress: httpListenAddr,
EnableHTTPS: config.EnableHTTPS,
TLSEmail: config.TLSEmail,
CertCacheDir: config.CertCacheDir,
RequestDataCallback: func(data reverseproxy.RequestData) {
log.WithFields(log.Fields{
"service_id": data.ServiceID,
"host": data.Host,
"method": data.Method,
"path": data.Path,
"response_code": data.ResponseCode,
"duration_ms": data.DurationMs,
"source_ip": data.SourceIP,
}).Info("Access log received")
// TODO: Send via gRPC to control service
// This would send pb.ProxyRequestData via the gRPC stream
},
// Use global OIDC configuration from config
OIDCConfig: config.OIDCConfig,
}
caddyProxy, err := reverseproxy.New(caddyConfig)
proxy, err := reverseproxy.New(proxyConfig)
if err != nil {
return nil, fmt.Errorf("failed to create Caddy proxy: %w", err)
return nil, fmt.Errorf("failed to create reverse proxy: %w", err)
}
server.caddyProxy = caddyProxy
server.proxy = proxy
// Create gRPC server if enabled
if config.EnableGRPC && config.GRPCListenAddress != "" {
@@ -131,14 +138,14 @@ func (s *Server) Start() error {
s.isRunning = true
s.mu.Unlock()
log.Infof("Starting Caddy reverse proxy server on %s", s.config.ListenAddress)
log.Infof("Starting proxy reverse proxy server on %s", s.config.ListenAddress)
// Start Caddy proxy
if err := s.caddyProxy.Start(); err != nil {
// Start reverse proxy
if err := s.proxy.Start(); err != nil {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
return fmt.Errorf("failed to start Caddy proxy: %w", err)
return fmt.Errorf("failed to start reverse proxy: %w", err)
}
// Start gRPC server if configured
@@ -162,21 +169,32 @@ func (s *Server) Start() error {
s.sendProxyEvent(pb.ProxyEvent_STARTED, "Proxy server started")
}
if err := s.caddyProxy.AddRoute(
// Enable Bearer authentication for the test route
// OIDC configuration is set globally in the proxy config above
testAuthConfig := &reverseproxy.AuthConfig{
Bearer: &reverseproxy.BearerConfig{
Enabled: true,
},
}
// Register main protected route with auth
// The /auth/callback endpoint is automatically handled globally for all routes
if err := s.proxy.AddRoute(
&reverseproxy.RouteConfig{
ID: "test",
Domain: "test.netbird.io",
PathMappings: map[string]string{"/": "localhost:8080"},
Conn: reverseproxy.NewDefaultConn(),
AuthConfig: testAuthConfig,
}); err != nil {
log.Warn("Failed to add test route: ", err)
}
// Block forever - Caddy runs in background
<-s.shutdownCtx.Done()
return nil
}
// Stop gracefully shuts down both Caddy and gRPC servers
// Stop gracefully shuts down both proxy and gRPC servers
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.isRunning {
@@ -212,9 +230,9 @@ func (s *Server) Stop(ctx context.Context) error {
s.mu.Unlock()
}
// Shutdown Caddy proxy
if err := s.caddyProxy.Stop(ctx); err != nil {
caddyErr = fmt.Errorf("Caddy proxy shutdown failed: %w", err)
// Shutdown reverse proxy
if err := s.proxy.Stop(ctx); err != nil {
caddyErr = fmt.Errorf("reverse proxy shutdown failed: %w", err)
log.Error(caddyErr)
}
@@ -401,14 +419,14 @@ func (s *Server) handleExposedServiceCreated(serviceID string, peerConfig *PeerC
pathMappings[path] = target
}
// Add route to Caddy
// Add route to proxy
route := &reverseproxy.RouteConfig{
ID: serviceID,
Domain: upstreamConfig.Domain,
PathMappings: pathMappings,
}
if err := s.caddyProxy.AddRoute(route); err != nil {
if err := s.proxy.AddRoute(route); err != nil {
return fmt.Errorf("failed to add route: %w", err)
}
@@ -449,14 +467,14 @@ func (s *Server) handleExposedServiceUpdated(serviceID string, peerConfig *PeerC
pathMappings[path] = target
}
// Update route in Caddy
// Update route in proxy
route := &reverseproxy.RouteConfig{
ID: serviceID,
Domain: upstreamConfig.Domain,
PathMappings: pathMappings,
}
if err := s.caddyProxy.UpdateRoute(route); err != nil {
if err := s.proxy.UpdateRoute(route); err != nil {
return fmt.Errorf("failed to update route: %w", err)
}
@@ -485,8 +503,8 @@ func (s *Server) handleExposedServiceRemoved(serviceID string) error {
"service_id": serviceID,
}).Info("Removing exposed service")
// Remove route from Caddy
if err := s.caddyProxy.RemoveRoute(serviceID); err != nil {
// Remove route from proxy
if err := s.proxy.RemoveRoute(serviceID); err != nil {
return fmt.Errorf("failed to remove route: %w", err)
}