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.

Subscribers / clients
dtvsol clients list
dtvsol clients add <mac> <ip> [hostname] [comment] [--ipv6 <addr>]
dtvsol clients get <mac|ip|hostname> # lookup by any identifier
dtvsol clients delete <mac>
dtvsol clients search <keyword>
dtvsol clients active [online|recent|offline] # who is connected now (shorthand: dtvsol active)
dtvsol clients list-net <network> # only that CIDR
dtvsol clients6 [iface] [--routers] # MAC -> IPv6 held + delegated prefix
dtvsol ips [network] # IP usage: used / free / next-free / strangers
dtvsol clients plan <mac> <plan|none> # QoS plan (none = unlimited)
dtvsol clients suspend <mac> | resume <mac> # shorthand: dtvsol suspend|resume <mac>
dtvsol clients expires <mac> <YYYY-MM-DD|never> # service end date
Bandwidth plans (QoS)
dtvsol plans list
dtvsol plans add <name> <down_mbps> <up_mbps> [comment]
dtvsol plans del <name>
NAT / CGNAT / port forwarding
dtvsol nat list | add <iface> <pool_start> [pool_end] [exempt <cidr,..>] | del <iface>
dtvsol cgnat status | set enable iface <i> pool <range> block <n> | lookup <ip> <port>
dtvsol portforward list | add <tcp|udp> <pub_port> <cli_ip> <cli_port> [pub_ip] | del ...
Interfaces / VLANs / IPs / routes
dtvsol iface # all interfaces (mtu / speed / rx-tx)
dtvsol vlan list | add | del [name] # 802.1Q / QinQ
dtvsol vlan disable <name> [reason] # off, keeping every setting
dtvsol vlan enable <name> # back exactly as it was
dtvsol ip add [iface] [ip/cidr] | del <iface> <ip/cidr>
dtvsol route list | add <prefix|default> [via <gw>] [dev <if>] | del <prefix>
DHCP networks
dtvsol networks # detected networks / gateways
dtvsol net list | add | del # DHCPv4 on an interface
dtvsol net6 list | add | del # DHCPv6 segment (+ radvd)
dtvsol net6 pool <iface> [start end] # fill in range6 (+ DNS)
dtvsol net6 lease <valid> [preferred] # DHCPv6 address/prefix lifetimes
DNS / resolvers
dtvsol dns # what each VLAN serves, and who serves none
dtvsol dns set v4=<a,b> [v6=<c,d>] [domain=<n>] # the global every VLAN inherits
dtvsol dns set v4=<a,b> apply=all # and make existing VLANs follow it too
dtvsol dns set <iface> v4=<a,b> [v6=<c,d>] # override one VLAN
dtvsol dns del <iface> # drop the override, inherit the global
dtvsol dns del global <v4|v6|domain|all> # clear it, serve nothing again
IPv6 prefix delegation (IA_PD)
dtvsol pd list # pools, capacity, pins, live routes
dtvsol pd add <iface> [size] # delegate a LAN prefix to every CPE
dtvsol pd resize <iface> <size> # change subscribers per VLAN
dtvsol pd del <iface>
dtvsol pd assign <mac> [prefix] # pin a subscriber's LAN prefix
dtvsol pd unassign <mac>
dtvsol pd sync [--dry-run] # reconcile delegated-prefix routes now
Monitoring
dtvsol graph <mac|iface> [hour|day|week|month|year] [out.png] # RRD PNG
dtvsol snmp status | set community <c> location <l> contact <ct>
dtvsol option82 [enable|disable] # relay circuit/remote id (OLT/port)
dtvsol alerts # conditions worth attention
dtvsol mkapi [status] | set user <u> password <p> # MikroTik API for billing
Security
dtvsol protect list | add <net> [comment] | del <net> # who may reach :8880
dtvsol firewall list | vlan <svlan> # per-MAC filtering
dtvsol fail2ban [status] | unban <ip> # brute-force bans
dtvsol antispoof # IP + MAC + VLAN binding: modes, bindings, drops
dtvsol antispoof on | off # enforce on every served VLAN / remove every rule
dtvsol antispoof set mode <strict|dynamic> [log on|off] [exempt <cidr,..>|none]
dtvsol antispoof iface <vlan> <strict|dynamic|off|default> # one VLAN
dtvsol antispoof log [30m|2h|1d] [limit] # who was dropped, by MAC
dtvsol antispoof sync # rebuild the rules from clients + leases now
Services — one VLAN per subscriber, one call for OLT + router
dtvsol service unregistered [--olt n] # the technician's list: unregistered ONTs on every OLT
dtvsol service add <sn> --name "…" (--plan p | --down d --up u) [--olt n] [--pon f/s/p] [--ref r] [--iptv]
dtvsol service list [--state s] [--olt n] [-q text] | get <id> | set <id> [--plan p] [--expires d] …
dtvsol service suspend|resume|del <id> | graph <id> [period] [out.png]
OLT (Huawei MA5600T)
dtvsol olt list | add <name> <host> [user] --pass <p> [--ssh] [--svlan n] [--cvlan-base n] [--iptv n] | set | del
dtvsol olt sync [--dry-run] [--olt n] # the router's plans -> profiles + rate tables on every OLT
dtvsol olt init [--olt n] [--parent iface] [--ntp ip] [--snmp c] [--apply] # baseline, dry-run by default
dtvsol olt info | autofind | onus [f/s/p] | vlans | sp | profiles | config [out]
dtvsol olt vlan add <id> [uplinks 0/3/0,..] [desc "..."] | vlan del <id>
dtvsol olt uplink add|del <vlan> <f/s/p>  | profile add <vlan> [dba <id>] | profile del <id>
dtvsol olt ont add <f/s/p> <sn> <vlan> [desc] [--plan p] # register + QinQ service-port + rate tables
dtvsol olt ont del|reboot <f/s/p> <id> | ont desc <f/s/p> <id> "text" | ont optical <f/s/p> [id]
dtvsol olt ntp <ip> | sysname <n> | save | run "display ..." | exec "<cmd>" ...
# --olt <name> (default: the only registered one), or --host/--user/--pass for a one-off call
Service / backup
dtvsol doctor # config + boot-persistence audit (alias: check)
dtvsol init # PATH + tab completion, safe to re-run
dtvsol version # release, commit, service states (alias: -v)
dtvsol daemon status | start | stop | restart | reload | log | logf # the API daemon (was: service)
dtvsol backup [out.tar.gz] | dtvsol restore <file.tar.gz>

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"}'

FieldTypeDescription
refStringThe billing's own id. Makes the call idempotent: the same ref returns the existing service instead of creating another.
snStringThe ONT serial (16 hex digits) from the unregistered list.
oltStringRegistered OLT name (required when more than one is registered).
ponStringframe/slot/port from the list; looked up in autofind when omitted.
plan  |  down_mbps + up_mbpsString | IntAn existing plan, or the speeds: plan_<down>_<up> is created on the fly and reused.
nameStringThe customer (required). contract, comment: free text, searchable.
ipv6BoolDefault true when svc_ipv6_pool is set.
iptvBoolETH 2 of the ONT on the OLT's IPTV VLAN (unicast) with its own service-port.
expiresDateService end date: suspended by the router on that day, resumed when a later date is pushed. never clears it.
svlan, cvlanIntOnly 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

FieldTypeDescription
apiStringAPI name and version.
dhcp_serviceString[running|stopped] — state of the ISC DHCP service.
total_clientsIntegerNumber of registered static clients.
per_networkObjectClient count per detected network (CIDR ⇒ count).
interfacesIntegerNumber of detected DHCP-serving interfaces.
server_timeStringCurrent 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

FieldTypeDescription
networkString(optional) Only clients of this network, e.g. 100.67.0.0/24.
qString(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)

FieldDescription
ipv6The global address the client actually holds, taken from the neighbour table. ipv6_all lists every one it is using.
ipv6_reservedThe address reserved for it in clients.json, if any. Frequently null while ipv6 is populated — that is normal, not a fault.
prefix6_delegatedThe 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.
lease6Its live DHCPv6 address lease (IA_NA), as distinct from what the neighbour table has seen.
state / onlineNeighbour state (REACHABLE, STALE, …) and whether that counts as up.
link_localIts fe80:: address, kept separate: every host has one and it proves nothing about addressing having worked.
registered / hostname / ipv4 / ifaceIts 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)

FieldTypeDescription
macStringClient MAC address (any common format, normalized to AA:BB:CC:DD:EE:FF).
ipStringIPv4 address to reserve. Must belong to one of the detected networks.
hostnameString(optional) Hostname for the lease.
commentString(optional) Free-text note.
ipv6String(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

FieldTypeDescription
networkString(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 2592000thirty 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)

FieldTypeDescription
parentStringParent interface (e.g. ens5f0) or existing S-VLAN for QinQ inner tag.
vlan_idIntegerVLAN ID (1-4094).
protocolString(optional) 802.1Q (default) or 802.1ad for the outer QinQ tag.
ipString(optional) IPv4 address/CIDR to assign to the new VLAN interface.
ipv6String(optional) IPv6 address/prefix to assign.
labelString(optional) Free-text label stored in the VLAN database.
forceBoolean(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.
serveBoolean(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 airKept, untouched
the link (ip link set … down) — all traffic stops immediatelythe prefix-delegation range, still reserved in pd-pools.json
the DHCPv4 / DHCPv6 listen entriesthe subnet and subnet6 blocks
the radvd stanzanat.json, portforward.json, routes.json
the NAT POSTROUTING rules and port-forward DNAT rulesevery client reservation on the VLAN
its routes, withdrawn from the kernelits 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)

FieldTypeDescription
nameStringThe VLAN to switch off or on.
reasonString(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:

FieldWhat it clears, and why it mattered
removed_subnetsThe subnet / subnet6 blocks for its networks. Kept if another interface is still served in that network.
removed_pd_poolIts 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_routesStatic 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_natIts 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_forwardsPort 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.
keptTraffic 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

CodeRefused 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).
400The 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.
409The 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)

FieldTypeDescription
prefixStringDestination in CIDR (10.50.0.0/24, 2001:db8::/48) or default (default route; requires via).
viaString(optional*) Gateway address — must match the prefix family (IPv4 via for IPv4 prefix, IPv6 for IPv6).
devString(optional*) Output interface. *At least one of via/dev is required.
srcString(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.
commentString(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)

FieldTypeDescription
v4String / array(optional) IPv4 resolvers, comma-separated or a JSON array. Written as the global option domain-name-servers.
v6String / array(optional) IPv6 resolvers. Written as the global option dhcp6.name-servers; reuses the existing IPv6 path, validation and rollback included.
domainString(optional) Search domain — option domain-name on IPv4 and option dhcp6.domain-search on IPv6.
applyString(optional) all or missing — see the table below. Omit to change only the global.
applyEffect on VLANs that already exist
omittedOnly the global changes. A VLAN with resolvers of its own keeps them.
allOne 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.
missingOnly 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 isThe 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 blockThe 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 withPOST /clients with ipv6  ·  dtvsol clients add … --ipv6 <addr>POST /pd/assign  ·  dtvsol pd assign <mac> [prefix]
Written asfixed-address6 in the host reservation.fixed-prefix6 in the same host reservation.
Left unset, it comes fromThe VLAN's range6 pool.The VLAN's slice of pd_pool.

Because they are independent, all four combinations are valid and useful:

WANLAN prefixWhen to use it
dynamicdynamicThe normal mass-residential case. Nothing is configured per subscriber at either end — the whole point of delegation.
staticdynamicYou want a predictable address to reach the CPE itself (monitoring, remote support). The customer's LAN network may still change.
dynamicpinnedThe customer's LAN network must never change — a business running servers or firewall rules against it.
staticpinnedBoth 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.

SettingMeaning
pd_poolThe block delegated prefixes come from, e.g. 2803:1840:1001::/48. Your upstream must route it to this box.
pd_lenSize 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_slicePrefixes 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_reservePrefixes 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)

FieldTypeDescription
macStringClient MAC. Must already exist as a DTVSOL client.
prefixString(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)

FieldTypeDescription
ifaceStringWAN (output) interface the NAT applies to.
pool_startStringFirst public IP of the SNAT pool.
pool_endString(optional) Last pool IP — omit for single-IP NAT.
exemptArray|String(optional) Source networks (CIDR) that bypass NAT — array or comma-separated string.
commentString(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:

ChannelDirectionUse for
Traffic Graphs (above)you pull a PNGquick look, no external system
SNMPan NMS polls the routercentral per-interface + per-client graphing across the fleet
SMTP email reportsrouter pushes emailscheduled 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 columns 1=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"]}'

FieldTypeDescription
enabledBoolTurn CGNAT on/off.
ifaceStringWAN (output) interface.
poolString|ArrayPublic IPs — range (a-b), list or array.
block_sizeIntPorts per subscriber (default 2048 → ~31 subs/IP over 1024-65535).
port_min / port_maxIntPort window (default 1024..65535).
exemptArrayNetworks 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

AreaChecks
persistenceAn 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.
addressingAn 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.
dhcpINTERFACESv4/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) — IPv4A 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) — inheritA 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 range3ffe::/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-gatewayAn 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.
servicesAny 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.
delegationpd-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.
natnat.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.
routesroutes.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.
clientsA 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.
antispoofAnti-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

SourceWhat is bound
registered clientsclients.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 leasesdhcpd 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 leasesOnly 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.
alwaysDHCP (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).
neverA Router Advertisement or an ICMPv6 Redirect arriving from a subscriber — this box is the only router here.

MODES

ModeMeaning
strict (default)Registered clients only. An unregistered device gets DHCP and nothing else — no contract, no service — which is what a BNG normally wants.
dynamicRegistered 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).
offPer-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)

FieldTypeDescription
enabledBoolSwitch the feature on or off. Off removes every rule; the other settings are kept.
modeStringstrict or dynamic — the default for every served VLAN.
logBoolRate-limited kernel log of what was dropped (10/min, burst 20, per family). Default true.
exemptArray|StringCIDRs (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 (enableconfigundo 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

opargumentsresult
read
infoproduct, version, patch, uptime, boards (slot/name/status), clock, NTP state
autofindunregistered ONTs: F/S/P, SN (+ vendor tag), password/LOID, versions, equipment id, when first seen
onus[port] f/s/pregistered ONTs on that port or on every GPON board: id, SN, control/run/config/match state, description
vlansVLAN table (type, attribute, port counts) and, for every uplink port found on the control board, the VLANs tagged on it and its native VLAN
serviceportsservice-port table (index, VLAN, port, ONT, GEM, flow, state)
profilesDBA profiles (type, fix/assure/max kbps), line profiles, service profiles, with binding counts
configdisplay current-configuration, raw (backup); allow up to 120 s on a full box
runcommandany display … command, raw output — nothing else is accepted here
write — nothing reaches flash until save
vlan-addvlan, [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-delvlan, [uplinks]untags and deletes (the OLT refuses while service-ports use it)
port-vlanvlan, port, [remove]tag / untag one uplink port
profile-addvlan, [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-delprofile_idremoves the pair
ont-addport, 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-delport, ont_idremoves its service-ports, then the ONT
ont-reboot / ont-desc / ont-opticalport, ont_id [, description]reset · set description · rx/tx power, temperature, voltage
ntp / sysname / saveserver [, remove] · name · —NTP unicast server (point it at this router) · hostname · write to flash
execcommands (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 commandEffect on this router
/loginauthenticate (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|nosuspend / 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/printlist clients as leases
/queue/simple/add|set =target=<ip> =max-limit=<up>/<down>set speed (auto-creates and assigns a plan)
/queue/simple/removeremove the limit (client becomes unlimited)
/system/identity/print, /system/resource/print, /ip/dhcp-server/printdevice 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.

ActionParametersREST equivalent
list[network]GET /clients
searchqGET /clients?q=
getmacGET /clients/{mac}
addmac, ip, [hostname], [comment], [ipv6]POST /clients
deletemacDELETE /clients/{mac}
networksGET /networks
reloadPOST /dhcp/reload
statusGET /status
firewall[network]GET /firewall
firewall-fullGET /firewall/full
protect-listGET /protect
protect-addnetwork, [comment]POST /protect
protect-deletenetworkDELETE /protect
interfacesGET /interfaces
iface-listGET /iface
add-netiface, [label]POST /net
remove-netifaceDELETE /net
net6-addifacePOST /net6
net6-delifaceDELETE /net6
vlan-listGET /vlans
vlan-addparent, vlan_id, [protocol], [ip], [ipv6], [label]POST /vlans
vlan-delnameDELETE /vlans
ip-addiface, ipPOST /ip
ip-deliface, ipDELETE /ip
route-listGET /routes
route-addprefix, [via], [dev], [comment]POST /routes
route-delprefix, [via], [dev]DELETE /routes
nat-listGET /nat
nat-addiface, pool_start, [pool_end], [exempt], [comment]POST /nat
nat-delifaceDELETE /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-planmac, planPOST /plan
suspend / resumemacPOST /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-delproto, public_port, client_ip, client_port/portforward  (alias: /pf)
set-expiresmac, expiresPOST /expires
option82 / alertsGET /option82, /alerts
fail2ban-status / fail2ban-unban[ip]/fail2ban
doctorGET /doctor
clients6[iface], [routers]GET /clients6
dns-listGET /dns
dns-set[v4], [v6], [domain], [iface], [apply=all|missing]POST /dns  |  POST /dns/{iface}
dns-deliface  or  global=v4|v6|domain|allDELETE /dns/{iface}  |  DELETE /dns
net6-pooliface, [start], [end]POST /net6/pool
net6-dnsservers, [domain], [clear_domain]POST /net6  (IPv6 only; dns-set does both)
net6-leasevalid, [preferred]POST /net6/lease
vlan-disablename, [reason]POST /vlans/disable
vlan-enablenamePOST /vlans/enable
pd-listGET /pd
pd-addiface, [size]POST /pd
pd-resizeiface, sizePOST /pd/resize
pd-delifaceDELETE /pd
pd-assignmac, [prefix]POST /pd/assign
pd-unassignmacDELETE /pd/assign
pd-sync[dry]GET /pd/sync
fail2banGET /fail2ban  (alias of fail2ban-status)
antispoof-statusGET /antispoof
antispoof-set[enabled], [mode], [log], [exempt]POST /antispoof
antispoof-ifaceiface, modePOST /antispoof/{iface}  |  DELETE /antispoof/{iface}
antispoof-log[since], [limit]GET /antispoof/log
antispoof-syncPOST /antispoof/sync
olt-<op>olt=<name> (or host, user, pass, [port], [protocol]), + the op's argumentsPOST /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-addref, 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-setid [, plan, name, contract, expires, comment, retry]GET /services/{id}  |  POST /services/{id}
service-suspend / service-resumeidPOST /services/{id}/suspend | /resume
service-delid, [keep_ont]DELETE /services/{id}
service-expiry(cron) enforce service end dates
olts-listGET /olts
olts-add / olts-setname, host, user, pass, [protocol], [port], [svlan], [cvlan_base], [iptv_vlan], [comment], [force]POST /olts  |  POST /olts/{name}
olts-delnameDELETE /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 codeMeaning
200OK — request succeeded.
400Bad request — missing or invalid parameter (e.g. malformed MAC, IP outside detected networks).
401Unauthorized — missing or wrong API key.
404Not found — unknown endpoint or client MAC not registered.
405Method not allowed on this resource.
409Conflict — e.g. MAC or IP already registered.
500Server 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).