diff --git a/core/register.go b/core/register.go
index 0ea16de5b6..998c1cade3 100644
--- a/core/register.go
+++ b/core/register.go
@@ -15,6 +15,7 @@ import (
clabnodescisco_sdwan "github.com/srl-labs/containerlab/nodes/cisco_sdwan"
clabnodescisco_vios "github.com/srl-labs/containerlab/nodes/cisco_vios"
clabnodescjunosevolved "github.com/srl-labs/containerlab/nodes/cjunosevolved"
+ clabnodescodelaboratory_bng "github.com/srl-labs/containerlab/nodes/codelaboratory_bng"
clabnodescrpd "github.com/srl-labs/containerlab/nodes/crpd"
clabnodescvx "github.com/srl-labs/containerlab/nodes/cvx"
clabnodesdell_sonic "github.com/srl-labs/containerlab/nodes/dell_sonic"
@@ -117,4 +118,5 @@ func (c *CLab) RegisterNodes() { //nolint:funlen
clabnodesvyosnetworks_vyos.Register(c.Reg)
clabnodescjunosevolved.Register(c.Reg)
clabnodesarrcus_arcos.Register(c.Reg)
+ clabnodescodelaboratory_bng.Register(c.Reg)
}
diff --git a/docs/lab-examples/bng01.md b/docs/lab-examples/bng01.md
new file mode 100644
index 0000000000..ee30bc625d
--- /dev/null
+++ b/docs/lab-examples/bng01.md
@@ -0,0 +1,75 @@
+| | |
+| ----------------------------- | -------------------------------------------------------------------------------------- |
+| **Description** | BNG with subscriber simulator and core router |
+| **Components** | [Code Laboratory BNG][bng], [BNG Blaster][blaster], [FRR][frr] |
+| **Resource requirements**[^1] | :fontawesome-solid-microchip: 2
:fontawesome-solid-memory: 2 GB |
+| **Topology file** | [bng01.clab.yml][topofile] |
+| **Name** | bng01 |
+
+## Description
+
+This lab demonstrates Code Laboratory's eBPF/XDP-based BNG handling IPoE/DHCPv4 subscriber sessions. The topology consists of three nodes:
+
+| Node | Role | Image |
+|------|------|-------|
+| `bng1` | Broadband Network Gateway | `ghcr.io/codelaboratoryltd/bng:latest` |
+| `subscribers` | Subscriber simulator (BNG Blaster) | `veesixnetworks/bngblaster:0.9.30` |
+| `corerouter1` | Core/upstream router (FRR) | `frrouting/frr:v8.4.1` |
+
+## Topology
+
+```
+subscribers:eth1 <-----> bng1:eth1 (access)
+ bng1:eth2 (core) <-----> corerouter1:eth1
+```
+
+## Deploying the lab
+
+```bash
+sudo clab deploy -t lab-examples/bng01/bng01.clab.yml
+```
+
+## Verification
+
+Check BNG is running:
+
+```bash
+docker logs clab-bng01-bng1
+```
+
+Check FRR routing:
+
+```bash
+docker exec clab-bng01-corerouter1 vtysh -c "show ip route"
+```
+
+Run BNG Blaster to simulate 10 untagged IPoE/DHCPv4 subscribers:
+
+```bash
+docker exec -it clab-bng01-subscribers bngblaster -C /config/config.json
+```
+
+A QinQ (802.1ad) subscriber config is also provided for double-tagged deployments:
+
+```bash
+docker exec -it clab-bng01-subscribers bngblaster -C /config/qinq.json
+```
+
+Check BNG metrics:
+
+```bash
+curl http://clab-bng01-bng1:9090/metrics
+```
+
+## Cleanup
+
+```bash
+sudo clab destroy -t lab-examples/bng01/bng01.clab.yml
+```
+
+[bng]: https://github.com/codelaboratoryltd/bng
+[blaster]: https://github.com/rtbrick/bngblaster
+[frr]: https://docs.frrouting.org/en/stable-8.4/
+[topofile]: https://github.com/srl-labs/containerlab/tree/main/lab-examples/bng01/bng01.clab.yml
+
+[^1]: Resource requirements are provisional. Consult with the installation guides for additional information.
diff --git a/docs/manual/kinds/codelaboratory_bng.md b/docs/manual/kinds/codelaboratory_bng.md
new file mode 100644
index 0000000000..9e24aefd65
--- /dev/null
+++ b/docs/manual/kinds/codelaboratory_bng.md
@@ -0,0 +1,167 @@
+---
+search:
+ boost: 4
+kind_code_name: codelaboratory_bng
+kind_display_name: Code Laboratory BNG
+---
+# Code Laboratory BNG
+
+Code Laboratory's eBPF/XDP-based Broadband Network Gateway is identified with `-{{ kind_code_name }}-` kind in the [topology file](../topo-def-file.md).
+
+The BNG uses eBPF/XDP for kernel-level DHCP fast path processing, achieving sub-100μs DHCP response times for cached subscribers. Unlike VPP-based BNGs, it requires only `NET_ADMIN` and `BPF` Linux capabilities — no hugepages, DPDK, or dedicated NICs.
+
+## Getting -{{ kind_display_name }}- image
+
+The -{{ kind_display_name }}- container image is available from GitHub Container Registry:
+
+```bash
+docker pull ghcr.io/codelaboratoryltd/bng:latest
+```
+
+Source code and build instructions are available on [GitHub](https://github.com/codelaboratoryltd/bng).
+
+## Managing -{{ kind_display_name }}- nodes
+
+### Health check
+
+The BNG exposes a health endpoint on the metrics port:
+
+```bash
+curl http://:9090/health
+```
+
+### Prometheus metrics
+
+Metrics are served at the configured metrics address (default `:9090`):
+
+```bash
+curl http://:9090/metrics
+```
+
+Key metrics include DHCP fast/slow path latencies, cache hit rates, pool utilization, and active session counts.
+
+## Interfaces naming
+
+-{{ kind_display_name }}- nodes use the following interface naming convention:
+
+| Interface | Purpose |
+|-----------|---------|
+| `eth0` | Management (containerlab default) |
+| `eth1` | Access / subscriber-facing |
+| `eth2+` | Core / upstream links |
+
+The `eth0` interface can only be used when `network-mode` is set to `none`.
+
+There are no other restrictions on the interface naming besides the generic Linux interface naming rules.
+
+## Features and options
+
+### Startup configuration
+
+The [`startup-config`](../nodes.md#startup-config) property sets the path to a YAML config file that is mounted to `/etc/bng/config.yaml` inside the container.
+
+The config file uses a flat `key: value` format where each key matches a CLI flag name (without `--`). CLI flags take precedence over config file values.
+
+Example minimal config:
+
+```yaml
+interface: eth1
+pool-network: 10.0.1.0/24
+pool-gateway: 10.0.1.1
+pool-dns: "8.8.8.8,8.8.4.4"
+lease-time: 3600s
+metrics-addr: ":9090"
+log-level: info
+```
+
+Example with advanced features:
+
+```yaml
+interface: eth1
+pool-network: 10.0.1.0/24
+pool-gateway: 10.0.1.1
+pool-dns: "8.8.8.8,8.8.4.4"
+lease-time: 3600s
+metrics-addr: ":9090"
+log-level: info
+
+# Anti-spoofing (disabled, strict, loose, log-only)
+antispoof-mode: log-only
+
+# Walled garden captive portal
+walled-garden: "false"
+walled-garden-portal: "10.255.255.1:8080"
+
+# NAT44/CGNAT
+nat-enabled: "true"
+nat-inside-interface: eth1
+nat-outside-interface: eth2
+
+# PPPoE
+pppoe-enabled: "false"
+
+# HA failover (active or standby)
+ha-role: ""
+ha-peer: ""
+```
+
+### Environment variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `BNG_INTERFACE` | `eth1` | Subscriber-facing interface |
+| `BNG_LOG_LEVEL` | `info` | Log level (debug, info, warn, error) |
+
+### Linux capabilities
+
+The -{{ kind_display_name }}- kind automatically adds the following capabilities:
+
+| Capability | Purpose |
+|------------|---------|
+| `NET_ADMIN` | Network interface and eBPF map management |
+| `BPF` | Loading eBPF/XDP programs into the kernel |
+
+This is significantly lighter than VPP-based BNGs which additionally require `SYS_ADMIN`, `IPC_LOCK`, `SYS_NICE`, and `SYS_RAWIO` for DPDK hugepage management.
+
+### QinQ (802.1ad) support
+
+The BNG supports QinQ double VLAN tagging at the eBPF/XDP data plane level. Subscribers are identified by S-TAG (service VLAN, outer) and C-TAG (customer VLAN, inner) pairs, supporting up to 100,000 concurrent VLAN-based subscribers.
+
+The [bng01 lab example](../../lab-examples/bng01.md) includes both untagged and QinQ subscriber configs for [BNG Blaster][blaster].
+
+[blaster]: https://github.com/rtbrick/bngblaster
+
+## Quickstart
+
+The following topology creates a minimal BNG lab with a subscriber simulator and core router:
+
+```yaml
+name: bng-quickstart
+
+topology:
+ nodes:
+ bng1:
+ kind: codelaboratory_bng
+ image: ghcr.io/codelaboratoryltd/bng:latest
+ startup-config: bng1/config.yaml
+ exec:
+ - ip addr add 10.0.1.1/24 dev eth1
+ - ip addr add 10.0.0.1/24 dev eth2
+ subscribers:
+ kind: linux
+ image: veesixnetworks/bngblaster:0.9.30
+ binds:
+ - subscribers/config.json:/config/config.json
+ corerouter1:
+ kind: linux
+ image: frrouting/frr:v8.4.1
+ binds:
+ - corerouter1/daemons:/etc/frr/daemons
+ - corerouter1/frr.conf:/etc/frr/frr.conf
+
+ links:
+ - endpoints: ["subscribers:eth1", "bng1:eth1"]
+ - endpoints: ["bng1:eth2", "corerouter1:eth1"]
+```
+
+See the [bng01 lab example](../../lab-examples/bng01.md) for a complete working topology with all config files.
diff --git a/docs/manual/kinds/index.md b/docs/manual/kinds/index.md
index 549d594768..bd060f5cc7 100644
--- a/docs/manual/kinds/index.md
+++ b/docs/manual/kinds/index.md
@@ -76,6 +76,7 @@ Within each predefined kind, we store the necessary information that is used to
| **6WIND VSR** | [`6wind_vsr`](6wind_vsr.md) | supported | container |
| **Keysight ixia-c-one** | [`keysight_ixia-c-one`](keysight_ixia-c-one.md) | supported | container |
| **Arrcus ArcOS** | [`arrcus_arcos`](arrcus_arcos.md) | supported | container |
+| **Code Laboratory BNG** | [`codelaboratory_bng`](codelaboratory_bng.md) | supported | container |
| **FD.io VPP** | [`fdio_vpp`](fdio_vpp.md) | supported | container |
| **RARE/freeRtr** | [`rare`](rare-freertr.md) | supported | container |
| **VyOS Networks VyOS** | [`vyosnetworks_vyos`](vyosnetworks_vyos.md) | supported | VM |
diff --git a/lab-examples/bng01/bng01.clab.yml b/lab-examples/bng01/bng01.clab.yml
new file mode 100644
index 0000000000..4f468ba451
--- /dev/null
+++ b/lab-examples/bng01/bng01.clab.yml
@@ -0,0 +1,27 @@
+name: bng01
+
+topology:
+ nodes:
+ bng1:
+ kind: codelaboratory_bng
+ image: ghcr.io/codelaboratoryltd/bng:latest
+ startup-config: bng1/config.yaml
+ exec:
+ - ip addr add 10.0.1.1/24 dev eth1
+ - ip addr add 10.0.0.1/24 dev eth2
+ corerouter1:
+ kind: linux
+ image: frrouting/frr:v8.4.1
+ binds:
+ - corerouter1/daemons:/etc/frr/daemons
+ - corerouter1/frr.conf:/etc/frr/frr.conf
+ subscribers:
+ kind: linux
+ image: veesixnetworks/bngblaster:0.9.30
+ binds:
+ - subscribers/config.json:/config/config.json
+ - subscribers/qinq.json:/config/qinq.json
+
+ links:
+ - endpoints: ["subscribers:eth1", "bng1:eth1"]
+ - endpoints: ["bng1:eth2", "corerouter1:eth1"]
diff --git a/lab-examples/bng01/bng1/config.yaml b/lab-examples/bng01/bng1/config.yaml
new file mode 100644
index 0000000000..8d5d758eb5
--- /dev/null
+++ b/lab-examples/bng01/bng1/config.yaml
@@ -0,0 +1,12 @@
+# BNG startup configuration for containerlab
+#
+# Flat key=value format where each key matches a CLI flag name (without --).
+# CLI flags take precedence over values in this file.
+
+interface: eth1
+pool-network: 10.0.1.0/24
+pool-gateway: 10.0.1.1
+pool-dns: "8.8.8.8,8.8.4.4"
+lease-time: 3600s
+metrics-addr: ":9090"
+log-level: info
diff --git a/lab-examples/bng01/corerouter1/daemons b/lab-examples/bng01/corerouter1/daemons
new file mode 100644
index 0000000000..a0cc2b5bf8
--- /dev/null
+++ b/lab-examples/bng01/corerouter1/daemons
@@ -0,0 +1,22 @@
+zebra=yes
+bgpd=no
+ospfd=yes
+ospf6d=no
+ripd=no
+ripngd=no
+isisd=no
+pimd=no
+ldpd=no
+nhrpd=no
+eigrpd=no
+babeld=no
+sharpd=no
+staticd=yes
+pbrd=no
+bfdd=no
+fabricd=no
+
+vtysh_enable=yes
+zebra_options=" -A 127.0.0.1"
+ospfd_options=" -A 127.0.0.1"
+staticd_options="-A 127.0.0.1"
diff --git a/lab-examples/bng01/corerouter1/frr.conf b/lab-examples/bng01/corerouter1/frr.conf
new file mode 100644
index 0000000000..731330713d
--- /dev/null
+++ b/lab-examples/bng01/corerouter1/frr.conf
@@ -0,0 +1,28 @@
+frr version 8.4.1
+frr defaults traditional
+hostname corerouter1
+log syslog informational
+service integrated-vtysh-config
+!
+interface eth1
+ ip address 10.0.0.2/24
+ ip ospf area 0.0.0.0
+ ip ospf network point-to-point
+exit
+!
+interface lo
+ ip address 10.255.0.2/32
+ ip ospf area 0.0.0.0
+ ip ospf passive
+exit
+!
+router ospf
+ ospf router-id 10.255.0.2
+ log-adjacency-changes
+ redistribute connected
+exit
+!
+ip route 10.0.1.0/24 10.0.0.1
+!
+line vty
+!
diff --git a/lab-examples/bng01/subscribers/config.json b/lab-examples/bng01/subscribers/config.json
new file mode 100644
index 0000000000..a904c6dcfa
--- /dev/null
+++ b/lab-examples/bng01/subscribers/config.json
@@ -0,0 +1,26 @@
+{
+ "interfaces": {
+ "access": [
+ {
+ "interface": "eth1",
+ "type": "ipoe",
+ "vlan-mode": "N:1"
+ }
+ ]
+ },
+ "dhcp": {
+ "enable": true
+ },
+ "ipoe": {
+ "ipv4": true,
+ "ipv6": false
+ },
+ "sessions": {
+ "count": 10,
+ "start-rate": 2
+ },
+ "access-line": {
+ "agent-circuit-id": "eth 0/1/{session-global}",
+ "agent-remote-id": "BNG-Blaster-{session-global}"
+ }
+}
diff --git a/lab-examples/bng01/subscribers/qinq.json b/lab-examples/bng01/subscribers/qinq.json
new file mode 100644
index 0000000000..601b34faaf
--- /dev/null
+++ b/lab-examples/bng01/subscribers/qinq.json
@@ -0,0 +1,30 @@
+{
+ "interfaces": {
+ "access": [
+ {
+ "interface": "eth1",
+ "type": "ipoe",
+ "vlan-mode": "1:1",
+ "outer-vlan-min": 100,
+ "outer-vlan-max": 100,
+ "inner-vlan-min": 10,
+ "inner-vlan-max": 19
+ }
+ ]
+ },
+ "dhcp": {
+ "enable": true
+ },
+ "ipoe": {
+ "ipv4": true,
+ "ipv6": false
+ },
+ "sessions": {
+ "count": 10,
+ "start-rate": 2
+ },
+ "access-line": {
+ "agent-circuit-id": "eth 0/1/{session-global}:100.{session-global}",
+ "agent-remote-id": "BNG-Blaster-{session-global}"
+ }
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index 8f1e091a59..eeebe8ed87 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,6 +62,7 @@ nav:
- Palo Alto PAN: manual/kinds/vr-pan.md
- 6WIND VSR: manual/kinds/6wind_vsr.md
- Arrcus ArcOS: manual/kinds/arrcus_arcos.md
+ - Code Laboratory BNG: manual/kinds/codelaboratory_bng.md
- FD.io VPP: manual/kinds/fdio_vpp.md
- KinD: manual/kinds/k8s-kind.md
- Linux bridge: manual/kinds/bridge.md
@@ -177,6 +178,7 @@ nav:
- Cisco ASAv: lab-examples/asav01.md
- Cisco FTDv: lab-examples/ftdv01.md
- F5 BIG-IP VE: lab-examples/f5bigipve01.md
+ - Code Laboratory BNG: lab-examples/bng01.md
- Templated labs:
- Leaf-spine topology: lab-examples/templated01.md
- 5-stage Clos topology: lab-examples/templated02.md
diff --git a/nodes/codelaboratory_bng/codelaboratory_bng.go b/nodes/codelaboratory_bng/codelaboratory_bng.go
new file mode 100644
index 0000000000..8dc04c9351
--- /dev/null
+++ b/nodes/codelaboratory_bng/codelaboratory_bng.go
@@ -0,0 +1,137 @@
+// Copyright 2025 Code Laboratory Ltd
+// Licensed under the BSD 3-Clause License.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package codelaboratory_bng
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path"
+ "strings"
+
+ clabconstants "github.com/srl-labs/containerlab/constants"
+ clabnodes "github.com/srl-labs/containerlab/nodes"
+ clabtypes "github.com/srl-labs/containerlab/types"
+ clabutils "github.com/srl-labs/containerlab/utils"
+)
+
+const (
+ generateable = true
+ generateIfFormat = "eth%d"
+
+ bngCfgDstPath = "/etc/bng/config.yaml"
+ ifWaitScriptDstPath = "/usr/sbin/if-wait.sh"
+)
+
+var kindNames = []string{"codelaboratory_bng"}
+
+// Register registers the node in the NodeRegistry.
+func Register(r *clabnodes.NodeRegistry) {
+ generateNodeAttributes := clabnodes.NewGenerateNodeAttributes(generateable, generateIfFormat)
+ nrea := clabnodes.NewNodeRegistryEntryAttributes(nil, generateNodeAttributes, nil)
+
+ r.Register(kindNames, func() clabnodes.Node {
+ return new(codelaboratory_bng)
+ }, nrea)
+}
+
+type codelaboratory_bng struct {
+ clabnodes.DefaultNode
+ // Path to the BNG config file on the host
+ bngCfgSrcPath string
+ // Path to the interface wait script on the host
+ ifWaitSrcPath string
+}
+
+func (n *codelaboratory_bng) Init(cfg *clabtypes.NodeConfig, opts ...clabnodes.NodeOption) error {
+ // Init DefaultNode
+ n.DefaultNode = *clabnodes.NewDefaultNode(n)
+ n.Cfg = cfg
+
+ // Capabilities required by BNG (eBPF/XDP - much lighter than VPP/DPDK)
+ n.Cfg.CapAdd = append(n.Cfg.CapAdd,
+ "NET_ADMIN",
+ "BPF",
+ )
+
+ // Tell the BNG which interface to use for subscriber traffic
+ if n.Cfg.Env == nil {
+ n.Cfg.Env = make(map[string]string)
+ }
+
+ // Set up the BNG config bind mount
+ n.bngCfgSrcPath = path.Join(n.Cfg.LabDir, "config.yaml")
+ n.Cfg.ResStartupConfig = n.bngCfgSrcPath
+ n.Cfg.Binds = append(n.Cfg.Binds, fmt.Sprint(n.Cfg.ResStartupConfig, ":", bngCfgDstPath))
+
+ // Bind-mount the interface wait script so the container can wait for
+ // containerlab to wire the veth links before starting the BNG process.
+ n.ifWaitSrcPath = path.Join(n.Cfg.LabDir, "if-wait.sh")
+ n.Cfg.Binds = append(n.Cfg.Binds, fmt.Sprint(n.ifWaitSrcPath, ":", ifWaitScriptDstPath))
+
+ // Override the entrypoint to wait for interfaces before starting BNG.
+ // The BNG image has ENTRYPOINT ["/app/bng"] CMD ["run"], but containerlab
+ // creates containers first and wires links afterwards. Without this wrapper,
+ // the BNG process tries to bind its subscriber-facing interface (eth1)
+ // before the veth pair exists and crashes.
+ n.Cfg.Entrypoint = "sh -c '" + ifWaitScriptDstPath + " ; exec /app/bng run --config " + bngCfgDstPath + "'"
+
+ for _, o := range opts {
+ o(n)
+ }
+
+ return nil
+}
+
+func (n *codelaboratory_bng) PreDeploy(_ context.Context, params *clabnodes.PreDeployParams) error {
+ clabutils.CreateDirectory(n.Cfg.LabDir, clabconstants.PermissionsOpen)
+
+ // Generate the interface wait script from the shared template.
+ // This script polls /sys/class/net/ until all CLAB_INTFS interfaces
+ // appear, ensuring the BNG doesn't start before its links are wired.
+ clabutils.CreateFile(n.ifWaitSrcPath, clabutils.IfWaitScript)
+ os.Chmod(n.ifWaitSrcPath, clabconstants.PermissionsOpen)
+
+ nodeCfg := n.Config()
+
+ // Handle startup config provided by the user
+ var bngCfgTpl string
+
+ if nodeCfg.StartupConfig != "" {
+ c, err := os.ReadFile(nodeCfg.StartupConfig)
+ if err != nil {
+ return err
+ }
+ bngCfgTpl = string(c)
+ }
+
+ err := n.GenerateConfig(n.Cfg.ResStartupConfig, bngCfgTpl)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CheckInterfaceName allows any interface name for BNG nodes, but checks
+// if eth0 is only used with network-mode=none.
+func (n *codelaboratory_bng) CheckInterfaceName() error {
+ nm := strings.ToLower(n.Cfg.NetworkMode)
+ for _, e := range n.Endpoints {
+ if e.GetIfaceName() == "eth0" && nm != "none" {
+ return fmt.Errorf(
+ "eth0 interface name is not allowed for %s node when network mode is not set to none",
+ n.Cfg.ShortName,
+ )
+ }
+ }
+ return nil
+}
+
+func (n *codelaboratory_bng) GetImages(_ context.Context) map[string]string {
+ return map[string]string{
+ clabnodes.ImageKey: n.Cfg.Image,
+ }
+}