Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ All notable changes to this project will be documented in this file.
- Switch geolocation invocations to `doublezero geolocation ...` and `doublezero init-geolocation-config`
- Agent: log after Arista eapi commit
- Agent: log received config size in bytes and expose `doublezero_agent_config_size_in_lines` and `doublezero_agent_config_size_in_bytes` Prometheus gauges ([#3741](https://github.com/malbeclabs/doublezero/issues/3741))
- Controller
- Add `--max-user-tunnel-slots` flag to override the per-device user-tunnel slot count of 128 at runtime.

## [v0.23.0](https://github.com/malbeclabs/doublezero/compare/client/v0.22.0...client/v0.23.0) - 2026-05-15

Expand Down
32 changes: 18 additions & 14 deletions controlplane/controller/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/gagliardetto/solana-go"
solanarpc "github.com/gagliardetto/solana-go/rpc"
"github.com/malbeclabs/doublezero/config"
controllerconfig "github.com/malbeclabs/doublezero/controlplane/controller/config"
"github.com/malbeclabs/doublezero/controlplane/controller/internal/controller"
pb "github.com/malbeclabs/doublezero/controlplane/proto/controller/gen/pb-go"
"github.com/malbeclabs/doublezero/smartcontract/sdk/go/serviceability"
Expand Down Expand Up @@ -129,24 +130,26 @@ func NewControllerCommand() *ControllerCommand {
c.fs.StringVar(&c.tlsKeyFile, "tls-key", "", "path to tls key file")
c.fs.BoolVar(&c.enablePprof, "enable-pprof", false, "enable pprof server")
c.fs.StringVar(&c.tlsListenPort, "tls-listen-port", "", "listening port for controller grpc server")
c.fs.IntVar(&c.maxUserTunnelSlots, "max-user-tunnel-slots", controllerconfig.DefaultMaxUserTunnelSlots, "per-device user tunnel slot count (must be positive)")
return c
}

type ControllerCommand struct {
fs *flag.FlagSet
description string
listenAddr string
listenPort string
env string
programID string
rpcEndpoint string
deviceLocalASN uint64
noHardware bool
showVersion bool
tlsCertFile string
tlsKeyFile string
tlsListenPort string
enablePprof bool
fs *flag.FlagSet
description string
listenAddr string
listenPort string
env string
programID string
rpcEndpoint string
deviceLocalASN uint64
noHardware bool
showVersion bool
tlsCertFile string
tlsKeyFile string
tlsListenPort string
enablePprof bool
maxUserTunnelSlots int
}

func (c *ControllerCommand) Fs() *flag.FlagSet {
Expand Down Expand Up @@ -226,6 +229,7 @@ func (c *ControllerCommand) Run() error {
defer ledgerRPCClient.Close()

options = append(options, controller.WithDeviceLocalASN(deviceLocalASN))
options = append(options, controller.WithMaxUserTunnelSlots(c.maxUserTunnelSlots))

const defaultFeaturesConfigPath = "/etc/doublezero-controller/features.yaml"
if f, err := os.Open(defaultFeaturesConfigPath); err == nil {
Expand Down
6 changes: 4 additions & 2 deletions controlplane/controller/config/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const (
// StartUserTunnelNum is the starting tunnel number for user tunnels
StartUserTunnelNum = 500

// MaxUserTunnelSlots is the maximum number of user tunnel slots per device
MaxUserTunnelSlots = 128
// DefaultMaxUserTunnelSlots is the default maximum number of user tunnel slots
// per device. Controllers may override this at runtime via the
// --max-user-tunnel-slots flag (see cmd/controller).
DefaultMaxUserTunnelSlots = 128
)
8 changes: 4 additions & 4 deletions controlplane/controller/internal/controller/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,10 @@ type Device struct {
LocationCode string
}

func NewDevice(ip net.IP, publicKey string) *Device {
tunnels := []*Tunnel{}
func NewDevice(ip net.IP, publicKey string, tunnelSlots int) *Device {
tunnels := make([]*Tunnel, 0, tunnelSlots)
devicePathologies := []string{}
for i := 0; i < config.MaxUserTunnelSlots; i++ {
for i := 0; i < tunnelSlots; i++ {
id := config.StartUserTunnelNum + i
tunnel := &Tunnel{
Id: id,
Expand All @@ -222,7 +222,7 @@ func NewDevice(ip net.IP, publicKey string) *Device {
PublicIP: ip,
PubKey: publicKey,
Tunnels: tunnels,
TunnelSlots: config.MaxUserTunnelSlots,
TunnelSlots: tunnelSlots,
DevicePathologies: devicePathologies,
}
}
Expand Down
2 changes: 1 addition & 1 deletion controlplane/controller/internal/controller/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ func TestRenderConfig(t *testing.T) {
if strings.HasSuffix(test.Want, ".tmpl") {
templateData := map[string]int{
"StartTunnel": config.StartUserTunnelNum,
"EndTunnel": config.StartUserTunnelNum + config.MaxUserTunnelSlots - 1,
"EndTunnel": config.StartUserTunnelNum + config.DefaultMaxUserTunnelSlots - 1,
}
rendered, err := renderTemplateFile(test.Want, templateData)
if err != nil {
Expand Down
45 changes: 29 additions & 16 deletions controlplane/controller/internal/controller/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/gogo/protobuf/proto"

"github.com/malbeclabs/doublezero/config"
controllerconfig "github.com/malbeclabs/doublezero/controlplane/controller/config"
pb "github.com/malbeclabs/doublezero/controlplane/proto/controller/gen/pb-go"
telemetryconfig "github.com/malbeclabs/doublezero/controlplane/telemetry/pkg/config"
"github.com/malbeclabs/doublezero/smartcontract/sdk/go/serviceability"
Expand All @@ -40,8 +41,9 @@ const (
)

var (
ErrServiceabilityRequired = errors.New("serviceability program client is required")
ErrLoggerRequired = errors.New("logger is required")
ErrServiceabilityRequired = errors.New("serviceability program client is required")
ErrLoggerRequired = errors.New("logger is required")
ErrInvalidMaxUserTunnelSlots = errors.New("max user tunnel slots must be positive")
)

type ServiceabilityProgramClient interface {
Expand All @@ -63,29 +65,34 @@ type stateCache struct {
type Controller struct {
pb.UnimplementedControllerServer

log *slog.Logger
cache stateCache
mu sync.RWMutex
serviceability ServiceabilityProgramClient
listener net.Listener
noHardware bool
updateDone chan struct{}
tlsConfig *tls.Config
environment string
deviceLocalASN uint32
clickhouse *ClickhouseWriter
featuresConfig *FeaturesConfig
log *slog.Logger
cache stateCache
mu sync.RWMutex
serviceability ServiceabilityProgramClient
listener net.Listener
noHardware bool
updateDone chan struct{}
tlsConfig *tls.Config
environment string
deviceLocalASN uint32
clickhouse *ClickhouseWriter
featuresConfig *FeaturesConfig
maxUserTunnelSlots int
}

type Option func(*Controller)

func NewController(options ...Option) (*Controller, error) {
controller := &Controller{
cache: stateCache{},
cache: stateCache{},
maxUserTunnelSlots: controllerconfig.DefaultMaxUserTunnelSlots,
}
for _, o := range options {
o(controller)
}
if controller.maxUserTunnelSlots < 1 {
return nil, fmt.Errorf("%w: got %d", ErrInvalidMaxUserTunnelSlots, controller.maxUserTunnelSlots)
}
if controller.listener == nil {
lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", 443))
if err != nil {
Expand Down Expand Up @@ -170,6 +177,12 @@ func WithFeaturesConfig(cfg *FeaturesConfig) Option {
}
}

func WithMaxUserTunnelSlots(n int) Option {
return func(c *Controller) {
c.maxUserTunnelSlots = n
}
}

// processDeviceInterfacesAndPeers processes a device's interfaces and extracts BGP peer information.
// It returns the candidate VPNv4 and IPv4 BGP peers found from the device's loopback interfaces.
func (c *Controller) processDeviceInterfacesAndPeers(device serviceability.Device, d *Device, devicePubKey string) (candidateVpnv4BgpPeer, candidateIpv4BgpPeer BgpPeer) {
Expand Down Expand Up @@ -315,7 +328,7 @@ func (c *Controller) updateStateCache(ctx context.Context) error {
}

devicePubKey := base58.Encode(device.PubKey[:])
d := NewDevice(ip, devicePubKey)
d := NewDevice(ip, devicePubKey, c.maxUserTunnelSlots)

d.MgmtVrf = device.MgmtVrf
d.Code = device.Code
Expand Down
114 changes: 101 additions & 13 deletions controlplane/controller/internal/controller/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controller
import (
"bytes"
"context"
"errors"
"io"
"log"
"log/slog"
Expand All @@ -27,7 +28,7 @@ import (
)

// helper that creates a slice of Tunnel structs with sequential IDs. We can use this to populate
// a list of tunnel slots so we don't have to update tests by hand when MaxUserTunnelSlots changes.
// a list of tunnel slots so we don't have to update tests by hand when DefaultMaxUserTunnelSlots changes.
func generateEmptyTunnelSlots(startID, count int) []*Tunnel {
tunnels := make([]*Tunnel, count)
for i := 0; i < count; i++ {
Expand Down Expand Up @@ -573,7 +574,7 @@ func TestGetConfig(t *testing.T) {
if strings.HasSuffix(test.Want, ".tmpl") {
templateData := map[string]int{
"StartTunnel": config.StartUserTunnelNum,
"EndTunnel": config.StartUserTunnelNum + config.MaxUserTunnelSlots - 1,
"EndTunnel": config.StartUserTunnelNum + config.DefaultMaxUserTunnelSlots - 1,
}
rendered, err := renderTemplateFile(test.Want, templateData)
if err != nil {
Expand Down Expand Up @@ -981,8 +982,8 @@ func TestStateCache(t *testing.T) {
{239, 0, 0, 1},
},
},
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+2, config.MaxUserTunnelSlots-2)...),
TunnelSlots: config.MaxUserTunnelSlots,
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+2, config.DefaultMaxUserTunnelSlots-2)...),
TunnelSlots: config.DefaultMaxUserTunnelSlots,
Interfaces: []Interface{
{
InterfaceType: InterfaceTypePhysical,
Expand Down Expand Up @@ -1117,8 +1118,8 @@ func TestStateCache(t *testing.T) {
MetroRouting: true,
TenantPubKey: "11111111111111111111111111111111",
},
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+1, config.MaxUserTunnelSlots-1)...),
TunnelSlots: config.MaxUserTunnelSlots,
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+1, config.DefaultMaxUserTunnelSlots-1)...),
TunnelSlots: config.DefaultMaxUserTunnelSlots,
},
},
},
Expand Down Expand Up @@ -1231,8 +1232,8 @@ func TestStateCache(t *testing.T) {
MetroRouting: true,
TenantPubKey: "11111111111111111111111111111111",
},
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+1, config.MaxUserTunnelSlots-1)...),
TunnelSlots: config.MaxUserTunnelSlots,
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+1, config.DefaultMaxUserTunnelSlots-1)...),
TunnelSlots: config.DefaultMaxUserTunnelSlots,
},
},
},
Expand Down Expand Up @@ -1384,8 +1385,8 @@ func TestStateCache(t *testing.T) {
MetroRouting: true,
TenantPubKey: "11111111111111111111111111111111",
},
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+2, config.MaxUserTunnelSlots-2)...),
TunnelSlots: config.MaxUserTunnelSlots,
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+2, config.DefaultMaxUserTunnelSlots-2)...),
TunnelSlots: config.DefaultMaxUserTunnelSlots,
},
},
},
Expand Down Expand Up @@ -1558,8 +1559,8 @@ func TestStateCache(t *testing.T) {
MetroRouting: true,
TenantPubKey: "7fTN12qMUn1gSUuTMxNCdjndcxwJu45kosXuqJiXMeT9",
},
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+3, config.MaxUserTunnelSlots-3)...),
TunnelSlots: config.MaxUserTunnelSlots,
}, generateEmptyTunnelSlots(config.StartUserTunnelNum+3, config.DefaultMaxUserTunnelSlots-3)...),
TunnelSlots: config.DefaultMaxUserTunnelSlots,
Interfaces: []Interface{
{
InterfaceType: InterfaceTypeLoopback,
Expand Down Expand Up @@ -1791,6 +1792,93 @@ func TestServiceabilityProgramClientArg(t *testing.T) {
}
}

func TestMaxUserTunnelSlotsOption(t *testing.T) {
mockClient := &mockServiceabilityProgramClient{
ProgramIDFunc: func() solana.PublicKey {
return solana.MustPublicKeyFromBase58("11111111111111111111111111111111")
},
}

tests := []struct {
name string
opts []Option
wantErr error
wantSize int
}{
{
name: "default_when_option_omitted",
opts: nil,
wantErr: nil,
wantSize: config.DefaultMaxUserTunnelSlots,
},
{
name: "valid_min",
opts: []Option{WithMaxUserTunnelSlots(1)},
wantErr: nil,
wantSize: 1,
},
{
name: "valid_default",
opts: []Option{WithMaxUserTunnelSlots(config.DefaultMaxUserTunnelSlots)},
wantErr: nil,
wantSize: config.DefaultMaxUserTunnelSlots,
},
{
name: "valid_large",
opts: []Option{WithMaxUserTunnelSlots(1024)},
wantErr: nil,
wantSize: 1024,
},
{
name: "invalid_zero",
opts: []Option{WithMaxUserTunnelSlots(0)},
wantErr: ErrInvalidMaxUserTunnelSlots,
},
{
name: "invalid_negative",
opts: []Option{WithMaxUserTunnelSlots(-1)},
wantErr: ErrInvalidMaxUserTunnelSlots,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
opts := []Option{
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
WithListener(bufconn.Listen(1024 * 1024)),
WithServiceabilityProgramClient(mockClient),
}
opts = append(opts, test.opts...)
c, err := NewController(opts...)
if test.wantErr != nil {
if !errors.Is(err, test.wantErr) {
t.Fatalf("expected error %v, got %v", test.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if c.maxUserTunnelSlots != test.wantSize {
t.Errorf("expected maxUserTunnelSlots=%d, got %d", test.wantSize, c.maxUserTunnelSlots)
}
d := NewDevice(net.IPv4(1, 2, 3, 4), "pk", c.maxUserTunnelSlots)
if len(d.Tunnels) != test.wantSize {
t.Errorf("expected %d tunnel slots on device, got %d", test.wantSize, len(d.Tunnels))
}
if d.TunnelSlots != test.wantSize {
t.Errorf("expected device.TunnelSlots=%d, got %d", test.wantSize, d.TunnelSlots)
}
if d.Tunnels[0].Id != config.StartUserTunnelNum {
t.Errorf("expected first tunnel id=%d, got %d", config.StartUserTunnelNum, d.Tunnels[0].Id)
}
if d.Tunnels[test.wantSize-1].Id != config.StartUserTunnelNum+test.wantSize-1 {
t.Errorf("expected last tunnel id=%d, got %d", config.StartUserTunnelNum+test.wantSize-1, d.Tunnels[test.wantSize-1].Id)
}
})
}
}

// TestEndToEnd verifies on-chain data can be fetched, the local state cache updated, and a config
// can be rendered and sent back to the client via gRPC.
func TestEndToEnd(t *testing.T) {
Expand Down Expand Up @@ -2283,7 +2371,7 @@ func TestEndToEnd(t *testing.T) {
if strings.HasSuffix(test.Want, ".tmpl") {
templateData := map[string]int{
"StartTunnel": config.StartUserTunnelNum,
"EndTunnel": config.StartUserTunnelNum + config.MaxUserTunnelSlots - 1,
"EndTunnel": config.StartUserTunnelNum + config.DefaultMaxUserTunnelSlots - 1,
}
rendered, err := renderTemplateFile(test.Want, templateData)
if err != nil {
Expand Down
Loading
Loading