Get started
The DTVSOL Router API manages a DTVSOL DHCP router: static DHCP client leases (IPv4 + IPv6), auto-detected networks and interfaces, per-MAC firewall rules, QinQ VLAN provisioning and DHCP service control.
The API runs on the router itself, port 8880. Networks, interfaces and gateways are auto-detected from the system — no manual OLT/VLAN mapping is needed. Adding a client automatically writes the ISC DHCP host entry and reloads the DHCP service.
Base URL: http://ROUTER-IP:8880
To use this API you need an API key (configured in /opt/dtvsol/etc/config.php on the router).
Router IP / host:
API key:
Send the key in the X-API-Key HTTP header (recommended) or as an ?api_key= query parameter:
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/status" -H "accept: application/json" -H "X-API-Key: "
The router software version is reported by GET /status as router_version
(and by dtvsol version on the box).
Network protection: port 8880 is additionally guarded by iptables — only source networks added
via the Protect endpoints (or dtvsol protect add on the router CLI) can reach the API at all.
Everyone else is dropped before authentication.
CLI (dtvsol)
Every endpoint on this page is also a dtvsol command. The CLI is a thin wrapper around the same
local API (127.0.0.1:8880), reading the key from etc/config.php — identical behaviour,
validation and output. It is placed on PATH by install.sh. Run dtvsol help
on the router, or dtvsol ? <group> for a group's details.
Tab completion: dtvsol <TAB> lists the groups, dtvsol vlan <TAB> its subcommands, and the next position completes the value that subcommand takes — VLAN names, interfaces, registered MACs. vlan disable offers only enabled VLANs and vlan enable only disabled ones. Install once with dtvsol init, or copy /opt/dtvsol/etc/dtvsol.bash into /etc/bash_completion.d/. The candidates are read from the tool at call time, so new commands appear without touching anything.
Interactive helpers (vlan add, net add, ip add) prompt for values when run without arguments. Tip: dtvsol clients active and dtvsol alerts are the two to watch day-to-day, and dtvsol doctor after any interface, VLAN, address or route change — and after a reboot.
SERVICES — ONE VLAN PER SUBSCRIBER
The one call a billing makes. The support desk creates the customer in the billing; the technician installs the ONU and picks its serial from the list the router provides; the billing sends that serial and a speed — and the router does both ends: it registers the ONT on its OLT with the plan's profiles and a QinQ service-port, and gives the subscriber his own C-VLAN, an interface on the router, a /30, a /64 and a delegated prefix, DHCP, the shaper, accounting and anti-spoofing. It returns a permanent id; every later change, suspension, end date, deletion and traffic graph is by that id, whatever the CPE, the address or the ONT become.
Why a VLAN per subscriber. Everything the router does to a subscriber used to key on a MAC
address learned from DHCP — which the technician never sees, and which changes when the customer replaces his
router. With one VLAN per service the interface is the subscriber: whatever is plugged in behind the
ONT gets that service's address, speed and protection; two subscribers never share a broadcast domain; suspend
is the link going down. Numbering is derived, never allocated: svlan = the OLT's svlan + PON card
slot (4094 C-VLANs per card, 65k per OLT) and cvlan = the OLT's cvlan_base + port × 128 + ONT
id, so the number itself says where the subscriber is. Interface name v<svlan>.<cvlan>.
Addresses. IPv4: a /30 per service from svc_ipv4_pool (default
100.64.0.0/10, carrier-NAT space: a million services), router = first host, subscriber = second,
handed out by DHCP to whatever asks on that interface. IPv6: a /64 per service from svc_ipv6_pool
(DHCPv6, router advertisements) and a delegated prefix from svc_pd_pool. Speed: the OLT carries
the plan as CAR on the service-port (olt_car_headroom, default 1.05) and the router shapes the
interface exactly (shape_on_router; set it false to leave the OLT as the only limiter).
svc_suspend_olt (default true) makes a suspension also deactivate the ONT — cut at the fibre.
the technician's list
/usr/bin/curl -ss "http://ROUTER-IP:8880/services/unregistered?olt=lab-olt" -H "X-API-Key: "
Every unregistered ONT on every (or one) registered OLT: {olt, pon, sn, vendor, model, software,
seen_at, svlan}. Only ONTs that are connected and not yet registered appear.
create
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/services" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"ref":"contract-00812","sn":"54504C473053E5F8","olt":"lab-olt","pon":"0/0/3","down_mbps":200,"up_mbps":200,"name":"Juan Perez","contract":"CONTR-00812","ipv6":true,"iptv":false,"expires":"2026-10-31"}'
| Field | Type | Description |
|---|---|---|
| ref | String | The billing's own id. Makes the call idempotent: the same ref returns the existing service instead of creating another. |
| sn | String | The ONT serial (16 hex digits) from the unregistered list. |
| olt | String | Registered OLT name (required when more than one is registered). |
| pon | String | frame/slot/port from the list; looked up in autofind when omitted. |
| plan | down_mbps + up_mbps | String | Int | An existing plan, or the speeds: plan_<down>_<up> is created on the fly and reused. |
| name | String | The customer (required). contract, comment: free text, searchable. |
| ipv6 | Bool | Default true when svc_ipv6_pool is set. |
| iptv | Bool | ETH 2 of the ONT on the OLT's IPTV VLAN (unicast) with its own service-port. |
| expires | Date | Service end date: suspended by the router on that day, resumed when a later date is pushed. never clears it. |
| svlan, cvlan | Int | Only with "olt":"none" — a subscriber whose ONT was provisioned by hand; no OLT step. |
Returns 201 with the service and a technician hint (ONU WAN = VLAN <cvlan>, DHCP);
200 + existing:true for a repeated ref; 404 when the serial is not in autofind;
409 when the serial already has a service; 502 when the OLT refused (nothing created here);
500 when the OLT step succeeded but the router side failed — the service exists in state error,
fix the cause and POST /services/{id} {"retry":true}. The call is synchronous (10–25 s: one OLT session).
{"ok":true,"message":"Service created","service":{"id":"svc_d320fcbd","ref":"contract-00812","name":"Juan Perez","contract":"CONTR-00812","olt":"lab-olt","pon":"0/0/3","ont_id":7,"sn":"54504C473053E5F8","svlan":1004,"cvlan":2391,"iface":"v1004.2391","ipv4":{"network":"100.64.0.4/30","gateway":"100.64.0.5","address":"100.64.0.6"},"ipv6":{"link":"2803:1840:1002:1::/64","pd":"2803:1840:1003:1::/64"},"plan":"plan_200_200","iptv":false,"expires":"2026-10-31 00:00:00","state":"active","live":{"link":"up","online":false,"mac":null,"lease":null,"rx_bytes":0,"tx_bytes":0}},"technician":{"cvlan":2391,"note":"set the ONU's WAN to VLAN 2391, DHCP; it receives 100.64.0.6"}}
by id, for the life of the contract
GET /services/{id} — the record plus live (link, online, the CPE's MAC as seen, lease, counters); {id} also accepts the ref, the serial, the contract, the address or the interface.
POST /services/{id} — plan | down_mbps+up_mbps (re-shapes here, moves the ONT to the new profile and rate tables on the OLT), name, contract, comment, ref, expires, retry.
POST /services/{id}/suspend · POST /services/{id}/resume — interface down/up and the ONT deactivated/activated.
DELETE /services/{id}[?keep_ont=1] — everything here, and the ONT with its service-ports on the OLT. The id is never reused.
GET /services/{id}/graph?period=day — the subscriber's traffic as PNG.
GET /services?state=&olt=&q=&fast=1 — the list, with state counts.
CLI: dtvsol service unregistered | add <sn> --name "…" (--plan p | --down d --up u) [--olt n] [--pon f/s/p] [--ref r] [--iptv] | list | get | set | suspend | resume | del | graph.
Persistence: data/services.json is the source of truth; bin/apply-services.php recreates every
interface at boot (from dtvsol-vlans.service); the DHCP side lives in /etc/dhcp/dtvsol-services.conf
and dtvsol-services6.conf. The full guide for the billing team: docs/BILLING-INTEGRATION.md in the repository.
STATUS
get status
Base url:
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/status" -H "X-API-Key: "
RETURN PARAMETERS
| Field | Type | Description |
|---|---|---|
| api | String | API name and version. |
| dhcp_service | String | [running|stopped] — state of the ISC DHCP service. |
| total_clients | Integer | Number of registered static clients. |
| per_network | Object | Client count per detected network (CIDR ⇒ count). |
| interfaces | Integer | Number of detected DHCP-serving interfaces. |
| server_time | String | Current router time (Y-m-d H:i:s). |
EXAMPLE RESPONSE
{
"api": "DTVSOL DHCP API v1.0",
"dhcp_service": "running",
"total_clients": 5,
"per_network": {
"100.67.0.0/24": 1,
"100.67.1.0/24": 1,
"100.67.2.0/24": 1
},
"interfaces": 6,
"server_time": "2026-07-23 15:31:41"
}
CLIENTS
Static DHCP leases. Creating a client writes the host entry (IPv4 and, optionally, IPv6), assigns it to the auto-detected network of the given IP, and reloads DHCP.
list / search clients
Base url:
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/clients" -H "X-API-Key: "
QUERY PARAMETERS
| Field | Type | Description |
|---|---|---|
| network | String | (optional) Only clients of this network, e.g. 100.67.0.0/24. |
| q | String | (optional) Free-text search over MAC, IP, hostname and comment. |
EXAMPLE RESPONSE
{
"count": 1,
"clients": [
{
"mac": "1C:61:B4:60:7F:92",
"ip": "100.67.0.10",
"ipv6": null,
"hostname": "NOC",
"network": "100.67.0.0/24",
"network6": null,
"iface": "vlan108",
"gateway": "100.67.0.1",
"comment": "prueba",
"created": "2026-06-25 15:49:28"
}
]
}
get one client — by MAC, IP or hostname
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/clients/AA:BB:CC:DD:EE:FF" -H "X-API-Key: "
The lookup key can be a MAC, an IPv4/IPv6 address or a hostname
(e.g. /clients/100.67.0.10, /clients/Pedro). Returns {"client": {...}} or HTTP 404.
static IP usage per network NEW
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/ips?network=100.67.0.0/24" -H "X-API-Key: "
Per network (omit ?network= for all): total assignable, used reservations (sorted), free count,
the first free IPs (next_free — pick one when provisioning), and unregistered_seen —
devices live in ARP/NDP that have no reservation ("strangers"). Also /api?action=ip-info;
CLI: dtvsol ips [network].
client activity — active / connected clients NEW
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/clients/active" -H "X-API-Key: "
Live view of who is actually connected: every registered client is correlated with the kernel ARP/NDP
neighbor tables (status: online = reachable now, recent = seen recently,
offline), including a mac_mismatch warning when an IP answers from a different MAC
than registered. Active dynamic leases from the DHCP pools are listed separately.
Filter with ?state=online|recent|offline. Also available as
/api?action=active (alias connected).
A totals summary is included, and GET /status now reports online_clients too.
IPv6 clients — MAC to address, and delegated prefix NEW
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/clients6" -H "X-API-Key: "
The IPv6 answer to “who is on this VLAN, and with which address?” — a question no other
list on this box can answer. clients.json stores only the address that was
reserved, which is null for every client not configured by hand, and the
DHCPv6 lease database is keyed by DUID, not MAC. The kernel neighbour table is where the
two meet, and it is also the only place a SLAAC address appears at all — no lease is ever written
for one.
This endpoint joins all three, and includes clients that are in no database: a CPE addressed out
of the dynamic pool is reported with registered:false. The router's own interfaces and the
upstream BNG are filtered out; pass ?routers=1 to see them. Filter to one VLAN with
?iface=vlan108.
RESPONSE FIELDS (per client)
| Field | Description |
|---|---|
| ipv6 | The global address the client actually holds, taken from the neighbour table. ipv6_all lists every one it is using. |
| ipv6_reserved | The address reserved for it in clients.json, if any. Frequently null while ipv6 is populated — that is normal, not a fault. |
| prefix6_delegated | The LAN prefix currently delegated to it (IA_PD), read from the live leases. prefix6_pinned is the one pinned to it with /pd/assign, if any. |
| lease6 | Its live DHCPv6 address lease (IA_NA), as distinct from what the neighbour table has seen. |
| state / online | Neighbour state (REACHABLE, STALE, …) and whether that counts as up. |
| link_local | Its fe80:: address, kept separate: every host has one and it proves nothing about addressing having worked. |
| registered / hostname / ipv4 / iface | Its DTVSOL client record, when it has one. |
Delegated prefixes that resolve to no MAC are returned in orphan_delegated_prefixes rather than
silently dropped — sync-pd-routes.php cannot route those either, so they are worth seeing.
Also available as /api?action=clients6. CLI: dtvsol clients6 [iface] [--routers].
GET /clients and /clients/active now also carry ipv6_actual,
ipv6_all and prefix6_delegated for the same reason.
add client
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/clients" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF","ip":"100.67.0.25","hostname":"client1","comment":"installed by tech 7"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| mac | String | Client MAC address (any common format, normalized to AA:BB:CC:DD:EE:FF). |
| ip | String | IPv4 address to reserve. Must belong to one of the detected networks. |
| hostname | String | (optional) Hostname for the lease. |
| comment | String | (optional) Free-text note. |
| ipv6 | String | (optional) IPv6 address to reserve as well (DHCPv6). This is the CPE's own WAN address, and it must be in the same interface's network as ip. It does not pin the delegated LAN prefix — that is a separate reservation, see below. |
“Static IPv6” means two different things, and this field is only one of them. A subscriber has an address on the CPE's own WAN port and a prefix the CPE hands out on the customer's LAN. They come from different blocks and are reserved by different calls — setting one has no effect on the other. See Prefix Delegation → WAN address vs LAN prefix for the full table.
delete client
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/clients/AA:BB:CC:DD:EE:FF" -H "X-API-Key: "
Removes the reservation (IPv4 + IPv6) and reloads DHCP.
NETWORKS
list detected networks
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/networks" -H "X-API-Key: "
Networks are auto-detected from the router's interfaces. IPv6 prefix info is merged per interface when present.
EXAMPLE RESPONSE
{
"count": 6,
"networks": [
{
"iface": "vlan108",
"gateway": "100.67.0.1",
"subnet": "100.67.0.0",
"mask": "255.255.255.0",
"cidr": 24,
"network": "100.67.0.0/24",
"bcast": "100.67.0.255",
"ipv6": null,
"gateway6": null
}
]
}
DHCP CONTROL
reload DHCP service
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/dhcp/reload" -H "X-API-Key: "
Regenerates host files and restarts the ISC DHCP service. Returns {"ok": true} on success (HTTP 500 with detail on failure).
Normally not needed — client add/delete reloads automatically.
FIREWALL
Per-client (MAC-based) FORWARD rules on the router.
list MAC rules
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/firewall" -H "X-API-Key: "
QUERY PARAMETERS
| Field | Type | Description |
|---|---|---|
| network | String | (optional) Only rules whose client belongs to this network. |
full FORWARD chain
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/firewall/full" -H "X-API-Key: "
Returns the complete iptables FORWARD chain as seen on the router (diagnostic view).
API PROTECTION
Manages which source networks may reach the API port at all (iptables INPUT rules, applied immediately and re-applied on service start).
list allowed networks
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/protect" -H "X-API-Key: "
allow a network
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/protect" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"network":"10.0.0.0/24","comment":"NOC"}'
remove a network
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/protect" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"network":"10.0.0.0/24"}'
Careful: removing the network you are calling from locks you out of the API (router CLI dtvsol protect add can recover).
INTERFACES
DHCP-serving interfaces
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/interfaces" -H "X-API-Key: "
Interfaces with their detected network, gateway and whether they are currently included in the DHCP configuration.
all system interfaces
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/iface" -H "X-API-Key: "
Raw list of every interface on the router (including ones without IPs), useful before creating VLANs or assigning IPs.
Each entry includes live statistics: status (UP/DOWN), mtu, speed_mbps
(physical links), and traffic counters rx_bytes/tx_bytes.
DHCP NETWORKS
Enable or disable DHCP service (IPv4: /net, IPv6: /net6) on an interface. The subnet
declaration is generated automatically from the interface's address.
add interface to DHCP (IPv4)
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/net" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108","label":"OLT 1 clients"}'
remove interface from DHCP (IPv4)
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/net" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108"}'
add / remove interface for DHCPv6
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/net6" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"svlan2700.2795"}'
Same with DELETE to remove. Adding also configures Router Advertisements (radvd) for the IPv6
prefix — which is what actually gives clients a default gateway, since DHCPv6 has no router option (RFC 8415).
An interface served by DHCPv6 without a radvd stanza hands out addresses that have no route out;
doctor flags exactly that.
Adding also creates the pool: range6 <net>::1000 <net>::ffff, leaving
::1-::fff for the router interface and static reservations, plus
option dhcp6.name-servers inherited from the subnets already configured. The response reports the
pool it created, and warns if no DNS could be inherited. This matters because a
subnet6 with no range6 is accepted by dhcpd6 and then hands out nothing, while radvd
keeps advertising — the client ends up with a default gateway and no address, and nothing appears in any log.
set or replace the pool (net6 pool)
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/net6/pool" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan135"}'
Fills in (or replaces) the range6 of the subnet that iface lives in, and adds
dhcp6.name-servers if the block has none. Pass start and end to override
the defaults; both are validated as being inside the subnet. The config is tested with
dhcpd -6 -t before the service is restarted, and rolled back if it fails.
Also available as /api?action=net6-pool&iface=vlan135.
CLI: dtvsol net6 pool <iface> [start end] — this is what
doctor tells you to run when a subnet has no pool.
On DELETE, the subnet6 block is only removed when no other interface is still
being served in that network. Two interfaces can legitimately share a prefix, and the block is keyed by
network alone — so removing one interface's "own" subnet would otherwise delete the block the other one still
needs, silently taking its DHCPv6 (pool included) down.
address lifetimes (net6 lease) NEW
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/net6/lease" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"valid":7200,"preferred":3600}'
How long a DHCPv6 address and its delegated prefix stay valid. A box built from the Debian sample
ships default-lease-time 2592000 — thirty days. Nothing expires for a
month, so a CPE that is unplugged keeps both its address and its prefix in
dhcpd6.leases, and sync-pd-routes.php faithfully keeps a route pointing at a next
hop that is gone. If deleted IPv6 configuration seems to linger, this is the mechanism that actually does
it.
preferred is optional and defaults to half of valid; renew (T1) and rebind (T2)
are derived at the RFC 8415 defaults. radvd's AdvValidLifetime /
AdvPreferredLifetime are regenerated from the same numbers, so a prefix is never advertised as
on-link for far longer than the lease that assigns addresses out of it — the state that reads as
“it has IPv6 but nothing works” long after RAs for that network stopped.
Existing leases keep their old lifetime until the client next renews. dtvsol doctor warns
while the valid lifetime is above a day. Also /api?action=net6-lease&valid=7200;
CLI: dtvsol net6 lease <valid> [preferred].
resolvers and search domain (net6 dns)
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/api?action=net6-dns&servers=2001:db8::53,2001:db8::54&clear_domain=1" -H "X-API-Key: "
Sets dhcp6.name-servers — and optionally dhcp6.domain-search — at global scope,
which is where every DHCPv6 client inherits them from. Pass domain=example.net to set a search
domain, or clear_domain=1 to remove it entirely.
Worth checking on any box built from the Debian sample: the stock
dhcpd6.conf ships with option dhcp6.domain-search "test.example.com","example.com";,
and unless it was removed every subscriber is being handed it. Addresses in the documentation range
(2001:db8::/32) and the decommissioned 6bone (3ffe::/16) are refused as resolvers,
since serving either is always a misconfiguration. The config is tested with dhcpd -6 -t and
rolled back if it fails.
VLANS / QinQ
Provision 802.1Q or 802.1ad (QinQ) VLAN interfaces. Created VLANs persist, and an address given here is
served straight away — see serve below.
list VLANs
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/vlans" -H "X-API-Key: "
add VLAN
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/vlans" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"parent":"ens5f0","vlan_id":2700,"protocol":"802.1ad","ip":"100.67.5.1/24","label":"OLT 5"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| parent | String | Parent interface (e.g. ens5f0) or existing S-VLAN for QinQ inner tag. |
| vlan_id | Integer | VLAN ID (1-4094). |
| protocol | String | (optional) 802.1Q (default) or 802.1ad for the outer QinQ tag. |
| ip | String | (optional) IPv4 address/CIDR to assign to the new VLAN interface. |
| ipv6 | String | (optional) IPv6 address/prefix to assign. |
| label | String | (optional) Free-text label stored in the VLAN database. |
| force | Boolean | (optional) Allow ip/ipv6 to overlap a subnet that is already on another interface. Off by default — see the validation table under Interface IPs; the same 400/409 rules apply here and are checked before the interface is created, so a rejected request leaves nothing behind. |
| serve | Boolean | (optional, default on) Configure DHCP for the addresses given. Set 0 for a bare link — an uplink or transit VLAN, which has no subscribers to serve. |
An address passed to vlan add is an address meant to be served, so it is.
With ip set the IPv4 subnet goes into dhcpd.conf; with ipv6 set the
subnet6, its range6, the radvd stanza and a prefix-delegation slice are all
created too. Creating the link and stopping there is what made this a four-command ritual
(vlan add, net add, net6 add, pd add) and what left a
VLAN's DHCP configuration reading null until somebody remembered the other three.
Each step is reported separately under served, and a failure never unwinds the ones before it
— an IPv6 problem must not take a working IPv4 subnet down with it. When a step does fail the response
carries a warning naming it, plus the single command that retries it. The link and its
addresses are real and saved either way.
"served":{"net":{"network":"100.67.99.0/24"},"net6":{"network":"2803:1840:1000:199::/64","pool":"2803:1840:1000:199::1000 - …::ffff"},"pd":{"pool":"2803:1840:1001:3000:: - 2803:1840:1001:30ff::","capacity":256}}
disable / enable a VLAN NEW
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/vlans/disable" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"vlan108","reason":"OLT maintenance"}'
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/vlans/enable" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"vlan108"}'
The middle ground between creating and deleting a VLAN. An OLT goes down for maintenance, a reseller stops paying, a segment is being migrated — delete is the wrong tool for all three, because it releases things: the delegation range returns to the pool, the subnet blocks go, the NAT pool is forgotten. Rebuilding the VLAN afterwards hands the same subscribers different prefixes.
Disable releases nothing.
| Taken off the air | Kept, untouched |
|---|---|
the link (ip link set … down) — all traffic stops immediately | the prefix-delegation range, still reserved in pd-pools.json |
| the DHCPv4 / DHCPv6 listen entries | the subnet and subnet6 blocks |
| the radvd stanza | nat.json, portforward.json, routes.json |
the NAT POSTROUTING rules and port-forward DNAT rules | every client reservation on the VLAN |
| its routes, withdrawn from the kernel | its addresses, recorded and re-applied on enable |
enable puts all of it back, and the state afterwards is identical to the state before —
same prefixes to the same subscribers. Only the serving that was actually on is restored: a VLAN created
with serve=0 comes back as a bare link, not silently promoted.
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| name | String | The VLAN to switch off or on. |
| reason | String | (optional, disable only) Free text stored with the timestamp and shown in vlan list and the log. |
Unlike delete, this is allowed with clients still on the VLAN — that is the point of it. Refused with 409 in three cases: the VLAN still has an enabled child (taking a parent down takes its children with it while they still read as up), CGNAT translates through it, or you are enabling a child whose parent is still off.
The state survives a reboot: the generated boot script creates a disabled VLAN and leaves it down and
bare, and apply-routes.php skips its routes ("2 route(s) applied, 1 skipped (VLAN
disabled)"). It is left bare deliberately — the kernel flushes global IPv6 addresses the
moment a link drops, so the addresses are re-applied from vlans.json on enable rather than
assumed to have survived.
doctor knows about it too: rather than reporting the half-dozen things that
are deliberately not applied, it stays silent and prints one info line naming the disabled
VLANs. In dtvsol vlan list they show as DISABLED, dimmed rather than red —
a VLAN somebody switched off is not a fault.
Also /api?action=vlan-disable&name=…&reason=… and
/api?action=vlan-enable&name=…. CLI:
dtvsol vlan disable <name> [reason] / dtvsol vlan enable <name>.
delete VLAN
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/vlans" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"svlan2700.2795"}'
Refused with 409 if another VLAN uses it as a parent, if registered clients are still on it, or if
CGNAT translates through it — that last one would take public connectivity away from
every subscriber behind the NAT with nothing in the CGNAT config to explain why, so it has to be pointed
at another interface first. Deleting a VLAN
removes the link, its vlans.json entry and its line in the generated boot script — and also cleans
up everything that would otherwise keep pointing at the gone interface: its entries in
INTERFACESv4/v6, its radvd stanza, and any subnet/subnet6 block for its
networks (reported in removed_subnets). A block is kept if another interface is still served in
that network.
It also clears every other database that used to outlive the interface, each reported in the response so nothing is removed invisibly:
| Field | What it clears, and why it mattered |
|---|---|
| removed_subnets | The subnet / subnet6 blocks for its networks. Kept if another interface is still served in that network. |
| removed_pd_pool | Its prefix-delegation range in data/pd-pools.json. A surviving entry held a slice out of the pool for nothing — and was handed straight back to the next VLAN that reused the name. |
| removed_routes | Static routes in data/routes.json that left through the VLAN or had their next hop in one of its networks. apply-routes.php replays that file at boot, so a leftover is a route that tries to come back on every reboot and fails, while still reading as live configuration in dtvsol route list. |
| removed_nat | Its dynamic NAT pool in data/nat.json and the DTVSOL_NAT_<iface> POSTROUTING rules. -o <gone-iface> never matches, so this broke nothing loudly — the pool simply sat there looking live, and came back into force if that interface name was ever recreated. |
| removed_forwards | Port forwards in data/portforward.json whose client_ip was inside one of its networks, and their DNAT rules. A forward is keyed by client IP, not by interface, so nothing connected it to the VLAN — and a live DNAT into an empty network blackholes that public port rather than refusing it. |
| kept | Traffic history in data/rrd/iface_<name>.rrd is deliberately not deleted: it is the only copy, and a VLAN name coming back usually means the same VLAN being rebuilt. Reported here rather than decided silently — remove it by hand if it is not wanted. |
Also cleaned, as before: the link, the vlans.json entry and the generated boot-script line, the
INTERFACESv4/v6 entries and the radvd stanza. Anything that slips through any of this is
reported by doctor.
INTERFACE IPS
add IP to interface
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/ip" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108","ip":"100.67.9.1/24"}'
remove IP from interface
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/ip" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108","ip":"100.67.9.1/24"}'
Addresses are persisted to data/vlans.json and re-applied at boot, so they survive a reboot.
Adding an address that is already on the interface is not an error: it is recorded (HTTP 200 instead
of 201, with persisted in the response), which is how an address configured by hand can be brought
under management.
VALIDATION
| Code | Refused when |
|---|---|
| (accepted) | Giving the segment is fine and is the normal way to do it: 2803:1840:1000:910::/64 or 100.67.9.0/24 assigns ::1 / .1 to the interface. The response carries requested, assigned and a note saying so, so a script always sees what actually happened. IPv4 /31, /32 and IPv6 /127, /128 are left alone (RFC 3021 / RFC 6164 use every address). |
| 400 | The address is the broadcast address of its prefix, e.g. 100.67.9.255/24. Unlike a network address that is not a way of naming the segment, it is a typo, so it is refused rather than guessed at. Not overridable. |
| 409 | The address overlaps a subnet already on another interface. Two interfaces in one subnet duplicate routes and make ARP/ND ambiguous. Compared at the shorter of the two prefix lengths, so a /25 inside a /24 is caught too, not only an exact duplicate. The response includes conflict naming the other interface and address. Add "force":true (or &force=1) for the deliberate cases. |
A DELETE that leaves a subnet/subnet6 block with no interface serving it
reports the block in warning + unused_subnet but does not remove it —
removing an address is often half of a re-addressing, and these blocks can hold hand-tuned options. Remove it
deliberately with /net / /net6 DELETE, or check with
doctor.
ROUTES V4/V6
Static route management for both IPv4 and IPv6 (family auto-detected from the prefix).
Managed routes are persisted in data/routes.json and re-applied automatically at boot,
after the VLAN interfaces are created.
list routes
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/routes" -H "X-API-Key: "
Returns the DTVSOL-managed routes plus the live kernel routing tables (live_ipv4, live_ipv6) for comparison.
add route
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/routes" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"prefix":"2001:db8:100::/48","via":"2001:db8::2","comment":"customer prefix"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| prefix | String | Destination in CIDR (10.50.0.0/24, 2001:db8::/48) or default (default route; requires via). |
| via | String | (optional*) Gateway address — must match the prefix family (IPv4 via for IPv4 prefix, IPv6 for IPv6). |
| dev | String | (optional*) Output interface. *At least one of via/dev is required. |
| src | String | (optional) Preferred source address for traffic the router itself originates. Needed when the outgoing interface holds both a transit address and a routed public address: without it the kernel picks the address in the nexthop's subnet, which is typically the unroutable transit one, and everything the router originates (updates, DNS, git) fails. Emitted for add/replace but not for del, since src is not a route selector. |
| comment | String | (optional) Free-text note. |
The route is applied immediately (ip route replace) and persisted for boot. Errors: 400 invalid prefix/family mismatch/unknown interface, 409 already managed, 500 kernel rejected the route.
delete route
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/routes" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"prefix":"2001:db8:100::/48"}'
Optionally include via/dev to disambiguate when several managed routes share a prefix. Removes the route from the kernel and from persistence. Only DTVSOL-managed routes can be deleted (404 otherwise).
CLI equivalent
dtvsol route list | dtvsol route add <prefix|default> [via <gw>] [dev <iface>] [comment] | dtvsol route del <prefix>
DNS / RESOLVERS
What name servers subscribers are handed, on IPv4 and IPv6. A subnet that declares none gives the CPE
0.0.0.0 — the customer has a working address and a gateway and cannot
resolve anything, which in the field reads as “the internet is down”.
Scope is the whole point of this being one command. In ISC dhcpd a global
option domain-name-servers is inherited by every subnet that does not declare its own, so a
single call fixes every VLAN at once; a line inside a subnet overrides it for that VLAN alone.
Both are managed here, and dtvsol dns tells you which one each VLAN is actually using.
what is being served right now
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/dns" -H "X-API-Key: "
Returns the global values for both families, every per-VLAN override, the effective
resolver for each served VLAN with ipv4_source / ipv6_source telling you whether
it came from the global or an override — and serving_no_resolver, the list of VLANs
handing out nothing. That last field is the direct answer to a CPE showing 0.0.0.0.
set the resolvers everyone inherits
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/dns" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"v4":"8.8.8.8,1.1.1.1","v6":"2803:1840:100:100:53::1,2803:1840:100:100:53::2","domain":"example.net"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| v4 | String / array | (optional) IPv4 resolvers, comma-separated or a JSON array. Written as the global option domain-name-servers. |
| v6 | String / array | (optional) IPv6 resolvers. Written as the global option dhcp6.name-servers; reuses the existing IPv6 path, validation and rollback included. |
| domain | String | (optional) Search domain — option domain-name on IPv4 and option dhcp6.domain-search on IPv6. |
| apply | String | (optional) all or missing — see the table below. Omit to change only the global. |
| apply | Effect on VLANs that already exist |
|---|---|
| omitted | Only the global changes. A VLAN with resolvers of its own keeps them. |
all | One default for every VLAN, old and new. Drops each VLAN's own copy so it inherits the global. Discards deliberate per-VLAN overrides too, which is why it must be asked for. |
missing | Only fills in VLANs that would otherwise serve nothing. |
Why apply=all exists. On IPv4 a global is inherited by every subnet, so it is
already a default for old and new VLANs alike. On IPv6 it was not: each subnet6 used to be
created carrying a frozen copy of the global, which then shadowed it — so changing the
global reported success and reached nobody, and every new VLAN recreated the problem. New blocks now
inherit, and apply=all clears the copies from VLANs created before that. Setting
v6 without it warns and names any VLAN the change will not reach.
Pass whichever you need; the two families hold different addresses but the intent is one thing,
which is why they share a verb. Both files are validated with dhcpd -t and rolled back if the
edit does not parse or the service will not restart.
Refused: 0.0.0.0, broadcast, loopback and multicast on IPv4;
::, ::1 and the documentation / decommissioned ranges
(2001:db8::/32, 3ffe::/16) on IPv6 — ISC's stock configuration ships a dead
6bone resolver, and serving it to a subscriber resolves nothing.
give one VLAN its own resolvers
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/dns/vlan135" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"v4":"181.10.20.5,181.10.20.6"}'
Writes the option inside that VLAN's subnet / subnet6 block, which wins over the
global. For a reseller or a segment that must resolve against its own servers. Drop it again with
DELETE /dns/vlan135 and the VLAN inherits the global values once more.
clear the global again
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/dns" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"global":"v4"}'
Removes the global declaration so nothing is served unless a VLAN declares its own. Per-VLAN overrides
are left alone — clearing the default is not the same as taking DNS away from a VLAN that has its
own. global must be one of v4, v6, domain or
all: there is deliberately no default, because an implicit
all will also remove IPv6 resolvers that were set long before and that the caller did not
mean to touch. CLI: dtvsol dns del global <v4|v6|domain|all>.
CLI equivalent
dtvsol dns | dtvsol dns set v4=<a,b> [v6=<c,d>] [domain=<n>] | dtvsol dns set <iface> v4=<a,b> | dtvsol dns del <iface>
dtvsol dns with no arguments prints the table and highlights in red any VLAN serving nothing.
Also /api?action=dns-list, dns-set (add &iface= for an override)
and dns-del. The older net6 dns still works and is IPv6-only.
doctor reports any served VLAN with no IPv4 resolver, and any per-subnet override that cannot answer.
PREFIX DELEGATION (DHCPv6 IA_PD)
A CPE asks for two things at once: an address for its own WAN side (IA_NA) and a
prefix to hand out on its LAN (IA_PD). A subnet6 that only has a
range6 serves the first and ignores the second, and the symptom is easy to miss:
the customer router shows a valid IPv6 address and gateway, so nothing looks broken — but the
installer has to type a Site Prefix into every CPE by hand, and an operator has to add a
matching static route back on the BNG for every single subscriber.
With delegation enabled the CPE is handed its LAN prefix automatically, and the route back to it is derived from the lease. Nothing is typed by hand at either end.
The CPE must be set to request it — on TP-Link XPON units that is Site Prefix Type = Delegated (not Static). A CPE left on Static will keep working off its hand-typed prefix and will never ask for one.
WAN address vs LAN prefix — the two “static IPv6”s
This is the single most common source of confusion, so it is worth being exact. Every subscriber has two IPv6 allocations, out of two different blocks, reserved by two different calls. Neither one implies the other.
| WAN address (IA_NA) | LAN prefix (IA_PD) | |
|---|---|---|
| What it is | The address on the CPE's own WAN port — how the BNG reaches the router itself. | The block the CPE hands out to the customer's PCs, phones and TVs. |
| Which block | The VLAN's own /64, e.g. 2803:1840:1000:108::/64. | The delegation pool, e.g. 2803:1840:1001::/48 — a different block entirely. |
| Reserve it with | POST /clients with ipv6 · dtvsol clients add … --ipv6 <addr> | POST /pd/assign · dtvsol pd assign <mac> [prefix] |
| Written as | fixed-address6 in the host reservation. | fixed-prefix6 in the same host reservation. |
| Left unset, it comes from | The VLAN's range6 pool. | The VLAN's slice of pd_pool. |
Because they are independent, all four combinations are valid and useful:
| WAN | LAN prefix | When to use it |
|---|---|---|
| dynamic | dynamic | The normal mass-residential case. Nothing is configured per subscriber at either end — the whole point of delegation. |
| static | dynamic | You want a predictable address to reach the CPE itself (monitoring, remote support). The customer's LAN network may still change. |
| dynamic | pinned | The customer's LAN network must never change — a business running servers or firewall rules against it. |
| static | pinned | Both fixed. Typical for a business circuit. |
A reservation carrying both looks like this in /etc/dhcp/dtvsol-hosts6.conf — note that only
the second line is delegation:
host acme-v6 {
hardware ethernet AA:BB:CC:00:11:22;
fixed-address6 2803:1840:1000:108::20; # WAN — from POST /clients
fixed-prefix6 2803:1840:1001:1::/64; # LAN — from POST /pd/assign
}
Note where the pinned prefix comes from. It is
2803:1840:1001:1::/64, not something inside that VLAN's slice — pins are taken from the
reserved band at the front of the pool (pd_reserve), which every per-VLAN slice deliberately
skips. That is what guarantees a pinned prefix can never also be handed out dynamically to
somebody else.
Reserving the WAN address also makes the route more stable.
sync-pd-routes.php prefers the reserved address from clients.json as the next hop
for that subscriber's delegated prefix, falling back to the lease only when there is no reservation — so
the route survives the address lease being rewritten. Worth doing for a customer you care about,
unnecessary for mass residential.
One thing that is not configured here: whether the CPE asks for a prefix at all. That is a setting in the customer's router — Site Prefix Type = Delegated, not Static — and it is normally pushed by the ONU profile rather than set per install. A CPE left on Static keeps using its hand-typed prefix and never asks, which shows up as an empty delegated-prefix column in /clients6.
how the pool is divided
Delegated prefixes are carved out of pd_pool in etc/config.php. Each
subnet6 gets its own non-overlapping slice — two subnets sharing a
prefix6 range would eventually hand the same prefix to clients on different VLANs — and
slices are allocated once and recorded in data/pd-pools.json rather than recomputed.
Slices are placed by address range, not by fixed-width slot: a slice's position is read
back out of its recorded start address, and a new one goes in the lowest free gap. That is what makes
pd_slice safe to change — widths mix freely, and narrowing it never renumbers a VLAN
that is already delegating. A single VLAN that needs more than the default can simply be given more,
without disturbing any other.
| Setting | Meaning |
|---|---|
| pd_pool | The block delegated prefixes come from, e.g. 2803:1840:1001::/48. Your upstream must route it to this box. |
| pd_len | Size of each subscriber's prefix. 64 gives the CPE exactly one LAN; 60 or 56 let it subnet further (guest/IoT). Every pool boundary is computed from this, so it is the only value to change. |
| pd_slice | Prefixes reserved per VLAN, and therefore the number of subscribers it can serve. Pool size ÷ pd_slice is the maximum number of subscriber VLANs. Now 256, giving 240 VLANs out of a /48 at /64; it was 4096, which left room for only 15 — fewer VLANs than a BNG of this size carries. |
| pd_reserve | Prefixes held back at the front of the pool for pinned assignments, so a pinned prefix is never also handed out dynamically. |
list delegation state
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/pd" -H "X-API-Key: "
Returns the pool and prefix length, the per-VLAN slices, every pinned prefix, and the live delegated
routes currently installed in the kernel. capacity answers the sizing question directly:
prefixes total / reserved / used / free, the largest free run, and — the number usually wanted —
vlans_at_default_slice and vlans_free_at_default_slice.
enable delegation on a VLAN
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/pd" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108"}'
Allocates the interface a slice, writes the prefix6 statement into its
subnet6 block, validates with dhcpd -6 -t and restarts DHCPv6 —
rolling the file back if either step fails, so a bad edit cannot take DHCPv6 down for
the subscribers already online. Errors: 404 no IPv6 on the interface or no subnet6 block
(run dtvsol net6 add first), 507 pool exhausted.
Pass an optional size to give this VLAN a slice other than pd_slice (a power of
two, e.g. {"iface":"vlan108","size":1024}). A 507 response reports the largest free run left,
so it is clear whether the pool is full or merely fragmented.
resize a VLAN's slice NEW
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/pd/resize" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108","size":1024}'
Changes how many subscribers a VLAN's slice holds without deleting the delegation. If the range has to move
to fit, the response carries replaced and a warning: CPEs already delegated out of the old
range keep their prefix until the lease expires, then pick one up from the new range on renew. Run
dtvsol pd sync afterwards, or let the timer do it. CLI:
dtvsol pd resize <iface> <size>.
disable delegation on a VLAN
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/pd" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan108"}'
Removes the prefix6 statement and releases the range back to the pool by
dropping the interface's entry from data/pd-pools.json. Routes for prefixes already delegated
are withdrawn as their leases expire. The same release happens on net6 del and on
vlan del — a pool entry that outlives its interface holds a range out of the pool for
nothing, and would be handed straight back to the next VLAN that reused the name.
pin a subscriber's prefix
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/pd/assign" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF","prefix":"2803:1840:1001:5::/64"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| mac | String | Client MAC. Must already exist as a DTVSOL client. |
| prefix | String | (optional) The prefix to pin. Omit it and the next free prefix from the reserved band is taken automatically. Must be inside pd_pool and match pd_len. |
Writes fixed-prefix6 into the host reservation, so the subscriber's LAN network survives
lease renewals and CPE swaps. Use this when migrating a customer who already has a hand-typed site
prefix: pin the prefix they are running on, and switching the CPE to Delegated changes nothing
they can see. Errors: 404 unknown client, 400 outside the pool or wrong length, 409 already pinned to
another client, 507 reserved band full.
Release a pin with DELETE /pd/assign and body {"mac":"AA:BB:CC:DD:EE:FF"}.
reconcile routes now
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/pd/sync" -H "X-API-Key: "
Add ?dry=1 to see what would change without touching the routing table.
Why this exists: ISC dhcpd hands a CPE a prefix and then does nothing else with it —
it never installs a route. bin/sync-pd-routes.php reads the lease database, works out which
CPE holds each live prefix (by pulling the MAC out of the client DUID, falling back to correlating the
address lease for DUID forms that carry no MAC), and makes the kernel match. Routes it installs are
tagged proto dhcp, which is what lets stale ones be withdrawn without ever touching the
operator's static routes in data/routes.json.
It runs automatically: dtvsol-pd-routes.path fires within a second of dhcpd writing a
lease, and dtvsol-pd-routes.timer is the safety net that repairs anything missed while the
box was down and withdraws expired prefixes. Both must be running — dtvsol doctor reports
it as critical if prefixes are being delegated while the reconciler is not, because those subscribers
would be unreachable with nothing else to flag it.
CLI equivalent
dtvsol pd list | dtvsol pd add <iface> [size] | dtvsol pd resize <iface> <size> | dtvsol pd del <iface> | dtvsol pd assign <mac> [prefix] | dtvsol pd unassign <mac> | dtvsol pd sync [--dry-run]
DYNAMIC NAT
Dynamic source NAT (SNAT) pools per WAN interface. Traffic leaving the interface is translated to a
pool of public IPs (or a single IP) with per-flow port randomization
(--random-fully) and client stickiness (--persistent — the same client always maps
to the same pool IP). Exempt networks (clients with real public addresses) and the router's own traffic
bypass NAT. Rules are tagged DTVSOL_NAT_<iface>, persisted with iptables and restored at boot.
list NAT pools
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/nat" -H "X-API-Key: "
Returns managed pools plus the live POSTROUTING chain for comparison.
configure NAT pool
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/nat" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan601","pool_start":"204.199.171.17","pool_end":"204.199.171.30","exempt":["200.215.228.192/29"],"comment":"clients NAT"}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| iface | String | WAN (output) interface the NAT applies to. |
| pool_start | String | First public IP of the SNAT pool. |
| pool_end | String | (optional) Last pool IP — omit for single-IP NAT. |
| exempt | Array|String | (optional) Source networks (CIDR) that bypass NAT — array or comma-separated string. |
| comment | String | (optional) Free-text note. |
Re-POSTing for the same interface replaces its pool configuration (rules are rebuilt atomically).
remove NAT pool
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/nat" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"iface":"vlan601"}'
Removes every DTVSOL-tagged NAT rule for that interface. Unmanaged (hand-made) NAT rules are never touched.
CLI equivalent
dtvsol nat list | dtvsol nat add <iface> <pool_start> [pool_end] [exempt <cidr,cidr>] [comment] | dtvsol nat del <iface>
PLANS / QoS
Bandwidth plans (down/up Mbps) applied per client with tc HTB shaping — download on the client
VLAN egress, upload via an IFB mirror of the ingress. A client with no plan is unlimited
(no shaper, line rate); that is the default. Assign or clear a plan at any time; shaping rebuilds live and
is re-applied at boot.
list plans
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/plans" -H "X-API-Key: "
Each plan includes a live clients count.
create / update plan
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/plans" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"basico","down_mbps":20,"up_mbps":5,"comment":"20/5 residential"}'
Posting an existing name updates it (and re-shapes its clients). Fields: name (alphanumeric/_-),
down_mbps, up_mbps (1..100000), comment.
delete plan
/usr/bin/curl -ss -X DELETE "http://ROUTER-IP:8880/plans" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"basico"}'
Refused with 409 if any client still uses the plan (reassign them first).
assign a plan to a client
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/plan" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF","plan":"basico"}'
Use "plan":"none" (or empty) to remove shaping and make the client unlimited again.
Clients can also be created with a plan directly: add "plan":"basico" to the POST /clients body.
CLI equivalent
dtvsol plans list|add|del | dtvsol clients plan <mac> <plan|none>
SUSPEND / RESUME
Cut a client's traffic (billing/abuse) with a firewall DROP in both directions, tagged
DTVSOL_SUSP_<mac>. The suspended state is persisted and re-applied at boot.
suspend
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/suspend" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF"}'
resume
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/resume" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF"}'
CLI: dtvsol clients suspend <mac> / dtvsol clients resume <mac> (or dtvsol suspend|resume <mac>).
TRAFFIC GRAPHS
Per-client and per-interface traffic is sampled every minute into RRD databases. This endpoint returns a
ready PNG graph (download vs upload, bits/s) — embed it directly with <img>.
get a graph (PNG)
/usr/bin/curl -ss "http://ROUTER-IP:8880/graph/AA:BB:CC:DD:EE:FF?period=day" -H "X-API-Key: " -o graph.png
The target is a client MAC or an interface name
(e.g. /graph/vlan108, /graph/eno5). period = hour,
day (default), week, month or year. Returns
image/png (404 until the first samples are collected). CLI:
dtvsol graph <mac|iface> [period] [outfile.png].
SNMP / MONITORING
There are three complementary ways to get traffic out of the router:
| Channel | Direction | Use for |
|---|---|---|
| Traffic Graphs (above) | you pull a PNG | quick look, no external system |
| SNMP | an NMS polls the router | central per-interface + per-client graphing across the fleet |
| SMTP email reports | router pushes email | scheduled summaries (roadmap — Phase 2) |
The built-in SNMP agent lets any NMS (LibreNMS, Zabbix, Cacti, Observium, PRTG) poll the router:
- Interfaces — native
IF-MIB(ifHCInOctets/ifHCOutOctets, 64-bit). Every VLAN and WAN port is graphed by the NMS out of the box. - Per-client traffic — a custom subtree at
.1.3.6.1.4.1.8072.9999:.1.0= client count, and a table at.2.1.<col>.<i>with columns1=idx,2=mac,3=ip,4=hostname,5=rx bits/s,6=tx bits/s,7=rx bytes,8=tx bytes. Values refresh every minute.
Walk it:
snmpwalk -v2c -c <community> ROUTER-IP .1.3.6.1.4.1.8072.9999
SNMP status
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/snmp" -H "X-API-Key: "
Reports the agent state, sys location/contact, the per-client OID base and a live sample.
configure SNMP
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/snmp" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"community":"dtvsol","location":"Lab BNG","contact":"noc@dtvsol.com"}'
Sets the read-only community and system info, regenerates snmpd.conf and enables/restarts the agent.
CLI: dtvsol snmp status / dtvsol snmp set community <c> location <l> contact <ct>.
Security note: SNMP is read-only (community-based). Restrict UDP/161 to your NOC networks at the edge, as the community is not a strong secret.
CGNAT
Carrier-grade NAT with deterministic port blocks and a logged mapping — the compliance-safe
way to put many subscribers behind a few public IPs. Each subscriber private IP is assigned a fixed
(public IP + port range) block; the mapping is stored (data/cgnat.json) and appended to an
audit log, so you can legally answer "who held public IP X port Y at time Z" without per-flow logging.
This is separate from Dynamic NAT (random pool, no determinism) — use CGNAT when you must trace subscribers.
status
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/cgnat" -H "X-API-Key: "
Shows pool, port-block size, subscribers-per-IP, capacity, how many are assigned/free, and a sample of the mappings.
configure
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/cgnat" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"enabled":true,"iface":"eno6","pool":"204.199.171.17-204.199.171.30","block_size":2048,"exempt":["200.215.228.192/29"]}'
| Field | Type | Description |
|---|---|---|
| enabled | Bool | Turn CGNAT on/off. |
| iface | String | WAN (output) interface. |
| pool | String|Array | Public IPs — range (a-b), list or array. |
| block_size | Int | Ports per subscriber (default 2048 → ~31 subs/IP over 1024-65535). |
| port_min / port_max | Int | Port window (default 1024..65535). |
| exempt | Array | Networks with real public IPs that bypass NAT (CIDR). |
compliance reverse lookup
/usr/bin/curl -ss "http://ROUTER-IP:8880/cgnat/lookup?public_ip=204.199.171.17&port=5000" -H "X-API-Key: "
Returns the subscriber (MAC, private IP, hostname) whose block owned that public IP + port. CLI:
dtvsol cgnat status | set enable iface <i> pool <range> block <n> exempt <cidr> | lookup <ip> <port>.
PORT FORWARDING
Inbound DNAT — publish a public IP:port to a client's private IP:port (DVR, CCTV, game server…). Persisted and reapplied at boot.
list / add / delete
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/portforward" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"proto":"tcp","public_port":8443,"client_ip":"100.67.1.10","client_port":443,"public_ip":"","comment":"cam"}'
public_ip optional (empty = any of the router's addresses). GET /portforward lists,
DELETE with {proto, public_port, public_ip} removes. CLI:
dtvsol portforward list | add <tcp|udp> <pub_port> <client_ip> <client_port> [pub_ip] [comment] | del ….
SERVICE END DATE
Give a client an end date; when it passes, the router auto-suspends the client (firewall cut). When billing registers a payment it simply sets a new future end date (or calls resume) and the client is auto-resumed. Reconciled immediately on change and every 5 minutes by cron.
set end date
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/expires" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mac":"AA:BB:CC:DD:EE:FF","expires":"2026-08-31"}'
expires = YYYY-MM-DD / YYYY-MM-DD HH:MM, or never to clear.
A client may also be created with "expires" in the POST /clients body. Manual
suspend/resume is independent of the auto end-date logic.
CLI: dtvsol clients expires <mac> <date|never>.
Billing model: non-payment → end date lapses → auto-suspend; payment → billing pushes a new end date → auto-resume. That is the only thing that stops a client automatically.
DHCP OPTION 82
Relay agent information (circuit-id / remote-id) identifies the subscriber's OLT and PON port — anti-fraud and physical location.
read
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/option82" -H "X-API-Key: "
Reports circuit/remote-id per lease IP (when the relay adds option 82), whether capture logging is enabled, and recent capture log lines.
enable capture
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/option82" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"enable":true}'
Adds a validated on commit log directive to dhcpd (auto-reverted if this dhcpd build rejects it — reads still work). CLI: dtvsol option82 [enable|disable].
DOCTOR
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/doctor" -H "X-API-Key: "
Also reachable as /api?action=doctor — both forms are supported, like every other endpoint on this page.
A full audit of the router's configuration consistency and, just as importantly, whether that configuration would survive a reboot. This is different from alerts, which reports live runtime conditions (service down, pool nearly full, unregistered devices). Doctor answers a question nothing else on the box answers: is what is running actually written down anywhere, and is it internally consistent?
CLI: dtvsol doctor (alias dtvsol check) — prints the findings grouped by area, coloured
by severity, each with a suggested fix command.
WHAT IT CHECKS
| Area | Checks |
|---|---|
| persistence | An address is live but recorded in neither data/vlans.json nor /etc/netplan (it disappears on reboot) · an address is recorded but not live · a VLAN's parent interface is not created by any persistent configuration — if a bond or physical parent exists only in the running kernel, setup-qinq-vlans.sh runs under set -e and its first ip link add link <parent> aborts every VLAN, address and route after it · the live default route is missing from data/routes.json · a managed route was never applied. |
| addressing | An interface is addressed with its own network or broadcast address (e.g. 100.67.1.0/24, or the IPv6 subnet-router anycast …::/64) — DHCP then advertises an unusable gateway · two interfaces sit in the same subnet, which duplicates routes and makes ARP/ND ambiguous. |
| dhcp | INTERFACESv4/v6 names an interface that does not exist · a served interface has no subnet/subnet6 block · a block is orphaned (nothing serves it) · a subnet6 has no range6, so clients get a gateway but never an address · option routers is not a usable host address inside its own subnet. |
| dhcp (DNS) — IPv4 | A VLAN served by DHCP with no IPv4 resolver declared at all, globally or per subnet — the CPE shows Primary/Secondary DNS 0.0.0.0 and the customer has connectivity with no name resolution. This check did not exist before v2.4, which is why the condition went unreported on every subnet this box served. Also: a per-subnet override that is null, loopback or multicast. |
| dhcp (DNS) — inherit | A subnet6 with no dhcp6.name-servers and no global to inherit. A block without its own line is the normal, correct state once a global is set — the check only fires when there is nothing to fall back on. |
| dhcp (DNS) | A subnet6 whose dhcp6.name-servers points into a documentation or decommissioned range — 3ffe::/16 was the 6bone, 2001:db8::/32 is reserved — so clients get an address and can resolve nothing. Checked for the global option too, because ISC ships one as an example and it is active: every subnet6 that does not set its own name-servers inherits it. Also warns when a subnet6 has no name-servers at all. |
| ipv6-gateway | An interface is served by DHCPv6 but radvd does not advertise on it. Clients then obtain an address and have no default route — and because DHCPv6 has no router option at all (RFC 8415), only Router Advertisements can supply one. This failure is invisible from the server otherwise. Also: radvd still advertising on an interface that no longer exists. |
| services | Any of radvd, isc-dhcp-server, isc-dhcp-server6, dtvsol-api, dtvsol-vlans not active · systemd-networkd-wait-online in failed state, which stalls network-online.target — and dtvsol-vlans.service waits on that target, so subscriber VLANs, routes and DHCP come up late on every boot. |
| delegation | pd-pools.json reserves a range for an interface that no longer exists · a VLAN's recorded delegation network no longer matches the address it holds (it was re-addressed) · a recorded range falls outside pd_pool · two VLANs delegating out of overlapping ranges, which can hand the same prefix to CPEs on different VLANs and lets only one of the two routes exist · an interface served by DHCPv6 with no delegation at all, so its CPEs need a site prefix typed in by hand · the DHCPv6 valid lifetime is longer than a day, which is how a dead CPE's prefix and route survive for weeks. |
| nat | nat.json keeps a dynamic NAT pool on an interface that does not exist — harmless today, live again the moment that name is recreated · a port forward points at an address that is on no network on this box, so its DNAT rule blackholes that public port · CGNAT translates through an interface that does not exist (critical) — every subscriber behind the NAT is without public connectivity. |
| persistence (disabled VLANs) | A VLAN switched off with vlan disable is reported once as info and then skipped by every other check — its bare interface, unapplied routes, held delegation range, withdrawn NAT rules and suppressed forwards are the state that was asked for, not faults. Without this, disabling one VLAN buried the report in findings describing exactly what the operator intended. |
| routes | routes.json keeps a route on an interface that does not exist — the one cause that re-applying can never fix, so apply-routes.php fails on it at every boot until the entry is removed. |
| clients | A registered client is pinned to an interface that does not exist · a registered address is being answered by a different MAC — a replaced CPE never re-registered, or an address being spoofed. |
| antispoof | Anti-spoofing is off (info) · it is on but a chain, a jump or the ARP table is missing (critical — the configuration says protected while every VLAN is open) · the rules are behind the current bindings, or dtvsol-antispoof.path/.timer is not active · a served VLAN is excluded by an override. |
RESPONSE
Each finding carries a severity (critical = broken now or on the next boot,
warning = latent or leftover, info), an area, a one-line
problem, a detail explaining the consequence, and a fix command.
Top level: counts per severity and ok, which is true only when there
are no criticals.
{"ok":false,"counts":{"critical":1,"warning":0,"info":0},"findings":[{"severity":"critical","area":"ipv6-gateway","problem":"vlan994 is served by DHCPv6 but radvd does not advertise on it","detail":"clients get an IPv6 address and no default gateway …","fix":"dtvsol net6 add vlan994"}]}
Run it before and after any interface, VLAN, address or route change — and before and after a reboot. If it reports nothing while something is visibly wrong, treat that as a bug in the check rather than a clean bill of health.
ALERTS & BACKUP
alerts
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/alerts" -H "X-API-Key: "
Current conditions worth attention: DHCP down, VLAN interface down, DHCP pool nearly full, CGNAT pool nearly
exhausted, unregistered devices seen, spoofing drops in the last hour and a
registered address answered by another MAC (type spoof). Each has a severity (critical/warning/info). CLI: dtvsol alerts.
(These are the events a future email digest will push.)
backup / restore
/usr/bin/curl -ss "http://ROUTER-IP:8880/backup" -H "X-API-Key: " -o router-backup.tar.gz
Downloads a tar.gz of etc/ + data/ (all config and state). Restore by uploading the file to the
router and POST /restore -d '{"file":"/path/backup.tar.gz"}' — it extracts and
reapplies DHCP, shaping, suspensions, accounting, CGNAT and forwards. CLI: dtvsol backup [out] / dtvsol restore <file>.
BRUTE-FORCE (fail2ban)
Defense-in-depth on top of the network allow-list and API key. Two jails ban a source IP (via iptables)
after repeated failures: dtvsol-api (bad API keys, 5 in 10 min) and sshd
(failed SSH logins). The API logs every 401 with its source IP to log/auth.log, which the
dtvsol-api jail watches. Ban time 1 hour; the router's own and management networks are exempt.
status
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/fail2ban" -H "X-API-Key: "
Lists each jail with the currently-banned and total-banned counts and the banned IP list.
unban an IP
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/fail2ban" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"unban":"1.2.3.4"}'
CLI: dtvsol fail2ban / dtvsol fail2ban unban <ip>.
ANTI-SPOOFING (IP / ARP BINDING)
Strict IP + MAC + VLAN binding per subscriber. Everything this router does to a subscriber keys on the address it was given: the shaper matches the IP, a suspension DROPs the IP, the accounting and the CGNAT compliance log are per IP. A CPE that simply typed in a different address — a neighbour's, an unused one, the one with the better plan — was therefore unshaped, unsuspendable, and appeared in the CGNAT log as somebody else. ARP was the same hole one layer down: an ARP reply claiming the gateway, or another subscriber's address, poisoned this router's neighbour table.
With this on, a packet arriving on an enforced VLAN is forwarded (or accepted by the router itself) only when its source MAC and source IP are a pair the router knows, and an ARP packet is accepted only when its sender MAC and sender IP are. Everything else is dropped and, rate-limited, logged with the MAC that sent it — so support can answer "who tried" instead of "something is wrong". Off by default.
WHERE THE PAIRS COME FROM
| Source | What is bound |
|---|---|
| registered clients | clients.json: the MAC, its IPv4, its optional IPv6 reservation and pinned prefix — plus every DHCPv6 address (IA_NA) and delegated prefix (IA_PD) the v6 lease file ties to that client. radvd on this router is DHCPv6-only (no SLAAC), so nothing is left to guess. |
| DHCPv6 leases | dhcpd knows a lease by DUID, not MAC, and a DUID only sometimes carries one — possibly the CPE's LAN-side MAC, one off from the WAN port that actually sends (DUID-LL/LLT), or none at all (DUID-EN/UUID). A lease is bound to a MAC only when that is certain: the DUID's MAC is a registered client on that VLAN, a neighbour entry for one of its addresses names one, or the DUID's MAC sits a few numbers from a registered one there (the base-MAC+n scheme every vendor uses). Otherwise the lease is honoured on the VLAN it was granted for, with no MAC — still no cross-VLAN theft, no unleased address. In strict mode a DUID that names an unregistered MAC is a stranger and gets nothing; a DUID with no MAC cannot be told apart from a registered CPE, so it is honoured on its VLAN (offer reservations, not a dynamic range6, where that matters). |
| dynamic leases | Only on a VLAN in dynamic mode: active dhcpd.leases entries for MACs that are not registered. A registered MAC always gets its fixed address from dhcpd, so a stray lease under its MAC is history, not a right. |
| always | DHCP (udp/67) from anyone, so a stranger can still obtain a lease and be seen under dtvsol ips; IPv6 link-local sources (ND, DHCPv6); ARP probes with sender 0.0.0.0 (RFC 5227 duplicate-address check); and any exempt network you configure (an OLT's relay/management address, say). |
| never | A Router Advertisement or an ICMPv6 Redirect arriving from a subscriber — this box is the only router here. |
MODES
| Mode | Meaning |
|---|---|
strict (default) | Registered clients only. An unregistered device gets DHCP and nothing else — no contract, no service — which is what a BNG normally wants. |
dynamic | Registered clients are locked to their address and unregistered devices are allowed on the address dhcpd leased them. The rules follow the lease file (see below). |
off | Per-VLAN only: exclude one VLAN while the rest stay enforced. |
Every VLAN dhcpd serves (INTERFACESv4/v6) is enforced in the default mode. A per-VLAN
override changes that VLAN's mode, off excludes it, and an override on a VLAN dhcpd does
not serve adds it (static-only segments). A VLAN switched off with
vlan disable is left alone; vlan enable reapplies.
status
/usr/bin/curl -ss -X GET "http://ROUTER-IP:8880/antispoof" -H "X-API-Key: "
Per enforced VLAN: its mode, how many registered clients and dynamic leases are bound, how many IPv6
bindings, and packets dropped (ip4 / ip6 / arp, cumulative across
rebuilds). Plus whether the live chains are complete, when they were last written, and how many packets
were dropped in the last hour from how many devices.
{"enabled":true,"mode":"strict","log":true,"exempt":[],"overrides":{"vlan194":"dynamic"},"served":["vlan108","vlan135","vlan194"],"enforced":{"vlan108":{"mode":"strict","exists":true,"bindings4":1,"bindings6":0,"dropped":{"ip4":0,"ip6":0,"arp":0},"clients":1,"leases":0},"vlan135":{"mode":"strict","exists":true,"bindings4":1,"bindings6":2,"dropped":{"ip4":3,"ip6":0,"arp":0},"clients":1,"leases":0},"vlan194":{"mode":"dynamic","exists":true,"bindings4":7,"bindings6":0,"dropped":{"ip4":0,"ip6":0,"arp":0},"clients":5,"leases":2}},"switched_off":[],"live":{"v4_chain":true,"v4_rules":16,"v4_forward":true,"v4_input":true,"v6_chain":true,"v6_rules":26,"v6_forward":true,"v6_input":true,"arp_table":true,"arp_rules":9,"complete":true},"applied":{"hash":"…","at":"2026-09-17 11:28:02","ifaces":3,"bindings4":9,"bindings6":2},"last_hour":{"attempts":3,"devices":1}}
enable / configure
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/antispoof" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"enabled":true,"mode":"strict","log":true,"exempt":["10.0.0.0/30"]}'
BODY PARAMETERS (JSON)
| Field | Type | Description |
|---|---|---|
| enabled | Bool | Switch the feature on or off. Off removes every rule; the other settings are kept. |
| mode | String | strict or dynamic — the default for every served VLAN. |
| log | Bool | Rate-limited kernel log of what was dropped (10/min, burst 20, per family). Default true. |
| exempt | Array|String | CIDRs (v4 or v6) allowed from any MAC on every enforced VLAN. Comma-separated string accepted. |
Every field is optional; only what is sent changes. The response carries the apply summary
(ifaces, bindings4, bindings6) and the full status.
one VLAN's mode
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/antispoof/vlan194" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"mode":"dynamic"}'
mode is strict, dynamic, off or default
(drop the override). DELETE /antispoof/{iface} is the same as default. The
response says the effective mode; while the feature is switched off globally the override is
recorded and takes effect on enable.
who was dropped
/usr/bin/curl -ss "http://ROUTER-IP:8880/antispoof/log?since=2h&limit=50" -H "X-API-Key: "
Reads the kernel log (DTVSOL_SPOOF: for IPv4/IPv6, DTVSOL_ARPSPOOF: for ARP).
offenders groups the drops by (VLAN, sender MAC, claimed address) with counts and last
seen; entries are the newest raw drops with protocol / port or ARP opcode.
since accepts 30m, 2h, 1d or a date. A sender shown as
(this router) is a packet the box sent to itself (a looped-back multicast), not a device.
rebuild now
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/antispoof/sync" -H "X-API-Key: "
Normally not needed. Every client add/delete, VLAN enable/disable/delete, network add/remove and address
change rebuilds the rules through the API, and the lease files are watched by
dtvsol-antispoof.path (PathModified, so it reacts within a second of dhcpd appending a lease) with
dtvsol-antispoof.timer once a minute as the safety net that withdraws expired leases and repairs
chains flushed by hand. The rule set is hashed, so a run that finds nothing changed writes nothing.
HOW IT IS ENFORCED
One iptables/ip6tables chain, DTVSOL_ANTISPOOF, jumped to first from
FORWARD and INPUT, with a per-VLAN block ending in a counted jump to
DTVSOL_ANTISPOOF_DROP (LOG + DROP). It is rebuilt through iptables-restore --noflush
so the chain is swapped atomically — there is never a window where it is empty. ARP goes through an
nftables table in the arp family, dtvsol_antispoof, written as one
transaction; nft rather than arptables because arptables cannot log, and an ARP drop with no record of who
sent it is a support call nobody can answer. A suspension is inserted ahead
of the jump and still wins; port forwards are inbound and unaffected. The rules are
saved to /etc/iptables/rules.v4/.v6 and rebuilt at boot by dtvsol-vlans.service. The jump is
re-checked on every run: nothing but a DROP (a suspension) may sit above it, or it is moved back to the top.
The ARP table is not in those files — it exists again once dtvsol-vlans.service has run at boot,
so ARP is unfiltered for the few seconds between netfilter-persistent and that unit. Operator settings live in
data/antispoof.json, what the reconciler last did in data/antispoof-state.json.
Not covered: subscriber-to-subscriber spoofing inside the same VLAN never reaches this router — that is the OLT's split-horizon / port isolation to enforce. A spoofed IPv6 link-local neighbour advertisement is likewise out of scope here (RA-guard on the OLT).
WHAT DOCTOR AND ALERTS ADD
doctor reports info while the feature is off, critical
when it is on but a chain or jump is missing, warning when the rules are behind the current
bindings or a watcher unit is not active, and a clients warning when a registered address is
being answered by a different MAC — a replaced CPE that was never re-registered, or an address being
spoofed (with enforcement on, that device is getting nothing, which is worth saying out loud).
alerts adds a spoof entry for drops in the last hour (with the
worst offender) and for every such MAC mismatch. GET /status carries
antispoof: strict|dynamic|off.
CLI: dtvsol antispoof · on / off ·
set mode <strict|dynamic> [log on|off] [exempt <cidr,..>|none] ·
iface <vlan> <strict|dynamic|off|default> ·
log [30m|2h|1d] [limit] · sync. Tab completes the VLAN and the mode.
OLT CONTROL (HUAWEI)
Read and configure the access OLT from the same API and CLI as the router — Huawei
MA5600T family (MA5608T / MA5680T / MA5683T), over telnet or SSH.
The driver (bin/olt.py) logs in, puts the CLI into a scriptable state
(enable → config → undo smart / undo interactive /
scroll 512), pages through output, answers the parameter and (y/n) prompts,
and turns the CLI's own error lines into a failed call. Every response carries the parsed result and,
on failure or with raw=true, the raw session.
Two ways to name the OLT. By registry name (olt: "lab-olt";
with exactly one OLT registered it is the default), or — for a one-off — with host,
user, pass on the call itself (JSON body or X-OLT-* headers), which
keeps nothing. api.log records only the operation, the OLT, the caller and the outcome; a
password that arrives in a URL is redacted from auth.log; the driver receives the request on
stdin, never as arguments ps could show. SSH uses the system client with the
legacy algorithms these boxes still speak (ssh-rsa host keys) and trusts a host key on first
use, kept in data/olt_known_hosts.
registry
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/olts" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"name":"lab-olt","host":"192.168.100.100","user":"root","pass":"…","protocol":"telnet","svlan":1004,"cvlan_base":100,"iptv_vlan":200,"comment":"MA5608T Pitalito"}'
GET /olts lists them (never the password — has_pass, the product found at
registration, and the last sync result), POST /olts/{name} updates any field,
DELETE /olts/{name} forgets one (its profiles on the OLT are left alone). The login is
tested before the entry is kept — a wrong password stored is a reconciler failing every
fifteen minutes — unless force=true. The password is stored AES-256-GCM under a key generated
once into data/olt_vault.key (root, 0600) and only ever decrypted to build a driver request.
svlan, cvlan_base and iptv_vlan record the numbering this OLT uses;
ont-add and init read them.
plans → OLT (sync)
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/olt/sync" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"dry_run":true}'
Every bandwidth plan on this router exists on every registered OLT as:
a line profile dtvsol-<plan> (mapping by port, T-CONT 1 on the plan's
upstream DBA profile, GEM 1 for ETH 1 = internet, GEM 2 for ETH 2 = IPTV when the OLT has an IPTV VLAN); a DBA
profile dtvsol-u<kbps> (type 4, the upload ceiling on the PON — the part this
router cannot do); two traffic tables dtvsol-r<kbps> (CAR, bound to the
ONT's service-port inbound/outbound); and one shared service profile
dtvsol-eth. Rates carry a headroom (olt_car_headroom in
etc/config.php, default 1.10): the OLT protects the PON, the router's HTB stays the exact
limiter, and two token buckets at the same rate would cost throughput. DBA profiles and rate tables are
named by rate and shared, because this firmware cannot modify a DBA profile in place — a plan
whose speed changes is rebound to another rate object, and rate objects nothing uses are removed.
It is a reconciler: the OLT is read first and only differences are written, so a second
run is a read-only visit, an OLT that was unreachable catches up, and one registered later gets every
plan. It runs in the background after every plan add/update/delete (the reply says so), from
dtvsol-olt-sync.timer every 15 minutes, and on demand (dtvsol olt sync;
--dry-run prints the exact commands). Runs are serialized behind one lock —
two at once meant two logins with the same account, which the OLT refuses. An object a plan no longer
wants is left in place while an ONT still uses it, and the reply says so.
ont-add … plan=<name> then registers an ONT with that plan's line profile, the shared
service profile, native VLAN on its ETH port, and a service-port carrying the plan's rate tables — as
QinQ (tag-transform translate-and-add: outer S-VLAN from the registry, inner = the
subscriber's C-VLAN, i.e. the router's vlan<c> on vlan<s>) when the
OLT has an svlan, plain translation otherwise. With iptv=true the subscriber also
gets ETH 2 on the OLT's IPTV VLAN and a second service-port on GEM 2 — IPTV is unicast,
point to point, on a VLAN of its own only to keep that traffic apart from internet; no
multicast, no IGMP, and no CAR on that service-port (the video rate is the video's, not the plan's).
init — the baseline, generated from the box
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/olt/init" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"olt":"lab-olt","router_parent":"bond0","snmp_community":"…"}'
Reads the board table (every PON port on every PON board, every uplink on the control board) and returns
the command list an engineer would otherwise type: sysname (the registry name), NTP (this
router's address on the path to the OLT, unless given), timezone and SNMP if given, the
stacking S-VLAN tagged on every uplink, the IPTV VLAN (as an inner
C-VLAN it needs nothing on the OLT; single-tagged it is one more VLAN on the uplinks),
ont-auto-find enable on every PON port, every plan object the sync would create, and save — plus
router_commands: the S-VLAN interface and one C-VLAN interface per PON port
(cvlan_base + port, subnet_base.<n>.1/24), the IPTV interface, and
antispoof on for this box. Dry run by default; apply=true executes it in
order and stops at the first command the OLT refuses. Idempotent as far as the OLT allows: re-running on
a configured box changes nothing that already matches.
Not covered by init: anything vendor-specific beyond the Huawei MA5600T dialect, and
registering the ONTs themselves — that is ont add, per subscriber.
calling
/usr/bin/curl -ss -X POST "http://ROUTER-IP:8880/olt/autofind" -H "Content-Type: application/json" -H "X-API-Key: " -d '{"host":"192.168.100.100","user":"root","pass":"…"}'
Or keep the credentials out of the body with headers X-OLT-Host, X-OLT-User,
X-OLT-Pass (and X-OLT-Protocol / X-OLT-Port). Every operation
is POST /olt/{op}; GET /olt lists them. The browser form
/api?action=olt-{op}&host=…&user=…&pass=… exists but puts the password in the URL.
OPERATIONS
| op | arguments | result |
|---|---|---|
| read | ||
info | — | product, version, patch, uptime, boards (slot/name/status), clock, NTP state |
autofind | — | unregistered ONTs: F/S/P, SN (+ vendor tag), password/LOID, versions, equipment id, when first seen |
onus | [port] f/s/p | registered ONTs on that port or on every GPON board: id, SN, control/run/config/match state, description |
vlans | — | VLAN table (type, attribute, port counts) and, for every uplink port found on the control board, the VLANs tagged on it and its native VLAN |
serviceports | — | service-port table (index, VLAN, port, ONT, GEM, flow, state) |
profiles | — | DBA profiles (type, fix/assure/max kbps), line profiles, service profiles, with binding counts |
config | — | display current-configuration, raw (backup); allow up to 120 s on a full box |
run | command | any display … command, raw output — nothing else is accepted here |
write — nothing reaches flash until save | ||
vlan-add | vlan, [type smart|standard|mux|super], [description], [uplinks: "0/3/0,0/3/1"] | creates the VLAN and tags it on the given uplink ports |
vlan-del | vlan, [uplinks] | untags and deletes (the OLT refuses while service-ports use it) |
port-vlan | vlan, port, [remove] | tag / untag one uplink port |
profile-add | vlan, [dba=5], [eth_ports=1], [profile_id=vlan] | line profile (one T-CONT on that DBA profile, one GEM port carrying the VLAN) + service profile (ETH ports translating it), both named dtvsol-v<vlan> |
profile-del | profile_id | removes the pair |
ont-add | port, sn, vlan, [description], [profile_id], [line_profile], [srv_profile], [eth_ports], [gemport] | registers an ONT from autofind as a working subscriber: ont add … sn-auth with the VLAN's profiles, native VLAN on its ETH port(s), one service-port on the VLAN. Returns the ONT id and service-port index the OLT allocated. |
ont-del | port, ont_id | removes its service-ports, then the ONT |
ont-reboot / ont-desc / ont-optical | port, ont_id [, description] | reset · set description · rx/tx power, temperature, voltage |
ntp / sysname / save | server [, remove] · name · — | NTP unicast server (point it at this router) · hostname · write to flash |
exec | commands (array or newline-separated) | arbitrary configuration commands in config mode, in order; the first the OLT refuses stops the run and the reply says how far it got |
plan-sync | [plans], [headroom], [iptv_vlan], [dry_run] | the reconciler above for one OLT (defaults come from the router and the registry); POST /olt/sync runs it for every registered OLT |
init | [svlan], [cvlan_base], [iptv_vlan], [ntp], [sysname], [timezone], [snmp_community], [uplinks], [router_parent], [subnet_base], [apply] | the baseline generator above; dry run unless apply |
Every reply: {ok, op, olt:{host, protocol, product, prompt}, took_ms, result, error, [raw], [note]}.
A refused login is 401, an OLT that cannot be reached or refuses a command is 502
with the CLI's own error text; a write reply carries note until you save.
{"ok":true,"op":"autofind","olt":{"host":"192.168.100.100","protocol":"telnet","port":23,"product":"MA5608T","prompt":"MA5608T>"},"took_ms":2825,"result":{"count":1,"onts":[{"number":1,"fsp":"0/0/0","sn":"54504C473053E5F8","sn_text":"TPLG-3053E5F8","vendor":"TPLG","password":"0x00000000000000000000","loid":null,"version":null,"software":null,"equipment_id":null,"found_at":"2005-08-01 21:25:13+08:00"}]},"error":null}
A FIRST SUBSCRIBER ON A BARE OLT
dtvsol olt vlan add 100 uplinks 0/3/0 && dtvsol olt profile add 100 dba 5 && dtvsol olt autofind && dtvsol olt ont add 0/0/0 54504C473053E5F8 100 "Juan Perez" && dtvsol olt save
The VLAN must also exist on the router side (dtvsol vlan add),
which is where the subscriber then gets its address, plan and anti-spoofing binding. CLI: the
dtvsol olt family takes --host --user --pass (or OLT_HOST /
OLT_USER / OLT_PASS for a shell session, or it asks — the password is not
echoed), --ssh, --port, and --raw to print the session.
Not in v1: other vendors (the driver has one dialect; ZTE is next), stored credentials (deliberately), and the one-call "add client + register ONT" — planned, since the billing's client comments already carry the ONT serial.
MK-API (BILLING INTEGRATION)
WISP billing platforms already know how to provision MikroTik routers over the RouterOS API. MK-api is a second listener on this router that speaks that same protocol on TCP 8728 — so an existing billing can drive this router unchanged, thinking it is a MikroTik. Commands are translated into the normal router operations documented above (same validation, same shaper).
Scope: DHCP / IPoE only. Create, change speed, suspend/resume and delete a subscriber. Anything PPPoE is deliberately refused with an error.
WHAT THE BILLING SENDS → WHAT HAPPENS
| MikroTik command | Effect on this router |
|---|---|
/login | authenticate (MK-api user/password) |
/ip/dhcp-server/lease/add =address= =mac-address= [=comment=] [=rate-limit=] | create client (+ apply speed if a rate is given); returns the lease .id |
/ip/dhcp-server/lease/set =.id= =disabled=yes|no | suspend / resume the client |
/ip/dhcp-server/lease/set =.id= =rate-limit= | change speed |
/ip/dhcp-server/lease/remove =.id= | delete client |
/ip/dhcp-server/lease/print | list clients as leases |
/queue/simple/add|set =target=<ip> =max-limit=<up>/<down> | set speed (auto-creates and assigns a plan) |
/queue/simple/remove | remove the limit (client becomes unlimited) |
/system/identity/print, /system/resource/print, /ip/dhcp-server/print | device probes — answered so the billing accepts the connection |
/ppp/secret/…, anything pppoe | !trap — "PPPoE is not supported on this device (DHCP/IPoE only)" |
SPEED MAPPING
MikroTik rates are <upload>/<download> (e.g. 5M/20M, or raw bits). MK-api converts
them into a plan named mk_<down>m_<up>m, creates it if needed and assigns it to the client — so the
speed shows up in Plans / QoS and uses the same tc shaper as everything else.
A client with no rate stays unlimited.
DEVICE IDENTITY (what the billing sees)
Billings display — and sometimes validate — the device they connect to, so MK-api answers the identity probes
as a MikroTik-shaped device: /system/identity/print returns the configured name (default
MikroTik), /system/resource/print returns a RouterOS version and board-name (with this
router's real uptime and memory), and /system/routerboard/print returns model + serial.
A billing may also rename the device with /system/identity/set, which is persisted. Change them with:
dtvsol mkapi set identity <name> model <model> version <routeros-version>
SETUP
Point the billing at ROUTER-IP:8728 with the MK-api username/password, choosing
"MikroTik" as the router type. Then:
dtvsol mkapi status — show the listener, port, user and how many client ids are mapped
dtvsol mkapi set user <u> password <p> [port <n>] — set the billing credentials
dtvsol protect add <billing-net/cidr> — required: allow the billing server to reach the port
Security: port 8728 is firewalled to the same allowed networks as the management API, and the MikroTik protocol sends the password in clear — keep the billing on a trusted network or VPN.
ACTION API (GET)
Every action the API implements is listed below — the table is checked against the code, not maintained by hand. Browser-friendly alternative: every operation is also available as a plain GET request on /api with
an action parameter and &api_key=. Same behavior as the REST endpoints.
| Action | Parameters | REST equivalent |
|---|---|---|
| list | [network] | GET /clients |
| search | q | GET /clients?q= |
| get | mac | GET /clients/{mac} |
| add | mac, ip, [hostname], [comment], [ipv6] | POST /clients |
| delete | mac | DELETE /clients/{mac} |
| networks | — | GET /networks |
| reload | — | POST /dhcp/reload |
| status | — | GET /status |
| firewall | [network] | GET /firewall |
| firewall-full | — | GET /firewall/full |
| protect-list | — | GET /protect |
| protect-add | network, [comment] | POST /protect |
| protect-delete | network | DELETE /protect |
| interfaces | — | GET /interfaces |
| iface-list | — | GET /iface |
| add-net | iface, [label] | POST /net |
| remove-net | iface | DELETE /net |
| net6-add | iface | POST /net6 |
| net6-del | iface | DELETE /net6 |
| vlan-list | — | GET /vlans |
| vlan-add | parent, vlan_id, [protocol], [ip], [ipv6], [label] | POST /vlans |
| vlan-del | name | DELETE /vlans |
| ip-add | iface, ip | POST /ip |
| ip-del | iface, ip | DELETE /ip |
| route-list | — | GET /routes |
| route-add | prefix, [via], [dev], [comment] | POST /routes |
| route-del | prefix, [via], [dev] | DELETE /routes |
| nat-list | — | GET /nat |
| nat-add | iface, pool_start, [pool_end], [exempt], [comment] | POST /nat |
| nat-del | iface | DELETE /nat |
| active / connected | [state] | GET /clients/active |
| ip-info | [network] | GET /ips |
| plan-list / plan-add / plan-del | [name, down_mbps, up_mbps] | /plans |
| set-plan | mac, plan | POST /plan |
| suspend / resume | mac | POST /suspend|/resume |
| snmp-status / snmp-set | [community, location, contact] | /snmp |
| cgnat-status / cgnat-set / cgnat-lookup | [iface, pool, …] / public_ip, port | /cgnat |
| pf-list / pf-add / pf-del | proto, public_port, client_ip, client_port | /portforward (alias: /pf) |
| set-expires | mac, expires | POST /expires |
| option82 / alerts | — | GET /option82, /alerts |
| fail2ban-status / fail2ban-unban | [ip] | /fail2ban |
| doctor | — | GET /doctor |
| clients6 | [iface], [routers] | GET /clients6 |
| dns-list | — | GET /dns |
| dns-set | [v4], [v6], [domain], [iface], [apply=all|missing] | POST /dns | POST /dns/{iface} |
| dns-del | iface or global=v4|v6|domain|all | DELETE /dns/{iface} | DELETE /dns |
| net6-pool | iface, [start], [end] | POST /net6/pool |
| net6-dns | servers, [domain], [clear_domain] | POST /net6 (IPv6 only; dns-set does both) |
| net6-lease | valid, [preferred] | POST /net6/lease |
| vlan-disable | name, [reason] | POST /vlans/disable |
| vlan-enable | name | POST /vlans/enable |
| pd-list | — | GET /pd |
| pd-add | iface, [size] | POST /pd |
| pd-resize | iface, size | POST /pd/resize |
| pd-del | iface | DELETE /pd |
| pd-assign | mac, [prefix] | POST /pd/assign |
| pd-unassign | mac | DELETE /pd/assign |
| pd-sync | [dry] | GET /pd/sync |
| fail2ban | — | GET /fail2ban (alias of fail2ban-status) |
| antispoof-status | — | GET /antispoof |
| antispoof-set | [enabled], [mode], [log], [exempt] | POST /antispoof |
| antispoof-iface | iface, mode | POST /antispoof/{iface} | DELETE /antispoof/{iface} |
| antispoof-log | [since], [limit] | GET /antispoof/log |
| antispoof-sync | — | POST /antispoof/sync |
| olt-<op> | olt=<name> (or host, user, pass, [port], [protocol]), + the op's arguments | POST /olt/{op} (op: info autofind onus vlans serviceports profiles config run exec vlan-add vlan-del port-vlan profile-add profile-del ont-add ont-del ont-reboot ont-desc ont-optical ntp sysname save plan-sync init) |
| service-unregistered | [olt] | GET /services/unregistered |
| service-add | ref, sn, olt, pon, plan | down_mbps+up_mbps, name, [contract], [ipv6], [iptv], [expires], [comment] | POST /services |
| service-list | [state], [olt], [q], [fast] | GET /services |
| service-get / service-set | id [, plan, name, contract, expires, comment, retry] | GET /services/{id} | POST /services/{id} |
| service-suspend / service-resume | id | POST /services/{id}/suspend | /resume |
| service-del | id, [keep_ont] | DELETE /services/{id} |
| service-expiry | — | (cron) enforce service end dates |
| olts-list | — | GET /olts |
| olts-add / olts-set | name, host, user, pass, [protocol], [port], [svlan], [cvlan_base], [iptv_vlan], [comment], [force] | POST /olts | POST /olts/{name} |
| olts-del | name | DELETE /olts/{name} |
| olt-sync | [olt], [dry_run] | POST /olt/sync |
Example:
http://ROUTER-IP:8880/api?action=add&mac=AA:BB:CC:DD:EE:FF&ip=100.67.0.25&hostname=client1&api_key=
Errors
All responses are JSON. Errors carry an error field with a human-readable message.
| HTTP code | Meaning |
|---|---|
| 200 | OK — request succeeded. |
| 400 | Bad request — missing or invalid parameter (e.g. malformed MAC, IP outside detected networks). |
| 401 | Unauthorized — missing or wrong API key. |
| 404 | Not found — unknown endpoint or client MAC not registered. |
| 405 | Method not allowed on this resource. |
| 409 | Conflict — e.g. MAC or IP already registered. |
| 500 | Server error — e.g. DHCP reload failed; the message contains the service error. |
Note: if the API does not answer at all (connection timeout), your source network is probably not in the allowed networks list (iptables drops the connection before HTTP).