Netvyn BNG — Vendor TCP Monitoring API¶
Integration document for third-party billing / monitoring portals. Protocol version 1. This document is self-contained: a vendor can implement a client from it without access to Netvyn source code.
The API is read-only and exposes five statistics commands over a single persistent TCP connection. It is not HTTP, REST, or WebSocket — it is a small framed binary protocol with JSON payloads.
1. Server address and configuration¶
The Netvyn operator configures the API on the BNG:
service api enable # start the server (live)
service api listen 0.0.0.0:8090 # bind address:port (0.0.0.0 = any router-owned IP)
service api allow 203.0.113.0/24 # OPTIONAL: source-IP ACL (empty ACL = open)
show api # current state
system user add monitor read <pw> # operator account the vendor authenticates with
The listen address is a router-owned (DPDK) IP of the BNG — the connection is terminated by the BNG's own control-plane TCP stack, not by the host Linux kernel. From the client's point of view it is a completely ordinary TCP server.
Authentication uses the BNG's operator accounts (the same RBAC user store
the CLI/GUI use) — there is no separate API credential. Since the API is
read-only, a dedicated read-role account is recommended for the vendor.
The operator gives the vendor: IP address, TCP port (default 8090), and an operator username + password.
The port should not be exposed to the public Internet without the ACL and/or an upstream firewall.
2. TCP port¶
Default 8090, operator-configurable. Plain TCP, IPv4.
3. Connection procedure¶
socket()
connect(<bng-ip>, <port>)
AUTH request / response # REQUIRED first command (operator account)
request / response
request / response
...
close() # the CLIENT decides when to close
- One socket carries one or many requests — both are valid.
- The server never closes the socket after a response.
- Requests are synchronous: send one request, read its response before
sending the next. (The header carries a
request_idso future protocol versions may allow pipelining; v1 clients should stay request-at-a-time — though frames that happen to arrive together are parsed correctly.) - Up to 16 simultaneous client connections are served; each is independent.
4. Protocol header (framing)¶
TCP is a byte stream — every request and response is wrapped in a fixed 13-byte header. All multi-byte fields are network byte order (big-endian):
offset size field
0 2 magic 0x4E56 ("NV")
2 1 version 0x01
3 2 msg_type command / response type (see §5)
5 4 payload_len number of payload bytes that follow (N)
9 4 request_id client-chosen; echoed in the response
13 N payload
Equivalent C structure (serialize field-by-field; do not rely on struct packing):
struct netvyn_api_header {
uint16_t magic; /* 0x4E56 */
uint8_t version; /* 1 */
uint16_t msg_type;
uint32_t payload_len;
uint32_t request_id;
};
Python: struct.Struct("!HBHII"). PHP: pack("nCnNN", ...).
Requests carry an empty payload (payload_len = 0) — except AUTH
(payload = "<username> <password>" as raw ASCII, no NUL, no JSON) and
SESSION_INFO (payload = the subscriber username, see §7.6).
Responses echo the request's msg_type and request_id; the payload is
one UTF-8 JSON object (see §7).
5. Message types¶
0x0001 SESSION_STATS session counts (total / pppoe / ipoe)
0x0002 INTERFACE_LIST interfaces known to the BNG
0x0003 INTERFACE_STATS cumulative per-interface counters
0x0004 RESOURCES CPU / memory / hugepages / occupancy / uptime
0x0005 HEALTH daemon + link health summary
0x0006 SESSION_INFO ONE session's statistics, queried by username
0x0010 AUTH authenticate with an operator account (REQUIRED first)
0xFFFF ERROR server->client only: framing-level error report
These numeric values are the published protocol and remain backward
compatible. Values 0x0007–0x000F and 0x0011–0x7FFF are reserved for future
commands.
6. Request format¶
A request is just the 13-byte header (payload empty), e.g. SESSION_STATS
with request_id = 1001:
4E 56 01 00 01 00 00 00 00 00 00 03 E9
AUTH is header + "<username> <password>":
4E 56 01 00 10 00 00 00 0C 00 00 00 01 61 64 6D 69 6E 20 73 33 63 72 65 74
("admin s3cret")
7. Response format¶
Header (msg_type + request_id echoed) followed by one JSON object:
Success:
{"status":"ok","request_id":1001,"data":{ ... }}
Error:
{"status":"error","request_id":1001,"error_code":3,"message":"unsupported message type"}
7.1 SESSION_STATS (0x0001)¶
{
"total_sessions": 1833,
"pppoe_sessions": 1700,
"ipoe_sessions": 133
}
Counts of currently-online sessions. Per-session traffic counters are fetched
with SESSION_INFO (§7.6), one subscriber at a time.
7.2 INTERFACE_LIST (0x0002)¶
{
"interfaces": [
{"id": 0, "name": "wan0", "type": "dpdk", "status": "up"},
{"id": 1, "name": "lan0", "type": "dpdk", "status": "up"},
{"id": 2, "name": "lan0.100", "type": "vlan", "parent": "lan0", "status": "up"}
],
"total": 3
}
typeis"dpdk"(physical port) or"vlan"(sub-interface; hasparent).nameis the stable key for an interface.idis only this response's enumeration index — do not persist it.statusis the link state:"up"or"down".
7.3 INTERFACE_STATS (0x0003)¶
{
"interfaces": [
{
"id": 0, "name": "wan0", "type": "dpdk",
"rx_packets": 12345678, "tx_packets": 9876543,
"rx_bytes": 1234567890, "tx_bytes": 987654321,
"rx_errors": 0, "tx_errors": 0, "rx_dropped": 0,
"rx_bps": 812345678, "tx_bps": 91234567,
"rx_pps": 81234, "tx_pps": 9123
},
{
"id": 1, "name": "lan0.100", "type": "vlan", "parent": "lan0",
"rx_packets": 45678901, "tx_packets": 34567890,
"rx_bytes": 4567890123, "tx_bytes": 3456789012,
"rx_bps": 0, "tx_bps": 0, "rx_pps": 0, "tx_pps": 0
}
],
"total": 2
}
- All
*_packets/*_bytes/*_errors/*_droppedvalues are cumulative hardware (DPDK) counters since interface start. rx_errors/tx_errors/rx_droppedare present on physical ("dpdk") interfaces only — VLAN sub-interfaces do not have hardware error counters.rx_bps/tx_bps/rx_pps/tx_ppsare an informational server-side 1-second-sampled current rate (bits/s, packets/s). For billing, compute rates from the cumulative counters (§13) instead.
7.4 RESOURCES (0x0004)¶
{
"cpu_usage": 42.5,
"cpu_temp_c": 54.0,
"mem_total_bytes": 16720368640,
"mem_free_bytes": 9123456789,
"mem_used_bytes": 7596911851,
"hugepages_total_bytes": 4294967296,
"hugepages_used_pct": 61.2,
"uptime": 864321,
"lcores": 8,
"workers": 5,
"subscribers": {"used": 1833, "max": 10000},
"nat_sessions": {"used": 51234, "max": 2097152}
}
cpu_temp_c is null when the platform does not expose a sensor. uptime is
seconds since the daemon started.
7.5 HEALTH (0x0005)¶
{
"health": "healthy",
"dpdk_status": "running",
"version": "1.0.0",
"dpdk": "DPDK 23.11.0",
"uptime": 864321,
"workers_total": 5,
"workers_alive": 5,
"interfaces_up": 2,
"interfaces_total": 2,
"sessions_active": 1833
}
health is "healthy", or "degraded" when no physical interface has link.
A response arriving at all proves the control plane is alive; use
interfaces_up/interfaces_total for finer-grained alerting.
7.6 SESSION_INFO (0x0006)¶
Returns one online session's statistics, queried by subscriber username.
The session table is deliberately not dumpable through this API — the
billing portal knows its subscribers and asks for them individually (poll the
ones you bill; use SESSION_STATS for counts).
Request payload (required, plain ASCII, ≤ 256 bytes) = the username, matched
case-insensitively against online sessions. An empty payload is error 5.
Response when the subscriber is online:
{
"status": "ok",
"request_id": 1006,
"data": {
"found": true,
"id": 17,
"username": "alice",
"type": "pppoe",
"ip": "100.64.3.17",
"interface": "ether1.100",
"rx_bytes": 963103,
"tx_bytes": 1284403,
"rx_packets": 8123,
"tx_packets": 9541,
"uptime": 3612
}
}
Unknown or offline username (still status: ok — not an error):
{"status":"ok","request_id":1006,"data":{"found":false,"username":"bob"}}
rx= received from the subscriber (upstream),tx= sent to the subscriber (downstream); both cumulative for the session's lifetime and reset when the session re-connects (uptimedropping signals that).idis the BNG's internal session slot — informational only; key onusername.- One request returns one session; issue one
SESSION_INFOper subscriber over the same persistent connection.
7.7 AUTH (0x0010)¶
Required as the first command on every connection — any other command
before a successful AUTH is rejected with error 6.
Request payload = "<username> <password>" of a BNG operator account.
Everything after the first space is the password (passwords may contain
spaces; usernames may not). Success:
{"status":"ok","request_id":1,"data":{"authenticated":true,
"username":"monitor","role":"viewer"}}
role is viewer / operator / admin — any role may use this API (it is
read-only end to end).
Wrong credentials: error 7 and the server closes the connection. The
normal operator-account protections apply: 5 consecutive failures lock the
account for 15 minutes, and every attempt is recorded in the BNG's audit trail
and diagnostic log. Authentication is per-connection; the session ends when the
connection closes.
8. Error codes¶
| code | meaning | connection |
|---|---|---|
| 1 | bad magic | closed |
| 2 | unsupported protocol version | closed |
| 3 | unsupported message type | stays open |
| 4 | invalid payload length | closed if the length is unparseable (> 256), open if merely non-empty |
| 5 | malformed AUTH payload | stays open |
| 6 | AUTH required before this command | stays open |
| 7 | authentication failed | closed |
| 8 | source IP not permitted (ACL) | closed |
| 9 | internal error | stays open |
Framing-level errors that cannot be attributed to a well-formed request are
reported with msg_type = 0xFFFF (ERROR); request_id is echoed when the
header was parseable, else 0.
9. Example — single command session¶
connect(bng, 8090)
send AUTH "monitor <password>" request_id=1
recv {"status":"ok","request_id":1,"data":{"authenticated":true,...}}
send SESSION_STATS request_id=2
recv {"status":"ok","request_id":2,"data":{"total_sessions":1833,...}}
close()
10. Example — multi-command polling session¶
connect(bng, 8090)
send AUTH "monitor <password>" -> ok
every 5 seconds:
send SESSION_STATS -> response
send INTERFACE_STATS -> response
send RESOURCES -> response
send HEALTH -> response
send SESSION_INFO "alice" -> response (one per subscriber of interest)
send SESSION_INFO "bob" -> response
(connection stays open between polls)
11. Reconnection behavior¶
Reconnection logic belongs to the client:
recv fails / socket closed
-> close() the old socket
-> connect() again (back off a few seconds between attempts)
-> re-send AUTH (authentication is per-connection)
-> continue polling
The server accepts the new connection normally. Cumulative counters are not affected by reconnects (they live in the BNG, not in the connection).
12. Timeout behavior¶
- The server reaps a connection idle for 30 seconds (no TCP activity). A client polling every ≤ 25 seconds never hits this; a slower poller should simply reconnect (§11) or poll HEALTH as a keepalive.
- Recommended client-side socket timeout: 10 s per response.
- Responses to statistics commands are generated immediately (microseconds); a slow response indicates a network problem, not server load.
13. Counter definitions¶
All *_bytes / *_packets / *_errors / *_dropped fields are cumulative
counters (monotonically increasing since daemon/interface start). The API
never returns a precomputed average. Compute rates from two polls:
rate_bps = (rx_bytes[t2] - rx_bytes[t1]) * 8 / (t2 - t1)
Handle counter reset (BNG restart): if current < previous, discard the
interval and re-baseline. HEALTH.uptime dropping is the restart signal.
Per-session counters (SESSION_INFO.rx_bytes etc.) cover the current
session's lifetime — they reset when the subscriber reconnects (a drop in
SESSION_INFO.uptime signals that; re-baseline). Authoritative per-subscriber
billing totals come from RADIUS accounting; this API is for live monitoring
and graphs.
14. Sample implementation¶
Reference Python client (stdlib only) — test_api.py in the Netvyn
distribution contains this class plus a full protocol test suite:
import socket, struct, json
MAGIC, VERSION = 0x4E56, 1
HDR = struct.Struct("!HBHII") # magic, ver, type, payload_len, request_id
SESSION_STATS, INTERFACE_LIST, INTERFACE_STATS = 0x0001, 0x0002, 0x0003
RESOURCES, HEALTH, SESSION_INFO, AUTH = 0x0004, 0x0005, 0x0006, 0x0010
class NetvynClient:
def __init__(self, host, port=8090, user=None, password=None, timeout=10.0):
self.host, self.port, self.timeout = host, port, timeout
self.user, self.password = user, password
self.sock, self.buf, self.rid = None, b"", 0
def connect(self):
self.sock = socket.create_connection((self.host, self.port), self.timeout)
self.sock.settimeout(self.timeout)
self.buf = b""
self._request(AUTH, ("%s %s" % (self.user, self.password)).encode())
def close(self):
if self.sock: self.sock.close(); self.sock = None
def _recv_exact(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk: raise ConnectionError("server closed")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _request(self, msg_type, payload=b""):
self.rid += 1
self.sock.sendall(HDR.pack(MAGIC, VERSION, msg_type, len(payload), self.rid)
+ payload)
magic, ver, rtype, plen, rrid = HDR.unpack(self._recv_exact(HDR.size))
if magic != MAGIC or rrid != self.rid:
raise ConnectionError("protocol desync")
obj = json.loads(self._recv_exact(plen).decode())
if obj.get("status") != "ok":
raise RuntimeError("API error %s: %s"
% (obj.get("error_code"), obj.get("message")))
return obj["data"]
def session_stats(self): return self._request(SESSION_STATS)
def interface_list(self): return self._request(INTERFACE_LIST)
def interface_stats(self): return self._request(INTERFACE_STATS)
def resources(self): return self._request(RESOURCES)
def health(self): return self._request(HEALTH)
def session_info(self, username):
return self._request(SESSION_INFO, username.encode())
# usage
c = NetvynClient("103.102.59.6", 8090, user="monitor", password="s3cret")
c.connect()
print(c.session_stats())
print(c.interface_stats())
print(c.session_info("alice"))
c.close()
Pseudocode for any language:
function request(sock, type, payload=""):
rid += 1
send(sock, pack_be16(0x4E56) + pack_be8(1) + pack_be16(type)
+ pack_be32(len(payload)) + pack_be32(rid) + payload)
hdr = recv_exactly(sock, 13) # loop until 13 bytes buffered
(magic, ver, rtype, plen, rrid) = unpack(hdr)
assert magic == 0x4E56 and rrid == rid
body = recv_exactly(sock, plen)
return json_decode(body)
The two rules that matter: read exactly 13 + payload_len bytes per frame
(never assume one recv() returns one message), and reuse the socket —
do not reconnect per command.