Skip to content

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_id so 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
}
  • type is "dpdk" (physical port) or "vlan" (sub-interface; has parent).
  • name is the stable key for an interface. id is only this response's enumeration index — do not persist it.
  • status is 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 / *_dropped values are cumulative hardware (DPDK) counters since interface start.
  • rx_errors/tx_errors/rx_dropped are present on physical ("dpdk") interfaces only — VLAN sub-interfaces do not have hardware error counters.
  • rx_bps/tx_bps/rx_pps/tx_pps are 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 (uptime dropping signals that).
  • id is the BNG's internal session slot — informational only; key on username.
  • One request returns one session; issue one SESSION_INFO per 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.