firewalla-snmp-proxy 2.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """Firewalla Switch SNMP proxy.
2
+
3
+ Exposes Firewalla Switch (SE / fwsw-*) port data from the Firewalla MSP cloud
4
+ API as a standards-compliant SNMP agent, so any SNMP monitoring system can
5
+ poll a switch that has no SNMP agent of its own.
6
+ """
7
+
8
+ __version__ = "2.2.1"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,184 @@
1
+ """pysnmp wiring: one SNMP agent per switch.
2
+
3
+ Design notes:
4
+
5
+ * **One UDP port per switch.** Multiplexing several switches onto one port via
6
+ distinct community strings works in some NMSes but breaks any that key a
7
+ device on ``IP:port``. A port each keeps every NMS happy.
8
+ * **All engines share one asyncio loop.** pysnmp registers its transport with
9
+ the running loop, so N agents cost N sockets, not N threads.
10
+ * **The tree is rebuilt only when its shape changes** (port count, SFP presence,
11
+ PoE capability). Values are live via callables, so an ordinary poll needs no
12
+ rebuild and an NMS never sees rows flicker mid-walk.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Optional, Sequence
19
+
20
+ from pysnmp.carrier.asyncio.dgram import udp
21
+ from pysnmp.entity import config, engine
22
+ from pysnmp.entity.rfc3413 import cmdrsp, context
23
+ from pysnmp.proto import rfc1902
24
+ from pysnmp.proto.api import v2c
25
+ from pysnmp.smi.instrum import AbstractMibInstrumController
26
+
27
+ from .mibs import SwitchContext
28
+ from .oid import OidTree
29
+ from .tree_builder import build_tree
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ #: Access-control view root. Note this is ``iso(1)``, not ``1.3.6.1``:
34
+ #: LLDP-MIB lives at ``1.0.8802``, outside the internet subtree. Scoping the
35
+ #: view to 1.3.6.1 -- the obvious choice -- silently hides the entire neighbour
36
+ #: table, and therefore the NMS topology link, with no error anywhere.
37
+ VIEW_ROOT = (1,)
38
+
39
+ #: SNMP security models served: v1 (1) and v2c (2).
40
+ SECURITY_MODELS = (1, 2)
41
+
42
+
43
+ class TreeInstrumController(AbstractMibInstrumController):
44
+ """Serves GET / GETNEXT / GETBULK from an :class:`OidTree`.
45
+
46
+ SET is refused: this is a monitoring proxy and the upstream API's mutating
47
+ endpoints must never be reachable over SNMP.
48
+ """
49
+
50
+ def __init__(self, tree: OidTree) -> None:
51
+ self.tree = tree
52
+
53
+ def swap_tree(self, tree: OidTree) -> None:
54
+ self.tree = tree
55
+
56
+ # -- GET -------------------------------------------------------------
57
+ def read_variables(self, *varBinds, **context):
58
+ result = []
59
+ for oid, _val in varBinds:
60
+ key = tuple(oid)
61
+ value = self.tree.get(key)
62
+ if value is not None:
63
+ result.append((oid, value))
64
+ continue
65
+ # Distinguish "wrong instance" from "wrong object", as a real agent
66
+ # does. Two ways this OID can still name a real object:
67
+ # - it is an interior node with instances beneath it
68
+ # (e.g. a GET on the ifOperStatus column itself), or
69
+ # - its parent column holds other instances, so the column is
70
+ # real and only this index is missing (e.g. ifOperStatus.99).
71
+ # Anything else genuinely is not implemented here.
72
+ if self.tree.has_descendants(key) or (
73
+ len(key) > 1 and self.tree.has_descendants(key[:-1])
74
+ ):
75
+ result.append((oid, v2c.NoSuchInstance("")))
76
+ else:
77
+ result.append((oid, v2c.NoSuchObject("")))
78
+ return result
79
+
80
+ # -- GETNEXT / GETBULK ----------------------------------------------
81
+ def read_next_variables(self, *varBinds, **context):
82
+ result = []
83
+ for oid, _val in varBinds:
84
+ nxt = self.tree.get_next(tuple(oid))
85
+ if nxt is None:
86
+ result.append((oid, v2c.EndOfMibView("")))
87
+ else:
88
+ next_oid, value = nxt
89
+ result.append((rfc1902.ObjectName(next_oid), value))
90
+ return result
91
+
92
+ # -- SET -------------------------------------------------------------
93
+ def write_variables(self, *varBinds, **context):
94
+ from pysnmp.smi import error
95
+
96
+ raise error.NotWritableError(
97
+ name=varBinds[0][0] if varBinds else None,
98
+ idx=0,
99
+ )
100
+
101
+
102
+ class SwitchAgent:
103
+ """An SNMP agent serving one Firewalla switch on its own UDP port."""
104
+
105
+ def __init__(
106
+ self,
107
+ ctx: SwitchContext,
108
+ listen_host: str,
109
+ listen_port: int,
110
+ community: str = "public",
111
+ ) -> None:
112
+ self.ctx = ctx
113
+ self.listen_host = listen_host
114
+ self.listen_port = listen_port
115
+ self.community = community
116
+ self._signature = ctx.port_signature()
117
+ self.tree = build_tree(ctx)
118
+ self._instrum = TreeInstrumController(self.tree)
119
+ self._engine: Optional[engine.SnmpEngine] = None
120
+
121
+ # -- lifecycle -------------------------------------------------------
122
+ def start(self) -> None:
123
+ """Bind the socket and register command responders.
124
+
125
+ Must be called with an asyncio event loop running, since pysnmp's
126
+ asyncio transport attaches to the current loop.
127
+ """
128
+ snmp_engine = engine.SnmpEngine()
129
+ transport = udp.UdpTransport().open_server_mode(
130
+ (self.listen_host, self.listen_port)
131
+ )
132
+ config.add_transport(snmp_engine, udp.DOMAIN_NAME, transport)
133
+
134
+ # Read-only v1 + v2c access under one community. writeSubTree is left
135
+ # empty, so SET is rejected by access control before it ever reaches the
136
+ # instrumentation controller.
137
+ config.add_v1_system(snmp_engine, "ro-area", self.community)
138
+ for model in SECURITY_MODELS:
139
+ config.add_vacm_user(
140
+ snmp_engine, model, "ro-area", "noAuthNoPriv", VIEW_ROOT
141
+ )
142
+
143
+ snmp_context = context.SnmpContext(snmp_engine)
144
+ snmp_context.unregister_context_name(v2c.OctetString(""))
145
+ snmp_context.register_context_name(v2c.OctetString(""), self._instrum)
146
+
147
+ cmdrsp.GetCommandResponder(snmp_engine, snmp_context)
148
+ cmdrsp.NextCommandResponder(snmp_engine, snmp_context)
149
+ cmdrsp.BulkCommandResponder(snmp_engine, snmp_context)
150
+ # Registered deliberately even though writes are denied: without a SET
151
+ # responder the agent simply does not reply, and the manager sees a
152
+ # timeout that looks like the agent is down. With it, a SET gets a
153
+ # clean notWritable/noAccess error. VACM (empty writeSubTree) rejects
154
+ # the request before it reaches the instrumentation controller, whose
155
+ # write_variables is a second line of defence.
156
+ cmdrsp.SetCommandResponder(snmp_engine, snmp_context)
157
+
158
+ self._engine = snmp_engine
159
+ log.info(
160
+ "SNMP agent for %s listening on %s:%d (%d objects)",
161
+ self.ctx.switch.name, self.listen_host, self.listen_port, len(self.tree),
162
+ )
163
+
164
+ def stop(self) -> None:
165
+ if self._engine is not None:
166
+ try:
167
+ self._engine.close_dispatcher()
168
+ except Exception as exc: # pragma: no cover - shutdown best effort
169
+ log.debug("error closing dispatcher: %s", exc)
170
+ self._engine = None
171
+
172
+ # -- refresh ---------------------------------------------------------
173
+ def refresh(self) -> bool:
174
+ """Rebuild the tree if the port set changed. Returns True if rebuilt."""
175
+ signature = self.ctx.port_signature()
176
+ if signature == self._signature:
177
+ return False
178
+ log.info(
179
+ "port layout changed on %s; rebuilding OID tree", self.ctx.switch.name
180
+ )
181
+ self._signature = signature
182
+ self.tree = build_tree(self.ctx)
183
+ self._instrum.swap_tree(self.tree)
184
+ return True