trying embedded caddy reverse proxy

This commit is contained in:
pascal
2026-01-14 17:16:42 +01:00
parent d9118eb239
commit 626e892e3b
30 changed files with 6978 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
package errors
import "fmt"
// Configuration errors
func NewConfigInvalid(message string) *AppError {
return New(CodeConfigInvalid, message)
}
func NewConfigNotFound(path string) *AppError {
return New(CodeConfigNotFound, fmt.Sprintf("configuration file not found: %s", path))
}
func WrapConfigParseFailed(err error, path string) *AppError {
return Wrap(CodeConfigParseFailed, fmt.Sprintf("failed to parse configuration file: %s", path), err)
}
// Server errors
func NewServerStartFailed(err error, reason string) *AppError {
return Wrap(CodeServerStartFailed, fmt.Sprintf("server start failed: %s", reason), err)
}
func NewServerStopFailed(err error) *AppError {
return Wrap(CodeServerStopFailed, "server shutdown failed", err)
}
func NewServerAlreadyRunning() *AppError {
return New(CodeServerAlreadyRunning, "server is already running")
}
func NewServerNotRunning() *AppError {
return New(CodeServerNotRunning, "server is not running")
}
// Proxy errors
func NewProxyBackendUnavailable(backend string, err error) *AppError {
return Wrap(CodeProxyBackendUnavailable, fmt.Sprintf("backend unavailable: %s", backend), err)
}
func NewProxyTimeout(backend string) *AppError {
return New(CodeProxyTimeout, fmt.Sprintf("request to backend timed out: %s", backend))
}
func NewProxyInvalidTarget(target string, err error) *AppError {
return Wrap(CodeProxyInvalidTarget, fmt.Sprintf("invalid proxy target: %s", target), err)
}
// Network errors
func NewNetworkTimeout(operation string) *AppError {
return New(CodeNetworkTimeout, fmt.Sprintf("network timeout: %s", operation))
}
func NewNetworkUnreachable(host string) *AppError {
return New(CodeNetworkUnreachable, fmt.Sprintf("network unreachable: %s", host))
}
func NewNetworkRefused(host string) *AppError {
return New(CodeNetworkRefused, fmt.Sprintf("connection refused: %s", host))
}
// Internal errors
func NewInternalError(message string) *AppError {
return New(CodeInternalError, message)
}
func WrapInternalError(err error, message string) *AppError {
return Wrap(CodeInternalError, message, err)
}
+138
View File
@@ -0,0 +1,138 @@
package errors
import (
"errors"
"fmt"
)
// Error codes for categorizing errors
type Code string
const (
// Configuration errors
CodeConfigInvalid Code = "CONFIG_INVALID"
CodeConfigNotFound Code = "CONFIG_NOT_FOUND"
CodeConfigParseFailed Code = "CONFIG_PARSE_FAILED"
// Server errors
CodeServerStartFailed Code = "SERVER_START_FAILED"
CodeServerStopFailed Code = "SERVER_STOP_FAILED"
CodeServerAlreadyRunning Code = "SERVER_ALREADY_RUNNING"
CodeServerNotRunning Code = "SERVER_NOT_RUNNING"
// Proxy errors
CodeProxyBackendUnavailable Code = "PROXY_BACKEND_UNAVAILABLE"
CodeProxyTimeout Code = "PROXY_TIMEOUT"
CodeProxyInvalidTarget Code = "PROXY_INVALID_TARGET"
// Network errors
CodeNetworkTimeout Code = "NETWORK_TIMEOUT"
CodeNetworkUnreachable Code = "NETWORK_UNREACHABLE"
CodeNetworkRefused Code = "NETWORK_REFUSED"
// Internal errors
CodeInternalError Code = "INTERNAL_ERROR"
CodeUnknownError Code = "UNKNOWN_ERROR"
)
// AppError represents a structured application error
type AppError struct {
Code Code // Error code for categorization
Message string // Human-readable error message
Cause error // Underlying error (if any)
}
// Error implements the error interface
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Unwrap returns the underlying error (for errors.Is and errors.As)
func (e *AppError) Unwrap() error {
return e.Cause
}
// Is checks if the error matches the target
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Code == t.Code
}
// New creates a new AppError
func New(code Code, message string) *AppError {
return &AppError{
Code: code,
Message: message,
}
}
// Wrap wraps an existing error with additional context
func Wrap(code Code, message string, cause error) *AppError {
return &AppError{
Code: code,
Message: message,
Cause: cause,
}
}
// Wrapf wraps an error with a formatted message
func Wrapf(code Code, cause error, format string, args ...interface{}) *AppError {
return &AppError{
Code: code,
Message: fmt.Sprintf(format, args...),
Cause: cause,
}
}
// GetCode extracts the error code from an error
func GetCode(err error) Code {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr.Code
}
return CodeUnknownError
}
// HasCode checks if an error has a specific code
func HasCode(err error, code Code) bool {
return GetCode(err) == code
}
// IsConfigError checks if an error is configuration-related
func IsConfigError(err error) bool {
code := GetCode(err)
return code == CodeConfigInvalid ||
code == CodeConfigNotFound ||
code == CodeConfigParseFailed
}
// IsServerError checks if an error is server-related
func IsServerError(err error) bool {
code := GetCode(err)
return code == CodeServerStartFailed ||
code == CodeServerStopFailed ||
code == CodeServerAlreadyRunning ||
code == CodeServerNotRunning
}
// IsProxyError checks if an error is proxy-related
func IsProxyError(err error) bool {
code := GetCode(err)
return code == CodeProxyBackendUnavailable ||
code == CodeProxyTimeout ||
code == CodeProxyInvalidTarget
}
// IsNetworkError checks if an error is network-related
func IsNetworkError(err error) bool {
code := GetCode(err)
return code == CodeNetworkTimeout ||
code == CodeNetworkUnreachable ||
code == CodeNetworkRefused
}
+160
View File
@@ -0,0 +1,160 @@
package errors
import (
"errors"
"testing"
)
func TestAppError_Error(t *testing.T) {
tests := []struct {
name string
err *AppError
expected string
}{
{
name: "error without cause",
err: New(CodeConfigInvalid, "invalid configuration"),
expected: "[CONFIG_INVALID] invalid configuration",
},
{
name: "error with cause",
err: Wrap(CodeServerStartFailed, "failed to bind port", errors.New("address already in use")),
expected: "[SERVER_START_FAILED] failed to bind port: address already in use",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("Error() = %v, want %v", got, tt.expected)
}
})
}
}
func TestGetCode(t *testing.T) {
tests := []struct {
name string
err error
expected Code
}{
{
name: "app error",
err: New(CodeConfigInvalid, "test"),
expected: CodeConfigInvalid,
},
{
name: "wrapped app error",
err: Wrap(CodeServerStartFailed, "test", errors.New("cause")),
expected: CodeServerStartFailed,
},
{
name: "standard error",
err: errors.New("standard error"),
expected: CodeUnknownError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetCode(tt.err); got != tt.expected {
t.Errorf("GetCode() = %v, want %v", got, tt.expected)
}
})
}
}
func TestHasCode(t *testing.T) {
err := New(CodeConfigInvalid, "invalid config")
if !HasCode(err, CodeConfigInvalid) {
t.Error("HasCode() should return true for matching code")
}
if HasCode(err, CodeServerStartFailed) {
t.Error("HasCode() should return false for non-matching code")
}
}
func TestIsConfigError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "config invalid error",
err: New(CodeConfigInvalid, "test"),
expected: true,
},
{
name: "config not found error",
err: New(CodeConfigNotFound, "test"),
expected: true,
},
{
name: "server error",
err: New(CodeServerStartFailed, "test"),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsConfigError(tt.err); got != tt.expected {
t.Errorf("IsConfigError() = %v, want %v", got, tt.expected)
}
})
}
}
func TestErrorUnwrap(t *testing.T) {
cause := errors.New("root cause")
err := Wrap(CodeInternalError, "wrapped error", cause)
unwrapped := errors.Unwrap(err)
if unwrapped != cause {
t.Errorf("Unwrap() = %v, want %v", unwrapped, cause)
}
}
func TestErrorIs(t *testing.T) {
err1 := New(CodeConfigInvalid, "test1")
err2 := New(CodeConfigInvalid, "test2")
err3 := New(CodeServerStartFailed, "test3")
if !errors.Is(err1, err2) {
t.Error("errors.Is() should return true for same error code")
}
if errors.Is(err1, err3) {
t.Error("errors.Is() should return false for different error codes")
}
}
func TestCommonConstructors(t *testing.T) {
t.Run("NewConfigNotFound", func(t *testing.T) {
err := NewConfigNotFound("/path/to/config")
if GetCode(err) != CodeConfigNotFound {
t.Error("NewConfigNotFound should create CONFIG_NOT_FOUND error")
}
})
t.Run("NewServerAlreadyRunning", func(t *testing.T) {
err := NewServerAlreadyRunning()
if GetCode(err) != CodeServerAlreadyRunning {
t.Error("NewServerAlreadyRunning should create SERVER_ALREADY_RUNNING error")
}
})
t.Run("NewProxyBackendUnavailable", func(t *testing.T) {
cause := errors.New("connection refused")
err := NewProxyBackendUnavailable("http://backend", cause)
if GetCode(err) != CodeProxyBackendUnavailable {
t.Error("NewProxyBackendUnavailable should create PROXY_BACKEND_UNAVAILABLE error")
}
if !errors.Is(err.Unwrap(), cause) {
t.Error("NewProxyBackendUnavailable should wrap the cause")
}
})
}
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
syntax = "proto3";
package proxy;
option go_package = "github.com/netbirdio/netbird/proxy/pkg/grpc/proto";
import "google/protobuf/timestamp.proto";
// ProxyService defines the bidirectional streaming service
// The proxy runs this service, control service connects as client
service ProxyService {
// Stream establishes a bidirectional stream between proxy and control service
// Control service (client) sends ControlMessage, Proxy (server) sends ProxyMessage
rpc Stream(stream ControlMessage) returns (stream ProxyMessage);
}
// ProxyMessage represents messages sent from proxy to control service
message ProxyMessage {
oneof message {
ProxyStats stats = 1;
ProxyEvent event = 2;
ProxyLog log = 3;
ProxyHeartbeat heartbeat = 4;
ProxyRequestData request_data = 5;
}
}
// ControlMessage represents messages sent from control service to proxy
message ControlMessage {
oneof message {
ControlEvent event = 1;
ControlCommand command = 2;
ControlConfig config = 3;
ExposedServiceEvent exposed_service = 4;
}
}
// ProxyStats contains proxy statistics
message ProxyStats {
google.protobuf.Timestamp timestamp = 1;
uint64 total_requests = 2;
uint64 active_connections = 3;
uint64 bytes_sent = 4;
uint64 bytes_received = 5;
double cpu_usage = 6;
double memory_usage_mb = 7;
map<string, uint64> status_code_counts = 8;
}
// ProxyEvent represents events from the proxy
message ProxyEvent {
google.protobuf.Timestamp timestamp = 1;
EventType type = 2;
string message = 3;
map<string, string> metadata = 4;
enum EventType {
UNKNOWN = 0;
STARTED = 1;
STOPPED = 2;
ERROR = 3;
BACKEND_UNAVAILABLE = 4;
BACKEND_RECOVERED = 5;
CONFIG_UPDATED = 6;
}
}
// ProxyLog represents log entries
message ProxyLog {
google.protobuf.Timestamp timestamp = 1;
LogLevel level = 2;
string message = 3;
map<string, string> fields = 4;
enum LogLevel {
DEBUG = 0;
INFO = 1;
WARN = 2;
ERROR = 3;
}
}
// ProxyHeartbeat is sent periodically to keep connection alive
message ProxyHeartbeat {
google.protobuf.Timestamp timestamp = 1;
string proxy_id = 2;
}
// ControlEvent represents events from control service
message ControlEvent {
google.protobuf.Timestamp timestamp = 1;
string event_id = 2;
string message = 3;
}
// ControlCommand represents commands sent to proxy
message ControlCommand {
string command_id = 1;
CommandType type = 2;
map<string, string> parameters = 3;
enum CommandType {
UNKNOWN = 0;
RELOAD_CONFIG = 1;
ENABLE_DEBUG = 2;
DISABLE_DEBUG = 3;
GET_STATS = 4;
SHUTDOWN = 5;
}
}
// ControlConfig contains configuration updates from control service
message ControlConfig {
string config_version = 1;
map<string, string> settings = 2;
}
// ExposedServiceEvent represents exposed service lifecycle events
message ExposedServiceEvent {
google.protobuf.Timestamp timestamp = 1;
EventType type = 2;
string service_id = 3;
PeerConfig peer_config = 4;
UpstreamConfig upstream_config = 5;
enum EventType {
UNKNOWN = 0;
CREATED = 1;
UPDATED = 2;
REMOVED = 3;
}
}
// PeerConfig contains WireGuard peer configuration
message PeerConfig {
string peer_id = 1;
string public_key = 2;
repeated string allowed_ips = 3;
string endpoint = 4;
string tunnel_ip = 5;
uint32 persistent_keepalive = 6;
}
// UpstreamConfig contains reverse proxy upstream configuration
message UpstreamConfig {
string domain = 1;
map<string, string> path_mappings = 2; // path -> port
}
// ProxyRequestData contains metadata about requests routed through the reverse proxy
message ProxyRequestData {
google.protobuf.Timestamp timestamp = 1;
string service_id = 2;
string path = 3;
int64 duration_ms = 4;
string method = 5; // HTTP method (GET, POST, PUT, DELETE, etc.)
int32 response_code = 6;
string source_ip = 7;
}
+137
View File
@@ -0,0 +1,137 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// ProxyServiceClient is the client API for ProxyService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ProxyServiceClient interface {
// Stream establishes a bidirectional stream between proxy and control service
// Control service (client) sends ControlMessage, Proxy (server) sends ProxyMessage
Stream(ctx context.Context, opts ...grpc.CallOption) (ProxyService_StreamClient, error)
}
type proxyServiceClient struct {
cc grpc.ClientConnInterface
}
func NewProxyServiceClient(cc grpc.ClientConnInterface) ProxyServiceClient {
return &proxyServiceClient{cc}
}
func (c *proxyServiceClient) Stream(ctx context.Context, opts ...grpc.CallOption) (ProxyService_StreamClient, error) {
stream, err := c.cc.NewStream(ctx, &ProxyService_ServiceDesc.Streams[0], "/proxy.ProxyService/Stream", opts...)
if err != nil {
return nil, err
}
x := &proxyServiceStreamClient{stream}
return x, nil
}
type ProxyService_StreamClient interface {
Send(*ControlMessage) error
Recv() (*ProxyMessage, error)
grpc.ClientStream
}
type proxyServiceStreamClient struct {
grpc.ClientStream
}
func (x *proxyServiceStreamClient) Send(m *ControlMessage) error {
return x.ClientStream.SendMsg(m)
}
func (x *proxyServiceStreamClient) Recv() (*ProxyMessage, error) {
m := new(ProxyMessage)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// ProxyServiceServer is the server API for ProxyService service.
// All implementations must embed UnimplementedProxyServiceServer
// for forward compatibility
type ProxyServiceServer interface {
// Stream establishes a bidirectional stream between proxy and control service
// Control service (client) sends ControlMessage, Proxy (server) sends ProxyMessage
Stream(ProxyService_StreamServer) error
mustEmbedUnimplementedProxyServiceServer()
}
// UnimplementedProxyServiceServer must be embedded to have forward compatible implementations.
type UnimplementedProxyServiceServer struct {
}
func (UnimplementedProxyServiceServer) Stream(ProxyService_StreamServer) error {
return status.Errorf(codes.Unimplemented, "method Stream not implemented")
}
func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {}
// UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ProxyServiceServer will
// result in compilation errors.
type UnsafeProxyServiceServer interface {
mustEmbedUnimplementedProxyServiceServer()
}
func RegisterProxyServiceServer(s grpc.ServiceRegistrar, srv ProxyServiceServer) {
s.RegisterService(&ProxyService_ServiceDesc, srv)
}
func _ProxyService_Stream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(ProxyServiceServer).Stream(&proxyServiceStreamServer{stream})
}
type ProxyService_StreamServer interface {
Send(*ProxyMessage) error
Recv() (*ControlMessage, error)
grpc.ServerStream
}
type proxyServiceStreamServer struct {
grpc.ServerStream
}
func (x *proxyServiceStreamServer) Send(m *ProxyMessage) error {
return x.ServerStream.SendMsg(m)
}
func (x *proxyServiceStreamServer) Recv() (*ControlMessage, error) {
m := new(ControlMessage)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ProxyService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "proxy.ProxyService",
HandlerType: (*ProxyServiceServer)(nil),
Methods: []grpc.MethodDesc{},
Streams: []grpc.StreamDesc{
{
StreamName: "Stream",
Handler: _ProxyService_Stream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "pkg/grpc/proto/proxy.proto",
}
+286
View File
@@ -0,0 +1,286 @@
package grpc
import (
"context"
"fmt"
"net"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
pb "github.com/netbirdio/netbird/proxy/pkg/grpc/proto"
)
// StreamHandler handles incoming messages from control service
type StreamHandler interface {
HandleControlEvent(ctx context.Context, event *pb.ControlEvent) error
HandleControlCommand(ctx context.Context, command *pb.ControlCommand) error
HandleControlConfig(ctx context.Context, config *pb.ControlConfig) error
HandleExposedServiceEvent(ctx context.Context, event *pb.ExposedServiceEvent) error
}
// Server represents the gRPC server running on the proxy
type Server struct {
pb.UnimplementedProxyServiceServer
listenAddr string
grpcServer *grpc.Server
handler StreamHandler
mu sync.RWMutex
streams map[string]*StreamContext
isRunning bool
}
// StreamContext holds the context for each active stream
type StreamContext struct {
stream pb.ProxyService_StreamServer
sendChan chan *pb.ProxyMessage
ctx context.Context
cancel context.CancelFunc
controlID string // ID of the connected control service
}
// Config holds gRPC server configuration
type Config struct {
ListenAddr string
Handler StreamHandler
}
// NewServer creates a new gRPC server
func NewServer(config Config) *Server {
return &Server{
listenAddr: config.ListenAddr,
handler: config.Handler,
streams: make(map[string]*StreamContext),
}
}
// Start starts the gRPC server
func (s *Server) Start() error {
s.mu.Lock()
if s.isRunning {
s.mu.Unlock()
return fmt.Errorf("gRPC server already running")
}
s.isRunning = true
s.mu.Unlock()
lis, err := net.Listen("tcp", s.listenAddr)
if err != nil {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
return fmt.Errorf("failed to listen: %w", err)
}
// Configure gRPC server with keepalive
s.grpcServer = grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 10 * time.Second,
PermitWithoutStream: true,
}),
)
pb.RegisterProxyServiceServer(s.grpcServer, s)
log.Infof("gRPC server listening on %s", s.listenAddr)
if err := s.grpcServer.Serve(lis); err != nil {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
return fmt.Errorf("failed to serve: %w", err)
}
return nil
}
// Stop gracefully stops the gRPC server
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.isRunning {
s.mu.Unlock()
return fmt.Errorf("gRPC server not running")
}
s.mu.Unlock()
log.Info("Stopping gRPC server...")
// Cancel all active streams
s.mu.Lock()
for _, streamCtx := range s.streams {
streamCtx.cancel()
close(streamCtx.sendChan)
}
s.streams = make(map[string]*StreamContext)
s.mu.Unlock()
// Graceful stop with timeout
stopped := make(chan struct{})
go func() {
s.grpcServer.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
log.Info("gRPC server stopped gracefully")
case <-ctx.Done():
log.Warn("gRPC server graceful stop timeout, forcing stop")
s.grpcServer.Stop()
}
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
return nil
}
// Stream implements the bidirectional streaming RPC
// The control service connects as client, proxy is server
// Control service sends ControlMessage, Proxy sends ProxyMessage
func (s *Server) Stream(stream pb.ProxyService_StreamServer) error {
ctx, cancel := context.WithCancel(stream.Context())
defer cancel()
controlID := fmt.Sprintf("control-%d", time.Now().Unix())
// Create stream context
streamCtx := &StreamContext{
stream: stream,
sendChan: make(chan *pb.ProxyMessage, 100),
ctx: ctx,
cancel: cancel,
controlID: controlID,
}
// Register stream
s.mu.Lock()
s.streams[controlID] = streamCtx
s.mu.Unlock()
log.Infof("Control service connected: %s", controlID)
// Start goroutine to send ProxyMessages to control service
sendDone := make(chan error, 1)
go s.sendLoop(streamCtx, sendDone)
// Start goroutine to receive ControlMessages from control service
recvDone := make(chan error, 1)
go s.receiveLoop(streamCtx, recvDone)
// Wait for either send or receive to complete
select {
case err := <-sendDone:
log.Infof("Control service %s send loop ended: %v", controlID, err)
return err
case err := <-recvDone:
log.Infof("Control service %s receive loop ended: %v", controlID, err)
return err
case <-ctx.Done():
log.Infof("Control service %s context done: %v", controlID, ctx.Err())
return ctx.Err()
}
}
// sendLoop handles sending ProxyMessages to the control service
func (s *Server) sendLoop(streamCtx *StreamContext, done chan<- error) {
for {
select {
case msg, ok := <-streamCtx.sendChan:
if !ok {
done <- nil
return
}
// Send ProxyMessage to control service
if err := streamCtx.stream.Send(msg); err != nil {
log.Errorf("Failed to send message to control service: %v", err)
done <- err
return
}
case <-streamCtx.ctx.Done():
done <- streamCtx.ctx.Err()
return
}
}
}
// receiveLoop handles receiving ControlMessages from the control service
func (s *Server) receiveLoop(streamCtx *StreamContext, done chan<- error) {
for {
// Receive ControlMessage from control service (client)
controlMsg, err := streamCtx.stream.Recv()
if err != nil {
log.Debugf("Stream receive error: %v", err)
done <- err
return
}
// Handle different ControlMessage types
switch m := controlMsg.Message.(type) {
case *pb.ControlMessage_Event:
if s.handler != nil {
if err := s.handler.HandleControlEvent(streamCtx.ctx, m.Event); err != nil {
log.Errorf("Failed to handle control event: %v", err)
}
}
case *pb.ControlMessage_Command:
if s.handler != nil {
if err := s.handler.HandleControlCommand(streamCtx.ctx, m.Command); err != nil {
log.Errorf("Failed to handle control command: %v", err)
}
}
case *pb.ControlMessage_Config:
if s.handler != nil {
if err := s.handler.HandleControlConfig(streamCtx.ctx, m.Config); err != nil {
log.Errorf("Failed to handle control config: %v", err)
}
}
case *pb.ControlMessage_ExposedService:
if s.handler != nil {
if err := s.handler.HandleExposedServiceEvent(streamCtx.ctx, m.ExposedService); err != nil {
log.Errorf("Failed to handle exposed service event: %v", err)
}
}
default:
log.Warnf("Received unknown control message type: %T", m)
}
}
}
// SendProxyMessage sends a ProxyMessage to all connected control services
func (s *Server) SendProxyMessage(msg *pb.ProxyMessage) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, streamCtx := range s.streams {
select {
case streamCtx.sendChan <- msg:
// Message queued successfully
default:
log.Warn("Send channel full, dropping message")
}
}
}
// GetActiveStreams returns the number of active streams
func (s *Server) GetActiveStreams() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.streams)
}
+125
View File
@@ -0,0 +1,125 @@
package proxy
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/caarlos0/env/v11"
)
var (
ErrFailedToParseConfig = errors.New("failed to parse config from env")
)
// 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")
ListenAddress string `env:"NB_PROXY_LISTEN_ADDRESS" envDefault:":443" json:"listen_address"`
// ReadTimeout is the maximum duration for reading the entire request, including the body
ReadTimeout time.Duration `env:"NB_PROXY_READ_TIMEOUT" envDefault:"30s" json:"read_timeout"`
// WriteTimeout is the maximum duration before timing out writes of the response
WriteTimeout time.Duration `env:"NB_PROXY_WRITE_TIMEOUT" envDefault:"30s" json:"write_timeout"`
// IdleTimeout is the maximum amount of time to wait for the next request when keep-alives are enabled
IdleTimeout time.Duration `env:"NB_PROXY_IDLE_TIMEOUT" envDefault:"60s" json:"idle_timeout"`
// ShutdownTimeout is the maximum duration to wait for graceful shutdown
ShutdownTimeout time.Duration `env:"NB_PROXY_SHUTDOWN_TIMEOUT" envDefault:"10s" json:"shutdown_timeout"`
// LogLevel sets the logging verbosity (debug, info, warn, error)
LogLevel string `env:"NB_PROXY_LOG_LEVEL" envDefault:"info" json:"log_level"`
// GRPCListenAddress is the address for the gRPC control server (empty to disable)
GRPCListenAddress string `env:"NB_PROXY_GRPC_LISTEN_ADDRESS" envDefault:":50051" json:"grpc_listen_address"`
// ProxyID is a unique identifier for this proxy instance
ProxyID string `env:"NB_PROXY_ID" envDefault:"" json:"proxy_id"`
// EnableGRPC enables the gRPC control server
EnableGRPC bool `env:"NB_PROXY_ENABLE_GRPC" envDefault:"false" json:"enable_grpc"`
}
// ParseAndLoad parses configuration from environment variables
func ParseAndLoad() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return cfg, fmt.Errorf("%w: %s", ErrFailedToParseConfig, err)
}
if err := cfg.Validate(); err != nil {
return cfg, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// LoadFromFile reads configuration from a JSON file
func LoadFromFile(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("failed to parse config file: %w", err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// LoadFromFileOrEnv loads configuration from a file if path is provided, otherwise from environment variables
// Environment variables will override file-based configuration if both are present
func LoadFromFileOrEnv(configPath string) (Config, error) {
var cfg Config
// If config file is provided, load it first
if configPath != "" {
fileCfg, err := LoadFromFile(configPath)
if err != nil {
return Config{}, fmt.Errorf("failed to load config from file: %w", err)
}
cfg = fileCfg
}
// Parse environment variables (will override file config with any set env vars)
if err := env.Parse(&cfg); err != nil {
return Config{}, fmt.Errorf("%w: %s", ErrFailedToParseConfig, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.ListenAddress == "" {
return errors.New("listen_address is required")
}
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[c.LogLevel] {
return fmt.Errorf("invalid log_level: %s (must be debug, info, warn, or error)", c.LogLevel)
}
return nil
}
+570
View File
@@ -0,0 +1,570 @@
package proxy
import (
"context"
"fmt"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/proxy/internal/reverseproxy"
grpcpkg "github.com/netbirdio/netbird/proxy/pkg/grpc"
pb "github.com/netbirdio/netbird/proxy/pkg/grpc/proto"
)
// Server represents the reverse proxy server with integrated gRPC control server
type Server struct {
config Config
grpcServer *grpcpkg.Server
caddyProxy *reverseproxy.CaddyProxy
mu sync.RWMutex
isRunning bool
grpcRunning bool
shutdownCtx context.Context
cancelFunc context.CancelFunc
// Statistics for gRPC reporting
stats *Stats
// Track exposed services and their peer configs
exposedServices map[string]*ExposedServiceConfig
}
// Stats holds proxy statistics
type Stats struct {
mu sync.RWMutex
totalRequests uint64
activeConns uint64
bytesSent uint64
bytesReceived uint64
}
// ExposedServiceConfig holds the configuration for an exposed service
type ExposedServiceConfig struct {
ServiceID string
PeerConfig *PeerConfig
UpstreamConfig *UpstreamConfig
}
// PeerConfig holds WireGuard peer configuration
type PeerConfig struct {
PeerID string
PublicKey string
AllowedIPs []string
Endpoint string
TunnelIP string // The WireGuard tunnel IP to route traffic to
}
// UpstreamConfig holds reverse proxy upstream configuration
type UpstreamConfig struct {
Domain string
PathMappings map[string]string // path -> port mapping (relative to tunnel IP)
}
// NewServer creates a new reverse proxy server instance
func NewServer(config Config) (*Server, error) {
// Validate config
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
shutdownCtx, cancelFunc := context.WithCancel(context.Background())
server := &Server{
config: config,
isRunning: false,
grpcRunning: false,
shutdownCtx: shutdownCtx,
cancelFunc: cancelFunc,
stats: &Stats{},
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
log.WithFields(log.Fields{
"service_id": data.ServiceID,
"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
},
}
caddyProxy, err := reverseproxy.New(caddyConfig)
if err != nil {
return nil, fmt.Errorf("failed to create Caddy proxy: %w", err)
}
server.caddyProxy = caddyProxy
// Create gRPC server if enabled
if config.EnableGRPC && config.GRPCListenAddress != "" {
grpcConfig := grpcpkg.Config{
ListenAddr: config.GRPCListenAddress,
Handler: server, // Server implements StreamHandler interface
}
server.grpcServer = grpcpkg.NewServer(grpcConfig)
}
return server, nil
}
// Start starts the reverse proxy server and optionally the gRPC control server
func (s *Server) Start() error {
s.mu.Lock()
if s.isRunning {
s.mu.Unlock()
return fmt.Errorf("server is already running")
}
s.isRunning = true
s.mu.Unlock()
log.Infof("Starting Caddy reverse proxy server on %s", s.config.ListenAddress)
// Start Caddy proxy
if err := s.caddyProxy.Start(); err != nil {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
return fmt.Errorf("failed to start Caddy proxy: %w", err)
}
// Start gRPC server if configured
if s.grpcServer != nil {
s.mu.Lock()
s.grpcRunning = true
s.mu.Unlock()
go func() {
log.Infof("Starting gRPC control server on %s", s.config.GRPCListenAddress)
if err := s.grpcServer.Start(); err != nil {
log.Errorf("gRPC server error: %v", err)
s.mu.Lock()
s.grpcRunning = false
s.mu.Unlock()
}
}()
// Send started event
time.Sleep(100 * time.Millisecond) // Give gRPC server time to start
s.sendProxyEvent(pb.ProxyEvent_STARTED, "Proxy server started")
}
if err := s.caddyProxy.AddRoute(
&reverseproxy.RouteConfig{
ID: "test",
Domain: "test.netbird.io",
PathMappings: map[string]string{"/": "localhost:8080"},
}); 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
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.isRunning {
s.mu.Unlock()
return fmt.Errorf("server is not running")
}
s.mu.Unlock()
log.Info("Shutting down servers gracefully...")
// If no context provided, use the server's shutdown timeout
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), s.config.ShutdownTimeout)
defer cancel()
}
// Send stopped event before shutdown
if s.grpcServer != nil && s.grpcRunning {
s.sendProxyEvent(pb.ProxyEvent_STOPPED, "Proxy server shutting down")
}
var caddyErr, grpcErr error
// Shutdown gRPC server first
if s.grpcServer != nil && s.grpcRunning {
if err := s.grpcServer.Stop(ctx); err != nil {
grpcErr = fmt.Errorf("gRPC server shutdown failed: %w", err)
log.Error(grpcErr)
}
s.mu.Lock()
s.grpcRunning = false
s.mu.Unlock()
}
// Shutdown Caddy proxy
if err := s.caddyProxy.Stop(ctx); err != nil {
caddyErr = fmt.Errorf("Caddy proxy shutdown failed: %w", err)
log.Error(caddyErr)
}
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
if caddyErr != nil {
return caddyErr
}
if grpcErr != nil {
return grpcErr
}
log.Info("All servers stopped successfully")
return nil
}
// IsRunning returns whether the server is currently running
func (s *Server) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.isRunning
}
// GetConfig returns a copy of the server configuration
func (s *Server) GetConfig() Config {
return s.config
}
// GetStats returns a copy of current statistics
func (s *Server) GetStats() *pb.ProxyStats {
s.stats.mu.RLock()
defer s.stats.mu.RUnlock()
return &pb.ProxyStats{
Timestamp: timestamppb.Now(),
TotalRequests: s.stats.totalRequests,
ActiveConnections: s.stats.activeConns,
BytesSent: s.stats.bytesSent,
BytesReceived: s.stats.bytesReceived,
}
}
// StreamHandler interface implementation
// HandleControlEvent handles incoming control events
// This is where ExposedService events will be routed
func (s *Server) HandleControlEvent(ctx context.Context, event *pb.ControlEvent) error {
log.WithFields(log.Fields{
"event_id": event.EventId,
"message": event.Message,
}).Info("Received control event")
// TODO: Parse event type and route to appropriate handler
// if event.Type == "ExposedServiceCreated" {
// return s.handleExposedServiceCreated(ctx, event)
// } else if event.Type == "ExposedServiceUpdated" {
// return s.handleExposedServiceUpdated(ctx, event)
// } else if event.Type == "ExposedServiceRemoved" {
// return s.handleExposedServiceRemoved(ctx, event)
// }
return nil
}
// HandleControlCommand handles incoming control commands
func (s *Server) HandleControlCommand(ctx context.Context, command *pb.ControlCommand) error {
log.WithFields(log.Fields{
"command_id": command.CommandId,
"type": command.Type.String(),
}).Info("Received control command")
switch command.Type {
case pb.ControlCommand_GET_STATS:
// Stats are automatically sent, just log
log.Debug("Stats requested via command")
case pb.ControlCommand_RELOAD_CONFIG:
log.Info("Config reload requested (not implemented yet)")
case pb.ControlCommand_ENABLE_DEBUG:
log.SetLevel(log.DebugLevel)
log.Info("Debug logging enabled")
case pb.ControlCommand_DISABLE_DEBUG:
log.SetLevel(log.InfoLevel)
log.Info("Debug logging disabled")
case pb.ControlCommand_SHUTDOWN:
log.Warn("Shutdown command received")
go func() {
time.Sleep(1 * time.Second)
s.cancelFunc() // Trigger graceful shutdown
}()
}
return nil
}
// HandleControlConfig handles incoming configuration updates
func (s *Server) HandleControlConfig(ctx context.Context, config *pb.ControlConfig) error {
log.WithFields(log.Fields{
"config_version": config.ConfigVersion,
"settings": config.Settings,
}).Info("Received config update")
return nil
}
// HandleExposedServiceEvent handles exposed service lifecycle events
func (s *Server) HandleExposedServiceEvent(ctx context.Context, event *pb.ExposedServiceEvent) error {
log.WithFields(log.Fields{
"service_id": event.ServiceId,
"type": event.Type.String(),
}).Info("Received exposed service event")
// Convert proto types to internal types
peerConfig := &PeerConfig{
PeerID: event.PeerConfig.PeerId,
PublicKey: event.PeerConfig.PublicKey,
AllowedIPs: event.PeerConfig.AllowedIps,
Endpoint: event.PeerConfig.Endpoint,
TunnelIP: event.PeerConfig.TunnelIp,
}
upstreamConfig := &UpstreamConfig{
Domain: event.UpstreamConfig.Domain,
PathMappings: event.UpstreamConfig.PathMappings,
}
// Route to appropriate handler based on event type
switch event.Type {
case pb.ExposedServiceEvent_CREATED:
return s.handleExposedServiceCreated(event.ServiceId, peerConfig, upstreamConfig)
case pb.ExposedServiceEvent_UPDATED:
return s.handleExposedServiceUpdated(event.ServiceId, peerConfig, upstreamConfig)
case pb.ExposedServiceEvent_REMOVED:
return s.handleExposedServiceRemoved(event.ServiceId)
default:
return fmt.Errorf("unknown exposed service event type: %v", event.Type)
}
}
// Exposed Service Handlers
// handleExposedServiceCreated handles the creation of a new exposed service
func (s *Server) handleExposedServiceCreated(serviceID string, peerConfig *PeerConfig, upstreamConfig *UpstreamConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if service already exists
if _, exists := s.exposedServices[serviceID]; exists {
return fmt.Errorf("exposed service %s already exists", serviceID)
}
log.WithFields(log.Fields{
"service_id": serviceID,
"peer_id": peerConfig.PeerID,
"tunnel_ip": peerConfig.TunnelIP,
"domain": upstreamConfig.Domain,
}).Info("Creating exposed service")
// TODO: Create WireGuard tunnel for peer
// 1. Initialize WireGuard interface if not already done
// 2. Add peer configuration:
// - Public key: peerConfig.PublicKey
// - Endpoint: peerConfig.Endpoint
// - Allowed IPs: peerConfig.AllowedIPs
// - Persistent keepalive: 25 seconds
// 3. Bring up the WireGuard interface
// 4. Verify tunnel connectivity to peerConfig.TunnelIP
// Example pseudo-code:
// wgClient.AddPeer(&wireguard.PeerConfig{
// PublicKey: peerConfig.PublicKey,
// Endpoint: peerConfig.Endpoint,
// AllowedIPs: peerConfig.AllowedIPs,
// PersistentKeepalive: 25,
// })
// Build path mappings with tunnel IP
pathMappings := make(map[string]string)
for path, port := range upstreamConfig.PathMappings {
// Combine tunnel IP with port
target := fmt.Sprintf("%s:%s", peerConfig.TunnelIP, port)
pathMappings[path] = target
}
// Add route to Caddy
route := &reverseproxy.RouteConfig{
ID: serviceID,
Domain: upstreamConfig.Domain,
PathMappings: pathMappings,
}
if err := s.caddyProxy.AddRoute(route); err != nil {
return fmt.Errorf("failed to add route: %w", err)
}
// Store service config
s.exposedServices[serviceID] = &ExposedServiceConfig{
ServiceID: serviceID,
PeerConfig: peerConfig,
UpstreamConfig: upstreamConfig,
}
log.Infof("Exposed service %s created successfully", serviceID)
return nil
}
// handleExposedServiceUpdated handles updates to an existing exposed service
func (s *Server) handleExposedServiceUpdated(serviceID string, peerConfig *PeerConfig, upstreamConfig *UpstreamConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if service exists
if _, exists := s.exposedServices[serviceID]; !exists {
return fmt.Errorf("exposed service %s not found", serviceID)
}
log.WithFields(log.Fields{
"service_id": serviceID,
"peer_id": peerConfig.PeerID,
"tunnel_ip": peerConfig.TunnelIP,
"domain": upstreamConfig.Domain,
}).Info("Updating exposed service")
// TODO: Update WireGuard tunnel if peer config changed
// Build path mappings with tunnel IP
pathMappings := make(map[string]string)
for path, port := range upstreamConfig.PathMappings {
target := fmt.Sprintf("%s:%s", peerConfig.TunnelIP, port)
pathMappings[path] = target
}
// Update route in Caddy
route := &reverseproxy.RouteConfig{
ID: serviceID,
Domain: upstreamConfig.Domain,
PathMappings: pathMappings,
}
if err := s.caddyProxy.UpdateRoute(route); err != nil {
return fmt.Errorf("failed to update route: %w", err)
}
// Update service config
s.exposedServices[serviceID] = &ExposedServiceConfig{
ServiceID: serviceID,
PeerConfig: peerConfig,
UpstreamConfig: upstreamConfig,
}
log.Infof("Exposed service %s updated successfully", serviceID)
return nil
}
// handleExposedServiceRemoved handles the removal of an exposed service
func (s *Server) handleExposedServiceRemoved(serviceID string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if service exists
if _, exists := s.exposedServices[serviceID]; !exists {
return fmt.Errorf("exposed service %s not found", serviceID)
}
log.WithFields(log.Fields{
"service_id": serviceID,
}).Info("Removing exposed service")
// Remove route from Caddy
if err := s.caddyProxy.RemoveRoute(serviceID); err != nil {
return fmt.Errorf("failed to remove route: %w", err)
}
// TODO: Remove WireGuard tunnel for peer
// Remove service config
delete(s.exposedServices, serviceID)
log.Infof("Exposed service %s removed successfully", serviceID)
return nil
}
// ListExposedServices returns a list of all exposed service IDs
func (s *Server) ListExposedServices() []string {
s.mu.RLock()
defer s.mu.RUnlock()
services := make([]string, 0, len(s.exposedServices))
for id := range s.exposedServices {
services = append(services, id)
}
return services
}
// GetExposedService returns the configuration for a specific exposed service
func (s *Server) GetExposedService(serviceID string) (*ExposedServiceConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
service, exists := s.exposedServices[serviceID]
if !exists {
return nil, fmt.Errorf("exposed service %s not found", serviceID)
}
return service, nil
}
// Helper methods
func (s *Server) sendProxyEvent(eventType pb.ProxyEvent_EventType, message string) {
// This would typically be called to send events
// The actual sending happens via the gRPC stream
log.WithFields(log.Fields{
"type": eventType.String(),
"message": message,
}).Debug("Proxy event")
}
// Stats methods
func (s *Stats) IncrementRequests() {
s.mu.Lock()
defer s.mu.Unlock()
s.totalRequests++
}
func (s *Stats) IncrementActiveConns() {
s.mu.Lock()
defer s.mu.Unlock()
s.activeConns++
}
func (s *Stats) DecrementActiveConns() {
s.mu.Lock()
defer s.mu.Unlock()
if s.activeConns > 0 {
s.activeConns--
}
}
func (s *Stats) AddBytesSent(bytes uint64) {
s.mu.Lock()
defer s.mu.Unlock()
s.bytesSent += bytes
}
func (s *Stats) AddBytesReceived(bytes uint64) {
s.mu.Lock()
defer s.mu.Unlock()
s.bytesReceived += bytes
}
+56
View File
@@ -0,0 +1,56 @@
package version
import (
"fmt"
"runtime"
)
var (
// Version is the application version (set via ldflags during build)
Version = "dev"
// Commit is the git commit hash (set via ldflags during build)
Commit = "unknown"
// BuildDate is the build date (set via ldflags during build)
BuildDate = "unknown"
// GoVersion is the Go version used to build the binary
GoVersion = runtime.Version()
)
// Info contains version information
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
// Get returns the version information
func Get() Info {
return Info{
Version: Version,
Commit: Commit,
BuildDate: BuildDate,
GoVersion: GoVersion,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
}
// String returns a formatted version string
func String() string {
return fmt.Sprintf("Version: %s, Commit: %s, BuildDate: %s, Go: %s",
Version, Commit, BuildDate, GoVersion)
}
// Short returns a short version string
func Short() string {
if Version == "dev" {
return fmt.Sprintf("%s (%s)", Version, Commit[:7])
}
return Version
}