mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-28 17:49:08 +02:00
refactor layout and structure
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type gRPCClient interface {
|
||||
SendAccessLog(ctx context.Context, in *proto.SendAccessLogRequest, opts ...grpc.CallOption) (*proto.SendAccessLogResponse, error)
|
||||
}
|
||||
|
||||
type errorLogger interface {
|
||||
ErrorContext(ctx context.Context, msg string, args ...any)
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
client gRPCClient
|
||||
errorLog errorLogger
|
||||
}
|
||||
|
||||
func NewLogger(client gRPCClient, errorLog errorLogger) *Logger {
|
||||
if errorLog == nil {
|
||||
errorLog = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Logger{
|
||||
client: client,
|
||||
errorLog: errorLog,
|
||||
}
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
ServiceId string
|
||||
Host string
|
||||
Path string
|
||||
DurationMs int64
|
||||
Method string
|
||||
ResponseCode int32
|
||||
SourceIp string
|
||||
AuthMechanism string
|
||||
UserId string
|
||||
AuthSuccess bool
|
||||
}
|
||||
|
||||
func (l *Logger) log(ctx context.Context, log logEntry) {
|
||||
// Fire off the log request in a separate routine.
|
||||
// This increases the possibility of losing a log message
|
||||
// (although it should still get logged in the event of an error),
|
||||
// but it will reduce latency returning the request in the
|
||||
// middleware.
|
||||
// There is also a chance that log messages will arrive at
|
||||
// the server out of order; however, the timestamp should
|
||||
// allow for resolving that on the server.
|
||||
now := timestamppb.Now() // Grab the timestamp before launching the goroutine to try to prevent weird timing issues. This is probably unnecessary.
|
||||
go func() {
|
||||
if _, err := l.client.SendAccessLog(ctx, &proto.SendAccessLogRequest{
|
||||
Log: &proto.AccessLog{
|
||||
Timestamp: now,
|
||||
ServiceId: log.ServiceId,
|
||||
Host: log.Host,
|
||||
Path: log.Path,
|
||||
DurationMs: log.DurationMs,
|
||||
Method: log.Method,
|
||||
ResponseCode: log.ResponseCode,
|
||||
SourceIp: log.SourceIp,
|
||||
AuthMechanism: log.AuthMechanism,
|
||||
UserId: log.UserId,
|
||||
AuthSuccess: log.AuthSuccess,
|
||||
},
|
||||
}); err != nil {
|
||||
// If it fails to send on the gRPC connection, then at least log it to the error log.
|
||||
l.errorLog.ErrorContext(ctx, "Error sending access log on gRPC connection",
|
||||
"service_id", log.ServiceId,
|
||||
"host", log.Host,
|
||||
"path", log.Path,
|
||||
"duration", log.DurationMs,
|
||||
"method", log.Method,
|
||||
"response_code", log.ResponseCode,
|
||||
"source_ip", log.SourceIp,
|
||||
"auth_mechanism", log.AuthMechanism,
|
||||
"user_id", log.UserId,
|
||||
"auth_success", log.AuthSuccess,
|
||||
"error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
)
|
||||
|
||||
func (l *Logger) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Use a response writer wrapper so we can access the status code later.
|
||||
sw := &statusWriter{
|
||||
w: w,
|
||||
status: http.StatusOK, // Default status is OK unless otherwise modified.
|
||||
}
|
||||
|
||||
// Get the source IP before passing the request on as the proxy will modify
|
||||
// headers that we wish to use to gather that information on the request.
|
||||
sourceIp := extractSourceIP(r)
|
||||
|
||||
start := time.Now()
|
||||
next.ServeHTTP(sw, r)
|
||||
duration := time.Since(start)
|
||||
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
// Fallback to just using the full host value.
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
l.log(r.Context(), logEntry{
|
||||
ServiceId: proxy.ServiceIdFromContext(r.Context()),
|
||||
Host: host,
|
||||
Path: r.URL.Path,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
Method: r.Method,
|
||||
ResponseCode: int32(sw.status),
|
||||
SourceIp: sourceIp,
|
||||
AuthMechanism: auth.MethodFromContext(r.Context()).String(),
|
||||
UserId: auth.UserFromContext(r.Context()),
|
||||
AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// requestIP attempts to extract the source IP from a request.
|
||||
// Adapted from https://husobee.github.io/golang/ip-address/2015/12/17/remote-ip-go.html
|
||||
// with the addition of some newer stdlib functions that are now
|
||||
// available.
|
||||
// The concept here is to look backwards through IP headers until
|
||||
// the first public IP address is found. The hypothesis is that
|
||||
// even if there are multiple IP addresses specified in these headers,
|
||||
// the last public IP should be the hop immediately before reaching
|
||||
// the server and therefore represents the "true" source IP regardless
|
||||
// of the number of intermediate proxies or network hops.
|
||||
func extractSourceIP(r *http.Request) string {
|
||||
for _, h := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
addresses := strings.Split(r.Header.Get(h), ",")
|
||||
// Iterate from right to left until we get a public address
|
||||
// that should be the address right before our proxy.
|
||||
for _, address := range slices.Backward(addresses) {
|
||||
// Trim the address because sometimes clients put whitespace in there.
|
||||
ip := strings.TrimSpace(address)
|
||||
// Parse the IP so that we can easily check whether it is a valid public address.
|
||||
realIP := net.ParseIP(ip)
|
||||
if !realIP.IsGlobalUnicast() || realIP.IsPrivate() || realIP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
return ip
|
||||
}
|
||||
}
|
||||
// Fallback to the requests RemoteAddr, this is least likely to be correct but
|
||||
// should at least yield something in the event that the above has failed.
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package accesslog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// statusWriter is a simple wrapper around an http.ResponseWriter
|
||||
// that captures the setting of the status code via the WriteHeader
|
||||
// function and stores it so that it can be retrieved later.
|
||||
type statusWriter struct {
|
||||
w http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) Header() http.Header {
|
||||
return w.w.Header()
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(data []byte) (int, error) {
|
||||
return w.w.Write(data)
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.w.WriteHeader(status)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
*autocert.Manager
|
||||
|
||||
domainsMux sync.RWMutex
|
||||
domains map[string]struct{}
|
||||
}
|
||||
|
||||
func NewManager(certDir, acmeURL string) *Manager {
|
||||
mgr := &Manager{
|
||||
domains: make(map[string]struct{}),
|
||||
}
|
||||
mgr.Manager = &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: mgr.hostPolicy,
|
||||
Cache: autocert.DirCache(certDir),
|
||||
Client: &acme.Client{
|
||||
DirectoryURL: acmeURL,
|
||||
},
|
||||
}
|
||||
return mgr
|
||||
}
|
||||
|
||||
func (mgr *Manager) hostPolicy(_ context.Context, domain string) error {
|
||||
mgr.domainsMux.RLock()
|
||||
defer mgr.domainsMux.RUnlock()
|
||||
if _, exists := mgr.domains[domain]; exists {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown domain %q", domain)
|
||||
}
|
||||
|
||||
func (mgr *Manager) AddDomain(domain string) {
|
||||
mgr.domainsMux.Lock()
|
||||
defer mgr.domainsMux.Unlock()
|
||||
mgr.domains[domain] = struct{}{}
|
||||
}
|
||||
|
||||
func (mgr *Manager) RemoveDomain(domain string) {
|
||||
mgr.domainsMux.Lock()
|
||||
defer mgr.domainsMux.Unlock()
|
||||
delete(mgr.domains, domain)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
{{ range . }}
|
||||
<p>{{ . }}</p>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type BasicAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func NewBasicAuth(username string, password string) BasicAuth {
|
||||
return BasicAuth{
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func (BasicAuth) Type() Method {
|
||||
return MethodBasicAuth
|
||||
}
|
||||
|
||||
func (b BasicAuth) Authenticate(r *http.Request) (string, bool, any) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(b.username)) == 1
|
||||
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(b.password)) == 1
|
||||
|
||||
// If authenticated, then return the username.
|
||||
if usernameMatch && passwordMatch {
|
||||
return username, false, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (b BasicAuth) Middleware(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package auth
|
||||
|
||||
import "github.com/netbirdio/netbird/proxy/internal/auth/methods"
|
||||
|
||||
// Config holds the authentication configuration for a route
|
||||
// Only ONE auth method should be configured per route
|
||||
type Config struct {
|
||||
// HTTP Basic authentication (username/password)
|
||||
BasicAuth *methods.BasicAuthConfig
|
||||
|
||||
// PIN authentication
|
||||
PIN *methods.PINConfig
|
||||
|
||||
// Bearer token with JWT validation and OAuth/OIDC flow
|
||||
// When enabled, uses the global OIDCConfig from proxy Config
|
||||
Bearer *methods.BearerConfig
|
||||
}
|
||||
|
||||
// IsEmpty returns true if no auth methods are configured
|
||||
func (c *Config) IsEmpty() bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
return c.BasicAuth == nil && c.PIN == nil && c.Bearer == nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type requestContextKey string
|
||||
|
||||
const (
|
||||
authMethodKey requestContextKey = "authMethod"
|
||||
authUserKey requestContextKey = "authUser"
|
||||
)
|
||||
|
||||
func withAuthMethod(ctx context.Context, method Method) context.Context {
|
||||
return context.WithValue(ctx, authMethodKey, method)
|
||||
}
|
||||
|
||||
func MethodFromContext(ctx context.Context) Method {
|
||||
v := ctx.Value(authMethodKey)
|
||||
method, ok := v.(Method)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
func withAuthUser(ctx context.Context, userId string) context.Context {
|
||||
return context.WithValue(ctx, authUserKey, userId)
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) string {
|
||||
v := ctx.Value(authUserKey)
|
||||
userId, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return userId
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// BasicAuthConfig holds HTTP Basic authentication settings
|
||||
type BasicAuthConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Validate checks Basic Auth credentials from the request
|
||||
func (c *BasicAuthConfig) Validate(r *http.Request) bool {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(c.Username)) == 1
|
||||
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(c.Password)) == 1
|
||||
|
||||
return usernameMatch && passwordMatch
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package methods
|
||||
|
||||
// BearerConfig holds JWT/OAuth/OIDC bearer token authentication settings
|
||||
// The actual OIDC/JWT configuration comes from the global proxy Config.OIDCConfig
|
||||
// This just enables Bearer auth for a specific route
|
||||
type BearerConfig struct {
|
||||
Enabled bool
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPINHeader is the default header name for PIN authentication
|
||||
DefaultPINHeader = "X-PIN"
|
||||
)
|
||||
|
||||
// PINConfig holds PIN authentication settings
|
||||
type PINConfig struct {
|
||||
PIN string
|
||||
Header string
|
||||
}
|
||||
|
||||
// Validate checks PIN from the request header
|
||||
func (c *PINConfig) Validate(r *http.Request) bool {
|
||||
header := c.Header
|
||||
if header == "" {
|
||||
header = DefaultPINHeader
|
||||
}
|
||||
|
||||
providedPIN := r.Header.Get(header)
|
||||
if providedPIN == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(providedPIN), []byte(c.PIN)) == 1
|
||||
}
|
||||
+171
-271
@@ -1,298 +1,198 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth/oidc"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middleware wraps an HTTP handler with authentication middleware
|
||||
//go:embed auth.gohtml
|
||||
var authTemplate string
|
||||
|
||||
type Method string
|
||||
|
||||
var (
|
||||
MethodBasicAuth Method = "basic"
|
||||
MethodPIN Method = "pin"
|
||||
MethodBearer Method = "bearer"
|
||||
)
|
||||
|
||||
func (m Method) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookieName = "nb_session"
|
||||
sessionExpiration = 24 * time.Hour
|
||||
)
|
||||
|
||||
type session struct {
|
||||
UserID string
|
||||
Method Method
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Scheme interface {
|
||||
Type() Method
|
||||
// Authenticate should check the passed request and determine whether
|
||||
// it represents an authenticated user request. If it does not, then
|
||||
// an empty string should indicate an unauthenticated request which
|
||||
// will be rejected; optionally, it can also return any data that should
|
||||
// be included in a UI template when prompting the user to authenticate.
|
||||
// If the request is authenticated, then a user id should be returned
|
||||
// along with a boolean indicating whether a redirect is needed to clean
|
||||
// up authentication artifacts from the URLs query.
|
||||
Authenticate(*http.Request) (userid string, needsRedirect bool, promptData any)
|
||||
// Middleware is applied within the outer auth middleware, but they will
|
||||
// be applied after authentication if no scheme has authenticated a
|
||||
// request.
|
||||
// If no scheme Middleware blocks the request processing, then the auth
|
||||
// middleware will then present the user with the auth UI.
|
||||
Middleware(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
next http.Handler
|
||||
config *Config
|
||||
routeID string
|
||||
rejectResponse func(w http.ResponseWriter, r *http.Request)
|
||||
oidcHandler *oidc.Handler // OIDC handler for OAuth flow (contains config and JWT validator)
|
||||
domainsMux sync.RWMutex
|
||||
domains map[string][]Scheme
|
||||
sessionsMux sync.RWMutex
|
||||
sessions map[string]*session
|
||||
}
|
||||
|
||||
// authResult holds the result of an authentication attempt
|
||||
type authResult struct {
|
||||
authenticated bool
|
||||
method string
|
||||
userID string
|
||||
func NewMiddleware() *Middleware {
|
||||
mw := &Middleware{
|
||||
domains: make(map[string][]Scheme),
|
||||
sessions: make(map[string]*session),
|
||||
}
|
||||
// TODO: goroutine is leaked here.
|
||||
go mw.cleanupSessions()
|
||||
return mw
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface
|
||||
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if m.config.IsEmpty() {
|
||||
m.allowWithoutAuth(w, r)
|
||||
return
|
||||
}
|
||||
// Protect applies authentication middleware to the passed handler.
|
||||
// For each incoming request it will be checked against the middleware's
|
||||
// internal list of protected domains.
|
||||
// If the Host domain in the inbound request is not present, then it will
|
||||
// simply be passed through.
|
||||
// However, if the Host domain is present, then the specified authentication
|
||||
// schemes for that domain will be applied to the request.
|
||||
// In the event that no authentication schemes are defined for the domain,
|
||||
// then the request will also be simply passed through.
|
||||
func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
tmpl := template.Must(template.New("auth").Parse(authTemplate))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mw.domainsMux.RLock()
|
||||
schemes, exists := mw.domains[r.Host]
|
||||
mw.domainsMux.RUnlock()
|
||||
|
||||
result := m.authenticate(w, r)
|
||||
if result == nil {
|
||||
// Authentication triggered a redirect (e.g., OIDC flow)
|
||||
return
|
||||
}
|
||||
// Domains that are not configured here or have no authentication schemes applied should simply pass through.
|
||||
if !exists || len(schemes) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !result.authenticated {
|
||||
m.rejectRequest(w, r)
|
||||
return
|
||||
}
|
||||
// Check for an existing session to avoid users having to authenticate for every request.
|
||||
// TODO: This does not work if you are load balancing across multiple proxy servers.
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
mw.sessionsMux.RLock()
|
||||
sess, ok := mw.sessions[cookie.Value]
|
||||
mw.sessionsMux.RUnlock()
|
||||
if ok {
|
||||
ctx := withAuthMethod(r.Context(), sess.Method)
|
||||
ctx = withAuthUser(ctx, sess.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.continueWithAuth(w, r, result)
|
||||
// Try to authenticate with each scheme.
|
||||
methods := make(map[Method]any)
|
||||
for _, s := range schemes {
|
||||
userid, needsRedirect, promptData := s.Authenticate(r)
|
||||
if userid != "" {
|
||||
mw.createSession(w, r, userid, s.Type())
|
||||
if needsRedirect {
|
||||
// Clean the path and redirect to the naked URL.
|
||||
// This is intended to prevent leaking potentially
|
||||
// sensitive query parameters for some authentication
|
||||
// methods such as OIDC.
|
||||
http.Redirect(w, r, r.URL.Path, http.StatusFound)
|
||||
return
|
||||
}
|
||||
ctx := withAuthMethod(r.Context(), s.Type())
|
||||
ctx = withAuthUser(ctx, userid)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
methods[s.Type()] = promptData
|
||||
}
|
||||
|
||||
// The handler is passed through the scheme middlewares,
|
||||
// if none of them intercept the request, then this handler will
|
||||
// be called and present the user with the authentication page.
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tmpl.Execute(w, methods); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}))
|
||||
|
||||
// No authentication succeeded. Apply the scheme handlers.
|
||||
for _, s := range schemes {
|
||||
handler = s.Middleware(handler)
|
||||
}
|
||||
|
||||
// Run the unauthenticated request against the scheme handlers and the final UI handler.
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// allowWithoutAuth allows requests when no authentication is configured
|
||||
func (m *Middleware) allowWithoutAuth(w http.ResponseWriter, r *http.Request) {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"auth_method": "none",
|
||||
"path": r.URL.Path,
|
||||
}).Debug("No authentication configured, allowing request")
|
||||
r.Header.Set("X-Auth-Method", "none")
|
||||
m.next.ServeHTTP(w, r)
|
||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme) {
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
mw.domains[domain] = schemes
|
||||
}
|
||||
|
||||
// authenticate attempts to authenticate the request using configured methods
|
||||
// Returns nil if a redirect occurred (e.g., OIDC flow initiated)
|
||||
func (m *Middleware) authenticate(w http.ResponseWriter, r *http.Request) *authResult {
|
||||
if result := m.tryBasicAuth(r); result.authenticated {
|
||||
return result
|
||||
}
|
||||
|
||||
if result := m.tryPINAuth(r); result.authenticated {
|
||||
return result
|
||||
}
|
||||
|
||||
return m.tryBearerAuth(w, r)
|
||||
func (mw *Middleware) RemoveDomain(domain string) {
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
delete(mw.domains, domain)
|
||||
}
|
||||
|
||||
// tryBasicAuth attempts Basic authentication
|
||||
func (m *Middleware) tryBasicAuth(r *http.Request) *authResult {
|
||||
if m.config.BasicAuth == nil {
|
||||
return &authResult{}
|
||||
}
|
||||
func (mw *Middleware) createSession(w http.ResponseWriter, r *http.Request, userID string, method Method) {
|
||||
// Generate a random sessionID
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
sessionID := base64.URLEncoding.EncodeToString(b)
|
||||
|
||||
if !m.config.BasicAuth.Validate(r) {
|
||||
return &authResult{}
|
||||
mw.sessionsMux.Lock()
|
||||
mw.sessions[sessionID] = &session{
|
||||
UserID: userID,
|
||||
Method: method,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
mw.sessionsMux.Unlock()
|
||||
|
||||
result := &authResult{
|
||||
authenticated: true,
|
||||
method: "basic",
|
||||
}
|
||||
|
||||
if username, _, ok := r.BasicAuth(); ok {
|
||||
result.userID = username
|
||||
}
|
||||
|
||||
return result
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionID,
|
||||
HttpOnly: true, // This cookie is only for proxy access, so no scripts should touch it.
|
||||
Secure: true, // The proxy only accepts TLS traffic regardless of the service proxied behind.
|
||||
SameSite: http.SameSiteLaxMode, // TODO: might this actually be strict mode?
|
||||
})
|
||||
}
|
||||
|
||||
// tryPINAuth attempts PIN authentication
|
||||
func (m *Middleware) tryPINAuth(r *http.Request) *authResult {
|
||||
if m.config.PIN == nil {
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
if !m.config.PIN.Validate(r) {
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
return &authResult{
|
||||
authenticated: true,
|
||||
method: "pin",
|
||||
userID: "pin_user",
|
||||
}
|
||||
}
|
||||
|
||||
// tryBearerAuth attempts Bearer token authentication with JWT validation
|
||||
// Returns nil if OIDC redirect occurred
|
||||
func (m *Middleware) tryBearerAuth(w http.ResponseWriter, r *http.Request) *authResult {
|
||||
if m.config.Bearer == nil || m.oidcHandler == nil {
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
cookieName := m.oidcHandler.SessionCookieName()
|
||||
|
||||
if m.handleAuthTokenParameter(w, r, cookieName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result := m.trySessionCookie(r, cookieName); result.authenticated {
|
||||
return result
|
||||
}
|
||||
|
||||
if result := m.tryAuthorizationHeader(r); result.authenticated {
|
||||
return result
|
||||
}
|
||||
|
||||
m.oidcHandler.RedirectToProvider(w, r, m.routeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAuthTokenParameter processes the _auth_token query parameter from OIDC callback
|
||||
// Returns true if a redirect occurred
|
||||
func (m *Middleware) handleAuthTokenParameter(w http.ResponseWriter, r *http.Request, cookieName string) bool {
|
||||
authToken := r.URL.Query().Get("_auth_token")
|
||||
if authToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"host": r.Host,
|
||||
}).Info("Found auth token in query parameter, setting cookie and redirecting")
|
||||
|
||||
if !m.oidcHandler.ValidateJWT(authToken) {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
}).Warn("Invalid token in query parameter")
|
||||
return false
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: authToken,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 hour
|
||||
HttpOnly: true,
|
||||
Secure: false, // Set to false for HTTP testing, true for HTTPS in production
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
// Redirect to same URL without the token parameter
|
||||
redirectURL := m.buildCleanRedirectURL(r)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"redirect_url": redirectURL,
|
||||
}).Debug("Redirecting to clean URL after setting cookie")
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return true
|
||||
}
|
||||
|
||||
// buildCleanRedirectURL builds a redirect URL without the _auth_token parameter
|
||||
func (m *Middleware) buildCleanRedirectURL(r *http.Request) string {
|
||||
cleanURL := *r.URL
|
||||
q := cleanURL.Query()
|
||||
q.Del("_auth_token")
|
||||
cleanURL.RawQuery = q.Encode()
|
||||
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s%s", scheme, r.Host, cleanURL.String())
|
||||
}
|
||||
|
||||
// trySessionCookie attempts authentication using a session cookie
|
||||
func (m *Middleware) trySessionCookie(r *http.Request, cookieName string) *authResult {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"cookie_name": cookieName,
|
||||
"host": r.Host,
|
||||
"path": r.URL.Path,
|
||||
}).Debug("Checking for session cookie")
|
||||
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"error": err,
|
||||
}).Debug("No session cookie found")
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"cookie_name": cookieName,
|
||||
}).Debug("Session cookie found, validating JWT")
|
||||
|
||||
if !m.oidcHandler.ValidateJWT(cookie.Value) {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
}).Debug("JWT validation failed for session cookie")
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
return &authResult{
|
||||
authenticated: true,
|
||||
method: "bearer_session",
|
||||
userID: m.oidcHandler.ExtractUserID(cookie.Value),
|
||||
}
|
||||
}
|
||||
|
||||
// tryAuthorizationHeader attempts authentication using the Authorization header
|
||||
func (m *Middleware) tryAuthorizationHeader(r *http.Request) *authResult {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if !m.oidcHandler.ValidateJWT(token) {
|
||||
return &authResult{}
|
||||
}
|
||||
|
||||
return &authResult{
|
||||
authenticated: true,
|
||||
method: "bearer",
|
||||
userID: m.oidcHandler.ExtractUserID(token),
|
||||
}
|
||||
}
|
||||
|
||||
// rejectRequest rejects an unauthenticated request
|
||||
func (m *Middleware) rejectRequest(w http.ResponseWriter, r *http.Request) {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"path": r.URL.Path,
|
||||
}).Warn("Authentication failed")
|
||||
|
||||
if m.rejectResponse != nil {
|
||||
m.rejectResponse(w, r)
|
||||
} else {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// continueWithAuth continues the request with authenticated user info
|
||||
func (m *Middleware) continueWithAuth(w http.ResponseWriter, r *http.Request, result *authResult) {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": m.routeID,
|
||||
"auth_method": result.method,
|
||||
"user_id": result.userID,
|
||||
"path": r.URL.Path,
|
||||
}).Debug("Authentication successful")
|
||||
|
||||
// TODO: Find other means of auth logging than headers
|
||||
r.Header.Set("X-Auth-Method", result.method)
|
||||
r.Header.Set("X-Auth-User-ID", result.userID)
|
||||
|
||||
// Continue to next handler
|
||||
m.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Wrap wraps an HTTP handler with authentication middleware
|
||||
func Wrap(next http.Handler, authConfig *Config, routeID string, rejectResponse func(w http.ResponseWriter, r *http.Request), oidcHandler *oidc.Handler) http.Handler {
|
||||
if authConfig == nil {
|
||||
authConfig = &Config{}
|
||||
}
|
||||
|
||||
return &Middleware{
|
||||
next: next,
|
||||
config: authConfig,
|
||||
routeID: routeID,
|
||||
rejectResponse: rejectResponse,
|
||||
oidcHandler: oidcHandler,
|
||||
func (mw *Middleware) cleanupSessions() {
|
||||
for range time.Tick(time.Minute) {
|
||||
cutoff := time.Now().Add(-sessionExpiration)
|
||||
mw.sessionsMux.Lock()
|
||||
for id, sess := range mw.sessions {
|
||||
if sess.CreatedAt.Before(cutoff) {
|
||||
delete(mw.sessions, id)
|
||||
}
|
||||
}
|
||||
mw.sessionsMux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// OIDCConfig holds configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
OIDCProviderURL string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string
|
||||
OIDCScopes []string
|
||||
}
|
||||
|
||||
// oidcState stores CSRF state with expiration
|
||||
type oidcState struct {
|
||||
OriginalURL string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// OIDC implements the Scheme interface for JWT/OIDC authentication
|
||||
type OIDC struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauthConfig *oauth2.Config
|
||||
states map[string]*oidcState
|
||||
statesMux sync.RWMutex
|
||||
}
|
||||
|
||||
// NewOIDC creates a new OIDC authentication scheme
|
||||
func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
|
||||
if cfg.OIDCProviderURL == "" || cfg.OIDCClientID == "" {
|
||||
return nil, fmt.Errorf("OIDC provider URL and client ID are required")
|
||||
}
|
||||
|
||||
scopes := cfg.OIDCScopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, cfg.OIDCProviderURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
o := &OIDC{
|
||||
verifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
}),
|
||||
oauthConfig: &oauth2.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: provider.Endpoint(),
|
||||
},
|
||||
states: make(map[string]*oidcState),
|
||||
}
|
||||
|
||||
go o.cleanupStates()
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (*OIDC) Type() Method {
|
||||
return MethodBearer
|
||||
}
|
||||
|
||||
func (o *OIDC) Authenticate(r *http.Request) (string, bool, any) {
|
||||
// Try Authorization: Bearer <token> header
|
||||
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
if userID := o.validateToken(r.Context(), strings.TrimPrefix(auth, "Bearer ")); userID != "" {
|
||||
return userID, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try _auth_token query parameter (from OIDC callback redirect)
|
||||
if token := r.URL.Query().Get("_auth_token"); token != "" {
|
||||
if userID := o.validateToken(r.Context(), token); userID != "" {
|
||||
return userID, true, nil // Redirect needed to clean up URL
|
||||
}
|
||||
}
|
||||
|
||||
// If the request is not authenticated, return a redirect URL for the UI to
|
||||
// route the user through if they select OIDC login.
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
state := base64.URLEncoding.EncodeToString(b)
|
||||
|
||||
// TODO: this does not work if you are load balancing across multiple proxy servers.
|
||||
o.statesMux.Lock()
|
||||
o.states[state] = &oidcState{OriginalURL: fmt.Sprintf("https://%s%s", r.Host, r.URL), CreatedAt: time.Now()}
|
||||
o.statesMux.Unlock()
|
||||
|
||||
return "", false, o.oauthConfig.AuthCodeURL(state)
|
||||
}
|
||||
|
||||
// Middleware returns an http.Handler that handles OIDC callback and flow initiation.
|
||||
func (o *OIDC) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OIDC callback
|
||||
if r.URL.Path == "/oauth/callback" {
|
||||
o.handleCallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// validateToken validates a JWT ID token and returns the user ID (subject)
|
||||
func (o *OIDC) validateToken(ctx context.Context, token string) string {
|
||||
if o.verifier == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
idToken, err := o.verifier.Verify(ctx, token)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return idToken.Subject
|
||||
}
|
||||
|
||||
// handleCallback processes the OIDC callback
|
||||
func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
http.Error(w, "Invalid callback parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify and consume state
|
||||
o.statesMux.Lock()
|
||||
st, ok := o.states[state]
|
||||
if ok {
|
||||
delete(o.states, state)
|
||||
}
|
||||
o.statesMux.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
token, err := o.oauthConfig.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
slog.Error("Token exchange failed", "error", err)
|
||||
http.Error(w, "Authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer ID token if available
|
||||
idToken := token.AccessToken
|
||||
if id, ok := token.Extra("id_token").(string); ok && id != "" {
|
||||
idToken = id
|
||||
}
|
||||
|
||||
// Redirect back to original URL with token
|
||||
origURL, err := url.Parse(st.OriginalURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
q := origURL.Query()
|
||||
q.Set("_auth_token", idToken)
|
||||
origURL.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(w, r, origURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// cleanupStates periodically removes expired states
|
||||
func (o *OIDC) cleanupStates() {
|
||||
for range time.Tick(time.Minute) {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
o.statesMux.Lock()
|
||||
for k, v := range o.states {
|
||||
if v.CreatedAt.Before(cutoff) {
|
||||
delete(o.states, k)
|
||||
}
|
||||
}
|
||||
o.statesMux.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package oidc
|
||||
|
||||
// Config holds the global OIDC/OAuth configuration
|
||||
type Config struct {
|
||||
ProviderURL string `env:"NB_OIDC_PROVIDER_URL" json:"provider_url"`
|
||||
ClientID string `env:"NB_OIDC_CLIENT_ID" json:"client_id"`
|
||||
ClientSecret string `env:"NB_OIDC_CLIENT_SECRET" json:"client_secret"`
|
||||
RedirectURL string `env:"NB_OIDC_REDIRECT_URL" json:"redirect_url"`
|
||||
Scopes []string `env:"NB_OIDC_SCOPES" json:"scopes"`
|
||||
|
||||
JWTKeysLocation string `env:"NB_OIDC_JWT_KEYS_LOCATION" json:"jwt_keys_location"`
|
||||
JWTIssuer string `env:"NB_OIDC_JWT_ISSUER" json:"jwt_issuer"`
|
||||
JWTAudience []string `env:"NB_OIDC_JWT_AUDIENCE" json:"jwt_audience"`
|
||||
JWTIdpSignkeyRefreshEnabled bool `env:"NB_OIDC_JWT_IDP_SIGNKEY_REFRESH_ENABLED" json:"jwt_idp_signkey_refresh_enabled"`
|
||||
|
||||
SessionCookieName string `env:"NB_OIDC_SESSION_COOKIE_NAME" json:"session_cookie_name"`
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
)
|
||||
|
||||
// Handler manages OIDC authentication flow
|
||||
type Handler struct {
|
||||
config *Config
|
||||
stateStore *StateStore
|
||||
jwtValidator *jwt.Validator
|
||||
}
|
||||
|
||||
// NewHandler creates a new OIDC handler
|
||||
func NewHandler(config *Config, stateStore *StateStore) *Handler {
|
||||
var jwtValidator *jwt.Validator
|
||||
if config.JWTKeysLocation != "" {
|
||||
jwtValidator = jwt.NewValidator(
|
||||
config.JWTIssuer,
|
||||
config.JWTAudience,
|
||||
config.JWTKeysLocation,
|
||||
config.JWTIdpSignkeyRefreshEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
config: config,
|
||||
stateStore: stateStore,
|
||||
jwtValidator: jwtValidator,
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectToProvider initiates the OAuth/OIDC authentication flow by redirecting to the provider
|
||||
func (h *Handler) RedirectToProvider(w http.ResponseWriter, r *http.Request, routeID string) {
|
||||
// Generate random state for CSRF protection
|
||||
state, err := generateRandomString(32)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to generate OIDC state")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store state with original URL for redirect after auth
|
||||
// Include the full URL with scheme and host
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
originalURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.String())
|
||||
h.stateStore.Store(state, originalURL, routeID)
|
||||
|
||||
// Default scopes if not configured
|
||||
scopes := h.config.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
authURL, err := url.Parse(h.config.ProviderURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Invalid OIDC provider URL")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Append /authorize if it doesn't exist (common OIDC endpoint)
|
||||
if !strings.HasSuffix(authURL.Path, "/authorize") && !strings.HasSuffix(authURL.Path, "/auth") {
|
||||
authURL.Path = strings.TrimSuffix(authURL.Path, "/") + "/authorize"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", h.config.ClientID)
|
||||
params.Set("redirect_uri", h.config.RedirectURL)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", strings.Join(scopes, " "))
|
||||
params.Set("state", state)
|
||||
|
||||
if len(h.config.JWTAudience) > 0 && h.config.JWTAudience[0] != h.config.ClientID {
|
||||
params.Set("audience", h.config.JWTAudience[0])
|
||||
}
|
||||
|
||||
authURL.RawQuery = params.Encode()
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": routeID,
|
||||
"provider_url": authURL.String(),
|
||||
"redirect_url": h.config.RedirectURL,
|
||||
"state": state,
|
||||
}).Info("Redirecting to OIDC provider for authentication")
|
||||
|
||||
http.Redirect(w, r, authURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleCallback creates an HTTP handler for the OIDC callback endpoint
|
||||
func (h *Handler) HandleCallback() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get authorization code and state from query parameters
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if code == "" || state == "" {
|
||||
log.Error("Missing code or state in OIDC callback")
|
||||
http.Error(w, "Invalid callback parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state to prevent CSRF
|
||||
oidcSt, ok := h.stateStore.Get(state)
|
||||
if !ok {
|
||||
log.Error("Invalid or expired OIDC state")
|
||||
http.Error(w, "Invalid or expired state parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete state to prevent reuse
|
||||
h.stateStore.Delete(state)
|
||||
|
||||
// Exchange authorization code for token
|
||||
token, err := h.exchangeCodeForToken(code)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to exchange code for token")
|
||||
http.Error(w, "Authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the original URL to add the token as a query parameter
|
||||
origURL, err := url.Parse(oidcSt.OriginalURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to parse original URL")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Add token as query parameter so the original domain can set its own cookie
|
||||
// We use a special parameter name that the auth middleware will look for
|
||||
q := origURL.Query()
|
||||
q.Set("_auth_token", token)
|
||||
origURL.RawQuery = q.Encode()
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": oidcSt.RouteID,
|
||||
"original_url": oidcSt.OriginalURL,
|
||||
"redirect_url": origURL.String(),
|
||||
"callback_host": r.Host,
|
||||
}).Info("OIDC authentication successful, redirecting with token parameter")
|
||||
|
||||
// Redirect back to original URL with token parameter
|
||||
http.Redirect(w, r, origURL.String(), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeCodeForToken exchanges an authorization code for an access token
|
||||
func (h *Handler) exchangeCodeForToken(code string) (string, error) {
|
||||
// Build token endpoint URL
|
||||
tokenURL, err := url.Parse(h.config.ProviderURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid OIDC provider URL: %w", err)
|
||||
}
|
||||
|
||||
// Auth0 uses /oauth/token, standard OIDC uses /token
|
||||
// Check if path already contains token endpoint
|
||||
if !strings.Contains(tokenURL.Path, "/token") {
|
||||
tokenURL.Path = strings.TrimSuffix(tokenURL.Path, "/") + "/oauth/token"
|
||||
}
|
||||
|
||||
// Build request body
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", h.config.RedirectURL)
|
||||
data.Set("client_id", h.config.ClientID)
|
||||
|
||||
// Only include client_secret if it's provided (not needed for public/SPA clients)
|
||||
if h.config.ClientSecret != "" {
|
||||
data.Set("client_secret", h.config.ClientSecret)
|
||||
}
|
||||
|
||||
// Make token exchange request
|
||||
resp, err := http.PostForm(tokenURL.String(), data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", fmt.Errorf("no access token in response")
|
||||
}
|
||||
|
||||
// Return the ID token if available (contains user claims), otherwise access token
|
||||
if tokenResp.IDToken != "" {
|
||||
return tokenResp.IDToken, nil
|
||||
}
|
||||
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token
|
||||
func (h *Handler) ValidateJWT(tokenString string) bool {
|
||||
if h.jwtValidator == nil {
|
||||
log.Error("JWT validation failed: JWT validator not initialized")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
ctx := context.Background()
|
||||
parsedToken, err := h.jwtValidator.ValidateAndParse(ctx, tokenString)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("JWT validation failed")
|
||||
// Try to parse token without validation to see what's in it
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) == 3 {
|
||||
// Decode payload (middle part)
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"payload": string(payload),
|
||||
}).Debug("Token payload for debugging")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Token is valid if parsedToken is not nil and Valid is true
|
||||
return parsedToken != nil && parsedToken.Valid
|
||||
}
|
||||
|
||||
// ExtractUserID extracts the user ID from a JWT token
|
||||
func (h *Handler) ExtractUserID(tokenString string) string {
|
||||
if h.jwtValidator == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse the token
|
||||
ctx := context.Background()
|
||||
parsedToken, err := h.jwtValidator.ValidateAndParse(ctx, tokenString)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// parsedToken is already *jwtgo.Token from ValidateAndParse
|
||||
// Create extractor to get user auth info
|
||||
extractor := jwt.NewClaimsExtractor()
|
||||
userAuth, err := extractor.ToUserAuth(parsedToken)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("Failed to extract user ID from JWT")
|
||||
return ""
|
||||
}
|
||||
|
||||
return userAuth.UserId
|
||||
}
|
||||
|
||||
// SessionCookieName returns the configured session cookie name or default
|
||||
func (h *Handler) SessionCookieName() string {
|
||||
if h.config.SessionCookieName != "" {
|
||||
return h.config.SessionCookieName
|
||||
}
|
||||
return "auth_session"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import "time"
|
||||
|
||||
// State represents stored OIDC state information for CSRF protection
|
||||
type State struct {
|
||||
OriginalURL string
|
||||
CreatedAt time.Time
|
||||
RouteID string
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// StateExpiration is how long OIDC state tokens are valid
|
||||
StateExpiration = 10 * time.Minute
|
||||
)
|
||||
|
||||
// StateStore manages OIDC state tokens for CSRF protection
|
||||
type StateStore struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]*State
|
||||
}
|
||||
|
||||
// NewStateStore creates a new OIDC state store
|
||||
func NewStateStore() *StateStore {
|
||||
return &StateStore{
|
||||
states: make(map[string]*State),
|
||||
}
|
||||
}
|
||||
|
||||
// Store saves a state token with associated metadata
|
||||
func (s *StateStore) Store(stateToken, originalURL, routeID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.states[stateToken] = &State{
|
||||
OriginalURL: originalURL,
|
||||
CreatedAt: time.Now(),
|
||||
RouteID: routeID,
|
||||
}
|
||||
|
||||
s.cleanup()
|
||||
}
|
||||
|
||||
// Get retrieves a state by token
|
||||
func (s *StateStore) Get(stateToken string) (*State, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
st, ok := s.states[stateToken]
|
||||
return st, ok
|
||||
}
|
||||
|
||||
// Delete removes a state token
|
||||
func (s *StateStore) Delete(stateToken string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.states, stateToken)
|
||||
}
|
||||
|
||||
// cleanup removes expired state tokens (must be called with lock held)
|
||||
func (s *StateStore) cleanup() {
|
||||
cutoff := time.Now().Add(-StateExpiration)
|
||||
for k, v := range s.states {
|
||||
if v.CreatedAt.Before(cutoff) {
|
||||
delete(s.states, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// generateRandomString generates a cryptographically secure random string of the specified length
|
||||
func generateRandomString(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
userId = "pin-user"
|
||||
formId = "pin"
|
||||
)
|
||||
|
||||
type Pin struct {
|
||||
pin string
|
||||
}
|
||||
|
||||
func NewPin(pin string) Pin {
|
||||
return Pin{
|
||||
pin: pin,
|
||||
}
|
||||
}
|
||||
|
||||
func (Pin) Type() Method {
|
||||
return MethodPIN
|
||||
}
|
||||
|
||||
// Authenticate attempts to authenticate the request using a form
|
||||
// value passed in the request.
|
||||
// If authentication fails, the required HTTP form ID is returned
|
||||
// so that it can be injected into a request from the UI so that
|
||||
// authentication may be successful.
|
||||
func (p Pin) Authenticate(r *http.Request) (string, bool, any) {
|
||||
pin := r.FormValue(formId)
|
||||
|
||||
// Compare the passed pin with the expected pin.
|
||||
if subtle.ConstantTimeCompare([]byte(pin), []byte(p.pin)) == 1 {
|
||||
return userId, false, nil
|
||||
}
|
||||
|
||||
return "", false, formId
|
||||
}
|
||||
|
||||
func (p Pin) Middleware(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type requestContextKey string
|
||||
|
||||
const (
|
||||
serviceIdKey requestContextKey = "serviceId"
|
||||
)
|
||||
|
||||
func withServiceId(ctx context.Context, serviceId string) context.Context {
|
||||
return context.WithValue(ctx, serviceIdKey, serviceId)
|
||||
}
|
||||
|
||||
func ServiceIdFromContext(ctx context.Context) string {
|
||||
v := ctx.Value(serviceIdKey)
|
||||
serviceId, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return serviceId
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ReverseProxy struct {
|
||||
transport http.RoundTripper
|
||||
mappingsMux sync.RWMutex
|
||||
mappings map[string]Mapping
|
||||
}
|
||||
|
||||
// NewReverseProxy configures a new NetBird ReverseProxy.
|
||||
// This is a wrapper around an httputil.ReverseProxy set
|
||||
// to dynamically route requests based on internal mapping
|
||||
// between requested URLs and targets.
|
||||
// The internal mappings can be modified using the AddMapping
|
||||
// and RemoveMapping functions.
|
||||
func NewReverseProxy(transport http.RoundTripper) *ReverseProxy {
|
||||
return &ReverseProxy{
|
||||
transport: transport,
|
||||
mappings: make(map[string]Mapping),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
target, serviceId, exists := p.findTargetForRequest(r)
|
||||
if !exists {
|
||||
// No mapping found so return an error here.
|
||||
// TODO: prettier error page.
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the serviceId in the context for later retrieval.
|
||||
ctx := withServiceId(r.Context(), serviceId)
|
||||
|
||||
// Set up a reverse proxy using the transport and then use it to serve the request.
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Transport = p.transport
|
||||
proxy.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mapping struct {
|
||||
ID string
|
||||
Host string
|
||||
Paths map[string]*url.URL
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) findTargetForRequest(req *http.Request) (*url.URL, string, bool) {
|
||||
p.mappingsMux.RLock()
|
||||
if p.mappings == nil {
|
||||
p.mappingsMux.RUnlock()
|
||||
p.mappingsMux.Lock()
|
||||
defer p.mappingsMux.Unlock()
|
||||
p.mappings = make(map[string]Mapping)
|
||||
// There cannot be any loaded Mappings as we have only just initialized.
|
||||
return nil, "", false
|
||||
}
|
||||
defer p.mappingsMux.RUnlock()
|
||||
m, exists := p.mappings[req.Host]
|
||||
if !exists {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Sort paths by length (longest first) in a naive attempt to match the most specific route first.
|
||||
paths := make([]string, 0, len(m.Paths))
|
||||
for path := range m.Paths {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
return len(paths[i]) > len(paths[j])
|
||||
})
|
||||
|
||||
for _, path := range paths {
|
||||
if strings.HasPrefix(req.URL.Path, path) {
|
||||
return m.Paths[path], m.ID, true
|
||||
}
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) AddMapping(m Mapping) {
|
||||
p.mappingsMux.Lock()
|
||||
defer p.mappingsMux.Unlock()
|
||||
if p.mappings == nil {
|
||||
p.mappings = make(map[string]Mapping)
|
||||
}
|
||||
p.mappings[m.Host] = m
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) RemoveMapping(m Mapping) {
|
||||
p.mappingsMux.Lock()
|
||||
defer p.mappingsMux.Unlock()
|
||||
delete(p.mappings, m.Host)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package certmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Manager defines the interface for certificate management
|
||||
type Manager interface {
|
||||
// IsEnabled returns whether certificate management is enabled
|
||||
IsEnabled() bool
|
||||
|
||||
// AddDomain adds a domain to the allowed hosts list
|
||||
AddDomain(domain string)
|
||||
|
||||
// RemoveDomain removes a domain from the allowed hosts list
|
||||
RemoveDomain(domain string)
|
||||
|
||||
// IssueCertificate eagerly issues a certificate for a domain
|
||||
IssueCertificate(ctx context.Context, domain string) error
|
||||
|
||||
// TLSConfig returns the TLS configuration for the HTTPS server
|
||||
TLSConfig() *tls.Config
|
||||
|
||||
// HTTPHandler returns the HTTP handler for ACME challenges (or fallback)
|
||||
HTTPHandler(fallback http.Handler) http.Handler
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package certmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// LetsEncryptManager handles TLS certificate issuance via Let's Encrypt
|
||||
type LetsEncryptManager struct {
|
||||
autocertManager *autocert.Manager
|
||||
allowedHosts map[string]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// LetsEncryptConfig holds Let's Encrypt certificate manager configuration
|
||||
type LetsEncryptConfig struct {
|
||||
// Email for Let's Encrypt registration (required)
|
||||
Email string
|
||||
|
||||
// CertCacheDir is the directory to cache certificates
|
||||
CertCacheDir string
|
||||
}
|
||||
|
||||
// NewLetsEncrypt creates a new Let's Encrypt certificate manager
|
||||
func NewLetsEncrypt(config LetsEncryptConfig) *LetsEncryptManager {
|
||||
m := &LetsEncryptManager{
|
||||
allowedHosts: make(map[string]bool),
|
||||
}
|
||||
|
||||
m.autocertManager = &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: m.hostPolicy,
|
||||
Cache: autocert.DirCache(config.CertCacheDir),
|
||||
Email: config.Email,
|
||||
RenewBefore: 0, // Use default 30 days prior to expiration
|
||||
}
|
||||
|
||||
log.Info("Let's Encrypt certificate manager initialized")
|
||||
return m
|
||||
}
|
||||
|
||||
// IsEnabled returns whether certificate management is enabled
|
||||
func (m *LetsEncryptManager) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AddDomain adds a domain to the allowed hosts list
|
||||
func (m *LetsEncryptManager) AddDomain(domain string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.allowedHosts[domain] = true
|
||||
log.Infof("Added domain to Let's Encrypt manager: %s", domain)
|
||||
}
|
||||
|
||||
// RemoveDomain removes a domain from the allowed hosts list
|
||||
func (m *LetsEncryptManager) RemoveDomain(domain string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.allowedHosts, domain)
|
||||
log.Infof("Removed domain from Let's Encrypt manager: %s", domain)
|
||||
}
|
||||
|
||||
// IssueCertificate eagerly issues a Let's Encrypt certificate for a domain
|
||||
func (m *LetsEncryptManager) IssueCertificate(ctx context.Context, domain string) error {
|
||||
log.Infof("Issuing Let's Encrypt certificate for domain: %s", domain)
|
||||
|
||||
hello := &tls.ClientHelloInfo{
|
||||
ServerName: domain,
|
||||
}
|
||||
|
||||
cert, err := m.autocertManager.GetCertificate(hello)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to issue certificate for domain %s: %w", domain, err)
|
||||
}
|
||||
|
||||
log.Infof("Successfully issued Let's Encrypt certificate for domain: %s (expires: %s)",
|
||||
domain, cert.Leaf.NotAfter.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TLSConfig returns the TLS configuration for the HTTPS server
|
||||
func (m *LetsEncryptManager) TLSConfig() *tls.Config {
|
||||
return m.autocertManager.TLSConfig()
|
||||
}
|
||||
|
||||
// HTTPHandler returns the HTTP handler for ACME challenges
|
||||
func (m *LetsEncryptManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
return m.autocertManager.HTTPHandler(fallback)
|
||||
}
|
||||
|
||||
// hostPolicy validates that a requested host is in the allowed hosts list
|
||||
func (m *LetsEncryptManager) hostPolicy(ctx context.Context, host string) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.allowedHosts[host] {
|
||||
log.Debugf("ACME challenge accepted for domain: %s", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("ACME challenge rejected for unconfigured domain: %s", host)
|
||||
return fmt.Errorf("host %s not configured", host)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package certmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SelfSignedManager handles self-signed certificate generation for local testing
|
||||
type SelfSignedManager struct {
|
||||
certificates map[string]*tls.Certificate // domain -> certificate cache
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSelfSigned creates a new self-signed certificate manager
|
||||
func NewSelfSigned() *SelfSignedManager {
|
||||
log.Info("Self-signed certificate manager initialized")
|
||||
return &SelfSignedManager{
|
||||
certificates: make(map[string]*tls.Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether certificate management is enabled
|
||||
func (m *SelfSignedManager) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AddDomain adds a domain to the manager (no-op for self-signed, but maintains interface)
|
||||
func (m *SelfSignedManager) AddDomain(domain string) {
|
||||
log.Infof("Added domain to self-signed manager: %s", domain)
|
||||
}
|
||||
|
||||
// RemoveDomain removes a domain from the manager
|
||||
func (m *SelfSignedManager) RemoveDomain(domain string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.certificates, domain)
|
||||
log.Infof("Removed domain from self-signed manager: %s", domain)
|
||||
}
|
||||
|
||||
// IssueCertificate generates and caches a self-signed certificate for a domain
|
||||
func (m *SelfSignedManager) IssueCertificate(ctx context.Context, domain string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.certificates[domain]; exists {
|
||||
log.Debugf("Self-signed certificate already exists for domain: %s", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
cert, err := m.generateCertificate(domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.certificates[domain] = cert
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TLSConfig returns the TLS configuration for the HTTPS server
|
||||
func (m *SelfSignedManager) TLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: m.getCertificate,
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPHandler returns the fallback handler (no ACME challenges for self-signed)
|
||||
func (m *SelfSignedManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getCertificate returns a self-signed certificate for the requested domain
|
||||
func (m *SelfSignedManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
m.mu.RLock()
|
||||
cert, exists := m.certificates[hello.ServerName]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
log.Infof("Generating self-signed certificate on-demand for: %s", hello.ServerName)
|
||||
|
||||
newCert, err := m.generateCertificate(hello.ServerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.certificates[hello.ServerName] = newCert
|
||||
m.mu.Unlock()
|
||||
|
||||
return newCert, nil
|
||||
}
|
||||
|
||||
// generateCertificate generates a self-signed certificate for a domain
|
||||
func (m *SelfSignedManager) generateCertificate(domain string) (*tls.Certificate, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"NetBird Local Development"},
|
||||
CommonName: domain,
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
tlsCert := &tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: priv,
|
||||
Leaf: cert,
|
||||
}
|
||||
|
||||
log.Infof("Generated self-signed certificate for domain: %s (expires: %s)",
|
||||
domain, cert.NotAfter.Format(time.RFC3339))
|
||||
|
||||
return tlsCert, nil
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth/oidc"
|
||||
)
|
||||
|
||||
// Config holds the reverse proxy configuration
|
||||
type Config struct {
|
||||
// ListenAddress is the address to listen on for HTTPS (default ":443")
|
||||
ListenAddress string `env:"NB_REVERSE_PROXY_LISTEN_ADDRESS" envDefault:":443" json:"listen_address"`
|
||||
|
||||
// ManagementURL is the URL of the management server
|
||||
ManagementURL string `env:"NB_REVERSE_PROXY_MANAGEMENT_URL" json:"management_url"`
|
||||
|
||||
// HTTPListenAddress is the address for HTTP (default ":80")
|
||||
// Used for ACME challenges (Let's Encrypt HTTP-01 challenge)
|
||||
HTTPListenAddress string `env:"NB_REVERSE_PROXY_HTTP_LISTEN_ADDRESS" envDefault:":80" json:"http_listen_address"`
|
||||
|
||||
// CertMode specifies certificate mode: "letsencrypt" or "selfsigned" (default: "letsencrypt")
|
||||
// "letsencrypt" - Uses Let's Encrypt for production certificates (requires public domain)
|
||||
// "selfsigned" - Generates self-signed certificates for local testing
|
||||
CertMode string `env:"NB_REVERSE_PROXY_CERT_MODE" envDefault:"letsencrypt" json:"cert_mode"`
|
||||
|
||||
// TLSEmail is the email for Let's Encrypt registration (required for letsencrypt mode)
|
||||
TLSEmail string `env:"NB_REVERSE_PROXY_TLS_EMAIL" json:"tls_email"`
|
||||
|
||||
// CertCacheDir is the directory to cache certificates (for letsencrypt mode, default "./certs")
|
||||
CertCacheDir string `env:"NB_REVERSE_PROXY_CERT_CACHE_DIR" envDefault:"./certs" json:"cert_cache_dir"`
|
||||
|
||||
// OIDCConfig is the global OIDC/OAuth configuration for authentication
|
||||
// This is shared across all routes that use Bearer authentication
|
||||
// If nil, routes with Bearer auth will fail to initialize
|
||||
OIDCConfig *oidc.Config `json:"oidc_config"`
|
||||
}
|
||||
|
||||
// RouteConfig defines a routing configuration
|
||||
type RouteConfig struct {
|
||||
// ID is a unique identifier for this route
|
||||
ID string
|
||||
|
||||
// Domain is the domain to listen on (e.g., "example.com" or "*" for all)
|
||||
Domain string
|
||||
|
||||
// PathMappings defines paths that should be forwarded to specific ports
|
||||
// Key is the path prefix (e.g., "/", "/api", "/admin")
|
||||
// Value is the target IP:port (e.g., "192.168.1.100:3000")
|
||||
// Must have at least one entry. Use "/" or "" for the default/catch-all route.
|
||||
PathMappings map[string]string
|
||||
|
||||
SetupKey string
|
||||
nbClient *embed.Client
|
||||
|
||||
// AuthConfig is optional authentication configuration for this route
|
||||
// Configure ONE of: BasicAuth, PIN, or Bearer (JWT/OIDC)
|
||||
// If nil, requests pass through without authentication
|
||||
AuthConfig *auth.Config
|
||||
|
||||
// AuthRejectResponse is an optional custom response for authentication failures
|
||||
// If nil, returns 401 Unauthorized with WWW-Authenticate header
|
||||
AuthRejectResponse func(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// routeEntry represents a compiled route with its proxy
|
||||
type routeEntry struct {
|
||||
routeConfig *RouteConfig
|
||||
path string
|
||||
target string
|
||||
proxy *httputil.ReverseProxy
|
||||
handler http.Handler // handler wraps proxy with middleware (auth, logging, etc.)
|
||||
}
|
||||
|
||||
// RequestDataCallback is called for each proxied request with metrics
|
||||
type RequestDataCallback func(data RequestData)
|
||||
|
||||
// RequestData contains metrics for a proxied request
|
||||
type RequestData struct {
|
||||
ServiceID string
|
||||
Host string
|
||||
Path string
|
||||
DurationMs int64
|
||||
Method string
|
||||
ResponseCode int32
|
||||
SourceIP string
|
||||
AuthMechanism string
|
||||
UserID string
|
||||
AuthSuccess bool
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth"
|
||||
)
|
||||
|
||||
// buildHandler creates the main HTTP handler with router for static endpoints
|
||||
func (p *Proxy) buildHandler() http.Handler {
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Register static endpoints
|
||||
router.HandleFunc("/auth/callback", p.handleOIDCCallback).Methods("GET")
|
||||
|
||||
// Catch-all handler for dynamic proxy routing
|
||||
router.PathPrefix("/").HandlerFunc(p.handleProxyRequest)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// handleProxyRequest handles all dynamic proxy requests
|
||||
func (p *Proxy) handleProxyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
startTime := time.Now()
|
||||
|
||||
routeEntry := p.findRoute(r.Host, r.URL.Path)
|
||||
if routeEntry == nil {
|
||||
log.Warnf("No route found for host=%s path=%s", r.Host, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rw := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
routeEntry.handler.ServeHTTP(rw, r)
|
||||
|
||||
if p.requestCallback != nil {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
host := r.Host
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// TODO: extract logging data
|
||||
authMechanism := r.Header.Get("X-Auth-Method")
|
||||
if authMechanism == "" {
|
||||
authMechanism = "none"
|
||||
}
|
||||
userID := r.Header.Get("X-Auth-User-ID")
|
||||
authSuccess := rw.statusCode != http.StatusUnauthorized && rw.statusCode != http.StatusForbidden
|
||||
sourceIP := extractSourceIP(r)
|
||||
|
||||
data := RequestData{
|
||||
ServiceID: routeEntry.routeConfig.ID,
|
||||
Host: host,
|
||||
Path: r.URL.Path,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
Method: r.Method,
|
||||
ResponseCode: int32(rw.statusCode),
|
||||
SourceIP: sourceIP,
|
||||
AuthMechanism: authMechanism,
|
||||
UserID: userID,
|
||||
AuthSuccess: authSuccess,
|
||||
}
|
||||
|
||||
p.requestCallback(data)
|
||||
}
|
||||
}
|
||||
|
||||
// findRoute finds the matching route for a given host and path
|
||||
func (p *Proxy) findRoute(host, path string) *routeEntry {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
routeConfig, exists := p.routes[host]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []*routeEntry
|
||||
|
||||
for routePath, target := range routeConfig.PathMappings {
|
||||
proxy := p.createProxy(routeConfig, target)
|
||||
|
||||
handler := auth.Wrap(proxy, routeConfig.AuthConfig, routeConfig.ID, routeConfig.AuthRejectResponse, p.oidcHandler)
|
||||
|
||||
if routeConfig.AuthConfig != nil && !routeConfig.AuthConfig.IsEmpty() {
|
||||
var authType string
|
||||
if routeConfig.AuthConfig.BasicAuth != nil {
|
||||
authType = "basic_auth"
|
||||
} else if routeConfig.AuthConfig.PIN != nil {
|
||||
authType = "pin"
|
||||
} else if routeConfig.AuthConfig.Bearer != nil {
|
||||
authType = "bearer_jwt"
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": routeConfig.ID,
|
||||
"auth_type": authType,
|
||||
}).Debug("Auth middleware enabled for route")
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": routeConfig.ID,
|
||||
}).Debug("No authentication configured for route")
|
||||
}
|
||||
|
||||
entries = append(entries, &routeEntry{
|
||||
routeConfig: routeConfig,
|
||||
path: routePath,
|
||||
target: target,
|
||||
proxy: proxy,
|
||||
handler: handler,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by path specificity (longest first)
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
pi, pj := entries[i].path, entries[j].path
|
||||
// Empty string or "/" goes last (catch-all)
|
||||
if pi == "" || pi == "/" {
|
||||
return false
|
||||
}
|
||||
if pj == "" || pj == "/" {
|
||||
return true
|
||||
}
|
||||
return len(pi) > len(pj)
|
||||
})
|
||||
|
||||
// Find first matching entry
|
||||
for _, entry := range entries {
|
||||
if entry.path == "" || entry.path == "/" {
|
||||
// Catch-all route
|
||||
return entry
|
||||
}
|
||||
if strings.HasPrefix(path, entry.path) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createProxy creates a reverse proxy for a target with the route's connection
|
||||
func (p *Proxy) createProxy(routeConfig *RouteConfig, target string) *httputil.ReverseProxy {
|
||||
targetURL, err := url.Parse("http://" + target)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse target URL %s: %v", target, err)
|
||||
return &httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
|
||||
proxy.Transport = &http.Transport{
|
||||
DialContext: routeConfig.nbClient.DialContext,
|
||||
MaxIdleConns: 1,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 0,
|
||||
DisableKeepAlives: false,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Errorf("Proxy error for %s%s: %v", r.Host, r.URL.Path, err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
// handleOIDCCallback handles the global /auth/callback endpoint for all routes
|
||||
func (p *Proxy) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if p.oidcHandler == nil {
|
||||
log.Error("OIDC callback received but no OIDC handler configured")
|
||||
http.Error(w, "Authentication not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
handler := p.oidcHandler.HandleCallback()
|
||||
handler(w, r)
|
||||
}
|
||||
|
||||
// extractSourceIP extracts the source IP from the request
|
||||
func extractSourceIP(r *http.Request) string {
|
||||
// Try X-Forwarded-For header first
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP in the list
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
|
||||
// Try X-Real-IP header
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return strings.TrimSpace(xri)
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
|
||||
return r.RemoteAddr[:idx]
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/auth/oidc"
|
||||
"github.com/netbirdio/netbird/proxy/internal/reverseproxy/certmanager"
|
||||
)
|
||||
|
||||
// Proxy wraps a reverse proxy with dynamic routing
|
||||
type Proxy struct {
|
||||
config Config
|
||||
mu sync.RWMutex
|
||||
routes map[string]*RouteConfig // key is host/domain (for fast O(1) lookup)
|
||||
server *http.Server
|
||||
httpServer *http.Server
|
||||
certManager certmanager.Manager
|
||||
isRunning bool
|
||||
requestCallback RequestDataCallback
|
||||
oidcHandler *oidc.Handler
|
||||
}
|
||||
|
||||
// New creates a new reverse proxy
|
||||
func New(config Config) (*Proxy, error) {
|
||||
if config.ListenAddress == "" {
|
||||
config.ListenAddress = ":443"
|
||||
}
|
||||
if config.HTTPListenAddress == "" {
|
||||
config.HTTPListenAddress = ":80"
|
||||
}
|
||||
if config.CertCacheDir == "" {
|
||||
config.CertCacheDir = "./certs"
|
||||
}
|
||||
|
||||
if config.CertMode == "" {
|
||||
config.CertMode = "letsencrypt"
|
||||
}
|
||||
|
||||
if config.CertMode == "letsencrypt" && config.TLSEmail == "" {
|
||||
return nil, fmt.Errorf("TLSEmail is required for letsencrypt mode")
|
||||
}
|
||||
|
||||
if config.OIDCConfig != nil && config.OIDCConfig.SessionCookieName == "" {
|
||||
config.OIDCConfig.SessionCookieName = "auth_session"
|
||||
}
|
||||
|
||||
var certMgr certmanager.Manager
|
||||
if config.CertMode == "selfsigned" {
|
||||
// HTTPS with self-signed certificates (for local testing)
|
||||
certMgr = certmanager.NewSelfSigned()
|
||||
} else {
|
||||
// HTTPS with Let's Encrypt (for production)
|
||||
certMgr = certmanager.NewLetsEncrypt(certmanager.LetsEncryptConfig{
|
||||
Email: config.TLSEmail,
|
||||
CertCacheDir: config.CertCacheDir,
|
||||
})
|
||||
}
|
||||
|
||||
p := &Proxy{
|
||||
config: config,
|
||||
routes: make(map[string]*RouteConfig),
|
||||
certManager: certMgr,
|
||||
isRunning: false,
|
||||
}
|
||||
|
||||
if config.OIDCConfig != nil {
|
||||
stateStore := oidc.NewStateStore()
|
||||
p.oidcHandler = oidc.NewHandler(config.OIDCConfig, stateStore)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Start starts the reverse proxy server (non-blocking)
|
||||
func (p *Proxy) Start() error {
|
||||
p.mu.Lock()
|
||||
if p.isRunning {
|
||||
p.mu.Unlock()
|
||||
return fmt.Errorf("reverse proxy already running")
|
||||
}
|
||||
p.isRunning = true
|
||||
p.mu.Unlock()
|
||||
|
||||
handler := p.buildHandler()
|
||||
|
||||
return p.startHTTPS(handler)
|
||||
}
|
||||
|
||||
// startHTTPS starts the proxy with HTTPS
|
||||
func (p *Proxy) startHTTPS(handler http.Handler) error {
|
||||
p.httpServer = &http.Server{
|
||||
Addr: p.config.HTTPListenAddress,
|
||||
Handler: p.certManager.HTTPHandler(nil),
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Infof("Starting HTTP server on %s", p.config.HTTPListenAddress)
|
||||
if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Errorf("HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
p.server = &http.Server{
|
||||
Addr: p.config.ListenAddress,
|
||||
Handler: handler,
|
||||
TLSConfig: p.certManager.TLSConfig(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Infof("Starting HTTPS reverse proxy server on %s", p.config.ListenAddress)
|
||||
if err := p.server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
log.Errorf("HTTPS server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the reverse proxy server
|
||||
func (p *Proxy) Stop(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
if !p.isRunning {
|
||||
p.mu.Unlock()
|
||||
return fmt.Errorf("reverse proxy not running")
|
||||
}
|
||||
p.isRunning = false
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Info("Stopping reverse proxy server...")
|
||||
|
||||
if p.httpServer != nil {
|
||||
if err := p.httpServer.Shutdown(ctx); err != nil {
|
||||
log.Errorf("Error shutting down HTTP server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.server != nil {
|
||||
if err := p.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("error shutting down server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Reverse proxy server stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns whether the proxy is running
|
||||
func (p *Proxy) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.isRunning
|
||||
}
|
||||
|
||||
// SetRequestCallback sets the callback for request metrics
|
||||
func (p *Proxy) SetRequestCallback(callback RequestDataCallback) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.requestCallback = callback
|
||||
}
|
||||
|
||||
// GetConfig returns the proxy configuration
|
||||
func (p *Proxy) GetConfig() Config {
|
||||
return p.config
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
)
|
||||
|
||||
const (
|
||||
clientStartupTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// AddRoute adds a new route to the proxy
|
||||
func (p *Proxy) AddRoute(route *RouteConfig) error {
|
||||
if route == nil {
|
||||
return fmt.Errorf("route cannot be nil")
|
||||
}
|
||||
if route.ID == "" {
|
||||
return fmt.Errorf("route ID is required")
|
||||
}
|
||||
if route.Domain == "" {
|
||||
return fmt.Errorf("route Domain is required")
|
||||
}
|
||||
if len(route.PathMappings) == 0 {
|
||||
return fmt.Errorf("route must have at least one path mapping")
|
||||
}
|
||||
if route.SetupKey == "" {
|
||||
return fmt.Errorf("route setup key is required")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if _, exists := p.routes[route.Domain]; exists {
|
||||
return fmt.Errorf("route for domain %s already exists", route.Domain)
|
||||
}
|
||||
|
||||
client, err := embed.New(embed.Options{DeviceName: fmt.Sprintf("ingress-%s", route.ID), ManagementURL: p.config.ManagementURL, SetupKey: route.SetupKey, LogOutput: io.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded client for route %s: %v", route.ID, err)
|
||||
}
|
||||
|
||||
ctx, _ := context.WithTimeout(context.Background(), clientStartupTimeout)
|
||||
err = client.Start(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start embedded client for route %s: %v", route.ID, err)
|
||||
}
|
||||
|
||||
route.nbClient = client
|
||||
|
||||
p.routes[route.Domain] = route
|
||||
|
||||
p.certManager.AddDomain(route.Domain)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": route.ID,
|
||||
"domain": route.Domain,
|
||||
"paths": len(route.PathMappings),
|
||||
}).Info("Added route")
|
||||
|
||||
go func(domain string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := p.certManager.IssueCertificate(ctx, domain); err != nil {
|
||||
log.Errorf("Failed to issue certificate: %v", err)
|
||||
// TODO: Better error feedback mechanism
|
||||
}
|
||||
}(route.Domain)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRoute removes a route by domain
|
||||
func (p *Proxy) RemoveRoute(domain string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if _, exists := p.routes[domain]; !exists {
|
||||
return fmt.Errorf("route for domain %s not found", domain)
|
||||
}
|
||||
|
||||
delete(p.routes, domain)
|
||||
|
||||
p.certManager.RemoveDomain(domain)
|
||||
|
||||
log.Infof("Removed route for domain: %s", domain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRoute updates an existing route
|
||||
func (p *Proxy) UpdateRoute(route *RouteConfig) error {
|
||||
if route == nil {
|
||||
return fmt.Errorf("route cannot be nil")
|
||||
}
|
||||
if route.ID == "" {
|
||||
return fmt.Errorf("route ID is required")
|
||||
}
|
||||
if route.Domain == "" {
|
||||
return fmt.Errorf("route Domain is required")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if _, exists := p.routes[route.Domain]; !exists {
|
||||
return fmt.Errorf("route for domain %s not found", route.Domain)
|
||||
}
|
||||
|
||||
p.routes[route.Domain] = route
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"route_id": route.ID,
|
||||
"domain": route.Domain,
|
||||
"paths": len(route.PathMappings),
|
||||
}).Info("Updated route")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRoutes returns a list of all configured domains
|
||||
func (p *Proxy) ListRoutes() []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
domains := make([]string, 0, len(p.routes))
|
||||
for domain := range p.routes {
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
// GetRoute returns a route configuration by domain
|
||||
func (p *Proxy) GetRoute(domain string) (*RouteConfig, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
route, exists := p.routes[domain]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("route for domain %s not found", domain)
|
||||
}
|
||||
|
||||
return route, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package roundtrip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/embed"
|
||||
)
|
||||
|
||||
const deviceNamePrefix = "ingress-"
|
||||
|
||||
// NetBird provides an http.RoundTripper implementation
|
||||
// backed by underlying NetBird connections.
|
||||
type NetBird struct {
|
||||
mgmtAddr string
|
||||
|
||||
clientsMux sync.RWMutex
|
||||
clients map[string]*http.Client
|
||||
}
|
||||
|
||||
func NewNetBird(mgmtAddr string) *NetBird {
|
||||
return &NetBird{
|
||||
mgmtAddr: mgmtAddr,
|
||||
clients: make(map[string]*http.Client),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NetBird) AddPeer(domain, key string) error {
|
||||
client, err := embed.New(embed.Options{
|
||||
DeviceName: deviceNamePrefix + domain,
|
||||
ManagementURL: n.mgmtAddr,
|
||||
SetupKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create netbird client: %w", err)
|
||||
}
|
||||
n.clientsMux.Lock()
|
||||
defer n.clientsMux.Unlock()
|
||||
n.clients[domain] = client.NewHTTPClient()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NetBird) RemovePeer(domain string) {
|
||||
n.clientsMux.Lock()
|
||||
defer n.clientsMux.Unlock()
|
||||
delete(n.clients, domain)
|
||||
}
|
||||
|
||||
func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
n.clientsMux.RLock()
|
||||
client, exists := n.clients[req.Host]
|
||||
// Immediately unlock after retrieval here rather than defer to avoid
|
||||
// the call to client.Do blocking other clients being used whilst one
|
||||
// is in use.
|
||||
n.clientsMux.RUnlock()
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no peer connection found for host: %s", req.Host)
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
Reference in New Issue
Block a user