Run a fake CPE (or fleet of fake CPEs) that talks real TR-069 to your ACS, locally, in containers, or in CI.
Single CPE, one bootstrap¶
docker run --rm herderlabs/cpe-sim \
--profile=/profiles/example-tr181-gateway/ \
--acs-url=http://your-acs.local:7547/ \
--log-level=info
The simulator sends one bootstrap Inform, drains any RPCs the ACS pushes back, and exits.
Single CPE, daemon mode¶
Profile declares periodicInformPaths (the example profile already does), so this runs continuously: bootstrap, then periodic Informs every 300s ± 10% jitter (interval comes from the profile, not a flag).
docker run --rm -p 7547:7547 herderlabs/cpe-sim \
--profile=/profiles/example-tr181-gateway/ \
--acs-url=http://your-acs.local:7547/ \
--cr-bind-addr=0.0.0.0:7547 \
--cr-publish-path=Device.ManagementServer.ConnectionRequestURL
--cr-bind-addr enables the connection-request listener (the URL the ACS calls to wake the CPE up). --cr-publish-path names the leaf the resolved URL gets written into so the next Inform reports it.
Multi-CPE: N fake CPEs in one process¶
Add a fleet: block to the profile and the simulator builds N independent CPEs from the same template, each with its own tree, transport (cookie jar / auth cache), tracker, session, scheduler entry, generator runner, and stamped per-instance values:
fleet:
count: 100
serialPattern: "TEST-{i:04}" # → TEST-0001, TEST-0002, ...
deviceIdPaths:
manufacturer: Device.DeviceInfo.Manufacturer
oui: Device.DeviceInfo.ManufacturerOUI
productClass: Device.DeviceInfo.ProductClass
serialNumber: Device.DeviceInfo.SerialNumber
parameters:
- path: Device.DeviceInfo.Manufacturer
value: "ACME Corp"
- path: Device.DeviceInfo.ManufacturerOUI
value: "001122"
- path: Device.DeviceInfo.ProductClass
value: "HomeGateway"
- path: Device.DeviceInfo.SerialNumber
value: "TEST" # base; gets stamped per CPE via serialPattern
# Per-CPE placeholders work in ANY value, not just the serial:
- path: Device.IP.Interface.1.IPv4Address
value: "10.0.0.{cpe}" # 10.0.0.1, 10.0.0.2, ...
writable: true
- path: Device.Ethernet.Interface.1.MACAddress
value: "AA:BB:CC:00:00:{cpe:02}" # AA:BB:CC:00:00:01, ...02, ...
writable: true
- path: Device.Hosts.Host.1.HostName
value: "host-{cpe_id}" # host-cpe-1, host-cpe-2, ...
writable: true
Recognized placeholders (substituted at fleet expansion time, before any tree mutation):
| Placeholder | Substitutes to | Example (instance 7) |
|---|---|---|
{base} |
The literal value: of the SerialNumber leaf (only valid in fleet.serialPattern) |
TEST |
{i} |
1-based instance index (only valid in fleet.serialPattern) |
7 |
{i:N} |
Instance index zero-padded to N digits (only valid in fleet.serialPattern) |
0007 |
{cpe} |
1-based instance index (valid in any leaf value) | 7 |
{cpe:N} |
Instance index zero-padded to N digits (valid in any leaf value) | 0007 |
{cpe_id} |
The assigned CPE ID (valid in any leaf value) | cpe-7 |
Existing {i} in path templates (table-instance materialization) is unrelated and is fully resolved at profile-load time before fleet substitution runs, so the two systems don't collide.
When count > 1:
- The CR listener routes to /<cr-path>/<cpe-id> per CPE on the shared port (/cr/cpe-1, /cr/cpe-2, ...).
- All N bootstrap Informs fire in parallel; one slow CPE doesn't gate the others.
- Each CPE's cpe_id is stamped on its log lines so you can grep one CPE out of the multi-CPE noise.
Docker¶
# Build the image.
docker build -t cpe-sim:dev .
# One-shot Inform.
docker run --rm \
-v "$PWD/profiles/example-tr181-gateway/:/profile.yaml:ro" \
cpe-sim:dev \
--profile=/profile.yaml \
--acs-url=http://your-acs.local:7547/
# Daemon mode (multi-CPE fleet, CR listener exposed).
docker run --rm \
-p 7547:7547 \
-v "$PWD/my-fleet.yaml:/profile.yaml:ro" \
cpe-sim:dev \
--profile=/profile.yaml \
--acs-url=http://your-acs.local:7547/ \
--cr-bind-addr=0.0.0.0:7547 \
--cr-publish-path=Device.ManagementServer.ConnectionRequestURL \
--seed=1
The image bundles the reference profiles at /profiles/example-tr181-gateway/ and /profiles/example-tr098-gateway/ so you can skip the volume mount when using one of those.
Compose¶
docker-compose.example.yml ships ready to copy:
cp docker-compose.example.yml docker-compose.yml
ACS_URL=http://your-acs.local:7547/ docker compose up --build
Edit the command: block to point at your profile and tweak flags.
Pointing at an ACS¶
The simulator is faithful to BBF TR-069. Most issues you'll hit during integration are profile mismatches, not protocol bugs:
- ACS asks for a path the profile doesn't have → simulator faults
9005 Invalid parameter name. Correct CPE behavior; expand the profile or register a TR-181 mapping in the ACS. - ACS expects TR-098 paths but you're running TR-181 → switch profiles (
profiles/example-tr098-gateway/) or rewrite the relevant leaves. - ACS has no "mapping profile" / vendor profile registered for the simulated
(Manufacturer, OUI, ProductClass)tuple → it asks for paths that don't exist anywhere. Register it ACS-side.
Run with --log-level=debug to see every SOAP request/response body. The structured cpe_id field on every log line makes greppinng one CPE out of an N-CPE fleet trivial.
Determinism¶
--seed=N (or CPE_SIM_SEED=N / seed: N in YAML) makes everything reproducible: scheduler jitter, counter generators, fleet RNG streams. Without --seed, the simulator derives a seed from time.Now().UnixNano() and logs it at startup as root_seed=<N>. Record that, pass it back via --seed=<N> next run, and the streams replay byte-for-byte.
CI integration patterns¶
One-shot smoke test (verify ACS handshakes)¶
# .github/workflows/openacs-smoke.yml (excerpt)
- run: |
docker run --rm --network=host herderlabs/cpe-sim \
--profile=/profiles/example-tr181-gateway/ \
--acs-url=http://localhost:7547/ \
--log-level=info \
--seed=1
Exit code 0 ⇒ Inform sent and InformResponse parsed cleanly. Exit code 1 ⇒ inspect logs.
Fleet against ACS in compose¶
services:
acs:
image: your-acs:dev
ports: ["7547:7547"]
cpe-sim:
build: .
depends_on: [acs]
command:
- --profile=/profiles/example-tr181-gateway/
- --acs-url=http://acs:7547/
- --seed=1
- --log-level=info
# No port mapping needed unless you want the ACS to push CRs back.
# in which case expose 7547 and add --cr-bind-addr / --cr-publish-path.
Spin both up, run your test suite against the ACS, then docker compose down. The simulator keeps Informing on its periodic schedule the whole time.
Pinning fleet size in CI¶
docker run --rm \
-v "$PWD/ci-fleet.yaml:/profile.yaml:ro" \
cpe-sim:dev \
--profile=/profile.yaml \
--acs-url=http://acs:7547/ \
--seed=$BUILD_NUMBER
Use the build number as the seed so each CI run is reproducible but different builds don't collide on the same RNG stream.
CLI / env / YAML reference¶
Every flag has an env var (CPE_SIM_*) and a YAML key. Precedence is flag > env > file > defaults.
| Flag | Env | YAML | Default | Notes |
|---|---|---|---|---|
--acs-url |
CPE_SIM_ACS_URL |
acsURL |
(required) | The ACS's CWMP endpoint. |
--acs-username |
CPE_SIM_ACS_USERNAME |
acsUsername |
"" | Optional HTTP Basic/Digest auth. |
--acs-password |
CPE_SIM_ACS_PASSWORD |
acsPassword |
"" | |
--acs-timeout |
CPE_SIM_ACS_TIMEOUT |
acsTimeout |
30s |
Per-request timeout. |
--profile |
CPE_SIM_PROFILE |
profile |
(required) | Path to a YAML/JSON profile or directory of YAML files. |
--seed |
CPE_SIM_SEED |
seed |
0 |
RNG seed for jitter + generators + fleet streams. 0 derives from time.Now().UnixNano(); the actual seed is logged as root_seed=<N> so you can replay an unseeded run. |
--cr-bind-addr |
CPE_SIM_CR_BIND_ADDR |
crBindAddr |
"" | Empty disables the CR listener. Setting it enables daemon mode. |
--cr-path |
CPE_SIM_CR_PATH |
crPath |
/cr |
URL path the listener serves. When fleet.count > 1 each CPE gets /cr/<cpe-id> automatically. |
--cr-publish-path |
CPE_SIM_CR_PUBLISH_PATH |
crPublishPath |
(required when --cr-bind-addr is set) |
Tree path the listener URL is written to. |
--tls-skip-verify |
CPE_SIM_TLS_SKIP_VERIFY |
tlsSkipVerify |
false |
Insecure; use only for self-signed test ACSes. |
--ca-cert-file |
CPE_SIM_CA_CERT_FILE |
caCertFile |
"" | PEM bundle for outbound HTTPS verification. |
--log-level |
CPE_SIM_LOG_LEVEL |
logLevel |
info |
debug / info / warn / error. |
--log-format |
CPE_SIM_LOG_FORMAT |
logFormat |
text |
text / json. |
--config |
CPE_SIM_CONFIG |
(n/a) | "" | Path to a YAML config file holding any of the above keys. |
TR-369 (USP) mode¶
The same binary speaks USP over MQTT, WebSocket, or STOMP. Same profile, same parameter tree, same fleet:
docker run --rm herderlabs/cpe-sim \
--profile=/profiles/example-tr181-gateway/ \
--usp-mtp=mqtt \
--usp-mqtt-addr=broker.example.com:1883
See USP Agent for the full set of MTP flags and Subscribe/Notify behavior.
If you hit a gap during integration, file an issue with the ACS / Controller log excerpt. That's the most actionable signal.