Skip to content
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,17 +378,19 @@ protocols:
Ambient example:

```text
mode=ambient target=3.01Gbps target_in=2.25Gbps target_out=758.00Mbps estimated=3.00Gbps estimated_in=2.24Gbps estimated_out=760.00Mbps samples=102 datagrams=13 top=10.10.0.17@402.10Mbps
mode=ambient target=3.01Gbps target_in=2.25Gbps target_out=758.00Mbps estimated=3.00Gbps estimated_in=2.24Gbps estimated_out=760.00Mbps samples=102 datagrams=13 clipped=0 send_errors=0 top=10.10.0.17@402.10Mbps
```

Attack example:

```text
mode=attack victim=10.10.0.42 target=9.62Gbps estimated=9.59Gbps samples=326 datagrams=41 phase=ramp-up
mode=attack victim=10.10.0.42 target=9.62Gbps estimated=9.59Gbps samples=326 datagrams=41 clipped=0 send_errors=0 phase=ramp-up
```

`target` is the traffic rate requested by the model. `estimated` is the traffic volume represented by the emitted samples, not the actual bandwidth consumed by the generator's UDP datagrams.

`clipped` counts the ticks in the interval that reached `max_samples_per_tick`. `send_errors` counts datagrams dropped because the collector could not be reached; an unreachable collector is logged but does not stop the generator, which resumes sending as soon as the collector accepts datagrams again.

## Safety limits

`max_samples_per_tick` limits the amount of work performed during a single model update:
Expand All @@ -397,7 +399,7 @@ mode=attack victim=10.10.0.42 target=9.62Gbps estimated=9.59Gbps samples=326 dat
max_samples_per_tick: 100000
```

A configuration with a very high represented rate, a low sampling rate, small frames, or a very short tick can reach this limit. The generator then emits a warning and caps the generated sample count for that tick.
A configuration with a very high represented rate, a low sampling rate, small frames, or a very short tick can reach this limit. The generator then warns once, counts the affected ticks in the `clipped` field of the periodic log, and caps the generated sample count for those ticks. Clipped ticks are shared out across hosts and networks, so no network is starved, and the reported `target` still describes the full configuration.

## Current limitations

Expand Down
15 changes: 1 addition & 14 deletions address.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"encoding/binary"
"fmt"
"math/big"
"math/rand"
Expand Down Expand Up @@ -90,13 +89,7 @@ func addressAt(prefix netip.Prefix, offset uint64) (netip.Addr, error) {
padded := make([]byte, width)
copy(padded[width-len(encoded):], encoded)

var addr netip.Addr
var ok bool
if width == 4 {
addr, ok = netip.AddrFromSlice(padded)
} else {
addr, ok = netip.AddrFromSlice(padded)
}
addr, ok := netip.AddrFromSlice(padded)
if !ok || !prefix.Contains(addr) {
return netip.Addr{}, fmt.Errorf("generated address %s is outside prefix %s", addr, prefix)
}
Expand All @@ -118,9 +111,3 @@ func macFromAddress(addr netip.Addr, discriminator byte) [6]byte {
mac[5] ^= v[15]
return mac
}

func uint16Bytes(value uint16) []byte {
buf := make([]byte, 2)
binary.BigEndian.PutUint16(buf, value)
return buf
}
115 changes: 67 additions & 48 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,31 @@ import (
"go.yaml.in/yaml/v3"
)

// Fields whose zero value is a meaningful setting are pointers, so an explicit
// 0 in the YAML is distinguishable from an omitted key.
type ExporterConfig struct {
Collector string `yaml:"collector"`
AgentIP string `yaml:"agent_ip"`
SubAgentID uint32 `yaml:"sub_agent_id"`
SamplingRate uint32 `yaml:"sampling_rate"`
Tick string `yaml:"tick"`
SamplesPerDatagram int `yaml:"samples_per_datagram"`
Seed int64 `yaml:"seed"`
Collector string `yaml:"collector"`
AgentIP string `yaml:"agent_ip"`
SubAgentID *uint32 `yaml:"sub_agent_id"`
SamplingRate uint32 `yaml:"sampling_rate"`
Tick string `yaml:"tick"`
SamplesPerDatagram int `yaml:"samples_per_datagram"`
Seed *int64 `yaml:"seed"`
}

type PacketConfig struct {
MeanSize int `yaml:"mean_size"`
StdDev float64 `yaml:"stddev"`
MinSize int `yaml:"min_size"`
MaxSize int `yaml:"max_size"`
MeanSize int `yaml:"mean_size"`
StdDev *float64 `yaml:"stddev"`
MinSize int `yaml:"min_size"`
MaxSize int `yaml:"max_size"`
}

// RuntimePacket is PacketConfig with defaults resolved.
type RuntimePacket struct {
MeanSize int
StdDev float64
MinSize int
MaxSize int
}

type ProtocolMix struct {
Expand All @@ -45,10 +55,10 @@ type AmbientNetworkConfig struct {
OutgoingShare float64 `yaml:"outgoing_share"`
Rate string `yaml:"rate"`
Hosts int `yaml:"hosts"`
Zipf float64 `yaml:"zipf"`
TotalNoise float64 `yaml:"total_noise"`
HostNoise float64 `yaml:"host_noise"`
Correlation float64 `yaml:"correlation"`
Zipf *float64 `yaml:"zipf"`
TotalNoise *float64 `yaml:"total_noise"`
HostNoise *float64 `yaml:"host_noise"`
Correlation *float64 `yaml:"correlation"`
PeerNetworks []string `yaml:"peer_networks"`
}

Expand Down Expand Up @@ -87,7 +97,7 @@ type RuntimeCommon struct {
Tick time.Duration
SamplesPerDatagram int
Seed int64
Packet PacketConfig
Packet RuntimePacket
Protocols ProtocolMix
LogInterval time.Duration
MaxSamplesPerTick int
Expand Down Expand Up @@ -159,18 +169,11 @@ func loadAmbientConfig(path string) (RuntimeAmbient, error) {
if n.Hosts == 0 {
n.Hosts = 128
}
if n.Zipf == 0 {
n.Zipf = 1.10
}
if n.TotalNoise == 0 {
n.TotalNoise = 0.015
}
if n.HostNoise == 0 {
n.HostNoise = 0.25
}
if n.Correlation == 0 {
n.Correlation = 0.98
}
// 0 is a valid setting here: uniform weights, no noise, no correlation.
zipf := valueOr(n.Zipf, 1.10)
totalNoise := valueOr(n.TotalNoise, 0.015)
hostNoise := valueOr(n.HostNoise, 0.25)
correlation := valueOr(n.Correlation, 0.98)

prefix, err := netip.ParsePrefix(n.CIDR)
if err != nil {
Expand All @@ -188,13 +191,13 @@ func loadAmbientConfig(path string) (RuntimeAmbient, error) {
if n.Hosts < 1 || n.Hosts > 65536 {
return RuntimeAmbient{}, fmt.Errorf("networks[%d].hosts must be between 1 and 65536", i)
}
if n.Zipf < 0 {
if zipf < 0 {
return RuntimeAmbient{}, fmt.Errorf("networks[%d].zipf must be non-negative", i)
}
if n.TotalNoise < 0 || n.HostNoise < 0 {
if totalNoise < 0 || hostNoise < 0 {
return RuntimeAmbient{}, fmt.Errorf("networks[%d] noise values must be non-negative", i)
}
if n.Correlation < 0 || n.Correlation >= 1 {
if correlation < 0 || correlation >= 1 {
return RuntimeAmbient{}, fmt.Errorf("networks[%d].correlation must be in [0, 1)", i)
}

Expand All @@ -209,10 +212,10 @@ func loadAmbientConfig(path string) (RuntimeAmbient, error) {
OutgoingShare: outgoingShare,
RateBPS: rate,
Hosts: n.Hosts,
Zipf: n.Zipf,
TotalNoise: n.TotalNoise,
HostNoise: n.HostNoise,
Correlation: n.Correlation,
Zipf: zipf,
TotalNoise: totalNoise,
HostNoise: hostNoise,
Correlation: correlation,
PeerPrefixes: peers,
})
}
Expand Down Expand Up @@ -307,8 +310,8 @@ func applyCommonDefaults(exporter *ExporterConfig, packet *PacketConfig, protoco
if exporter.AgentIP == "" {
exporter.AgentIP = "192.0.2.10"
}
if exporter.SubAgentID == 0 {
exporter.SubAgentID = defaultSubAgent
if exporter.SubAgentID == nil {
exporter.SubAgentID = &defaultSubAgent
}
if exporter.SamplingRate == 0 {
exporter.SamplingRate = 4096
Expand All @@ -319,14 +322,14 @@ func applyCommonDefaults(exporter *ExporterConfig, packet *PacketConfig, protoco
if exporter.SamplesPerDatagram == 0 {
exporter.SamplesPerDatagram = 8
}
if exporter.Seed == 0 {
exporter.Seed = 1
if exporter.Seed == nil {
exporter.Seed = ptr(int64(1))
}
if packet.MeanSize == 0 {
packet.MeanSize = 900
}
if packet.StdDev == 0 {
packet.StdDev = 220
if packet.StdDev == nil {
packet.StdDev = ptr(220.0)
}
if packet.MinSize == 0 {
packet.MinSize = 64
Expand Down Expand Up @@ -371,7 +374,7 @@ func validateCommon(exporter ExporterConfig, packet PacketConfig, protocols Prot
if packet.MinSize < 64 || packet.MaxSize < 78 || packet.MaxSize > 9216 || packet.MinSize > packet.MaxSize {
return RuntimeCommon{}, errors.New("packet sizes must satisfy 64 <= min_size <= max_size <= 9216 and max_size >= 78 for IPv6")
}
if packet.MeanSize < packet.MinSize || packet.MeanSize > packet.MaxSize || packet.StdDev < 0 {
if packet.MeanSize < packet.MinSize || packet.MeanSize > packet.MaxSize || *packet.StdDev < 0 {
return RuntimeCommon{}, errors.New("packet.mean_size/stddev are inconsistent with min_size/max_size")
}
if _, err := protocols.normalized(); err != nil {
Expand All @@ -388,18 +391,34 @@ func validateCommon(exporter ExporterConfig, packet PacketConfig, protocols Prot
return RuntimeCommon{
Collector: exporter.Collector,
AgentIP: agentIP,
SubAgentID: exporter.SubAgentID,
SubAgentID: *exporter.SubAgentID,
SamplingRate: exporter.SamplingRate,
Tick: tick,
SamplesPerDatagram: exporter.SamplesPerDatagram,
Seed: exporter.Seed,
Packet: packet,
Protocols: protocols,
LogInterval: logEvery,
MaxSamplesPerTick: maxSamples,
Seed: *exporter.Seed,
Packet: RuntimePacket{
MeanSize: packet.MeanSize,
StdDev: *packet.StdDev,
MinSize: packet.MinSize,
MaxSize: packet.MaxSize,
},
Protocols: protocols,
LogInterval: logEvery,
MaxSamplesPerTick: maxSamples,
}, nil
}

func ptr[T any](value T) *T {
return &value
}

func valueOr[T any](value *T, fallback T) T {
if value == nil {
return fallback
}
return *value
}

func (p ProtocolMix) normalized() (ProtocolMix, error) {
if p.TCP < 0 || p.UDP < 0 || p.ICMP < 0 {
return ProtocolMix{}, errors.New("protocol weights cannot be negative")
Expand Down
64 changes: 63 additions & 1 deletion config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "testing"
import (
"os"
"path/filepath"
"testing"
)

func TestParseBitRate(t *testing.T) {
tests := map[string]float64{
Expand Down Expand Up @@ -34,3 +38,61 @@ func TestParseAmbientShares(t *testing.T) {
t.Fatal("expected zero shares to be rejected")
}
}

func TestExplicitZeroValuesAreHonoured(t *testing.T) {
path := filepath.Join(t.TempDir(), "ambient.yaml")
body := `exporter:
seed: 0
sub_agent_id: 0
packet:
stddev: 0
networks:
- cidr: "10.0.0.0/24"
incoming_share: 1
outgoing_share: 0
rate: "100Mbps"
zipf: 0
total_noise: 0
host_noise: 0
correlation: 0
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadAmbientConfig(path)
if err != nil {
t.Fatal(err)
}
if cfg.Common.Seed != 0 || cfg.Common.Packet.StdDev != 0 {
t.Fatalf("seed=%d stddev=%f, expected both 0", cfg.Common.Seed, cfg.Common.Packet.StdDev)
}
network := cfg.Networks[0]
if network.Zipf != 0 || network.TotalNoise != 0 || network.HostNoise != 0 || network.Correlation != 0 {
t.Fatalf("zipf=%f total_noise=%f host_noise=%f correlation=%f, expected all 0",
network.Zipf, network.TotalNoise, network.HostNoise, network.Correlation)
}
}

func TestOmittedValuesUseDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "ambient.yaml")
body := `networks:
- cidr: "10.0.0.0/24"
incoming_share: 1
outgoing_share: 0
rate: "100Mbps"
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadAmbientConfig(path)
if err != nil {
t.Fatal(err)
}
if cfg.Common.Seed != 1 || cfg.Common.Packet.StdDev != 220 {
t.Fatalf("seed=%d stddev=%f, expected 1/220", cfg.Common.Seed, cfg.Common.Packet.StdDev)
}
network := cfg.Networks[0]
if network.Zipf != 1.10 || network.TotalNoise != 0.015 || network.HostNoise != 0.25 || network.Correlation != 0.98 {
t.Fatalf("defaults not applied: %+v", network)
}
}
Loading