initial commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install package with dev extras
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
python -m pytest -q --tb=short
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
run: |
|
||||||
|
python -m pip install build
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
- name: Upload dist artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist-${{ matrix.python-version }}
|
||||||
|
path: dist/*
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
*.whl
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Testing / coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Type checking / lint
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# IDE / editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS / misc
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# napalm-opnsense
|
||||||
|
|
||||||
|
NAPALM community driver for **OPNsense** firewalls (read-only, via REST API).
|
||||||
|
|
||||||
|
## Tested devices
|
||||||
|
|
||||||
|
| Model | OPNsense Version | Tested |
|
||||||
|
|---|---|---|
|
||||||
|
| OPNsense (virtual/bare metal) | 24.7 | ✅ |
|
||||||
|
|
||||||
|
> Additional OPNsense versions should work — contributions welcome.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Dependency | Minimum version |
|
||||||
|
|---|---|
|
||||||
|
| Python | 3.9 |
|
||||||
|
| NAPALM | 4.0 |
|
||||||
|
| requests | 2.28 |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install napalm-opnsense
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/napalm-automation-community/napalm-opnsense
|
||||||
|
cd napalm-opnsense
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from napalm import get_network_driver
|
||||||
|
|
||||||
|
driver = get_network_driver("opnsense")
|
||||||
|
with driver(
|
||||||
|
"192.168.1.1",
|
||||||
|
"my_api_key",
|
||||||
|
"my_api_secret",
|
||||||
|
optional_args={"verify": False},
|
||||||
|
) as device:
|
||||||
|
facts = device.get_facts()
|
||||||
|
print(facts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
OPNsense uses API key/secret pairs instead of username/password. Generate
|
||||||
|
a key pair in the OPNsense GUI under **System → Access → Users → edit user →
|
||||||
|
API keys**.
|
||||||
|
|
||||||
|
Pass the credentials via `optional_args`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
optional_args={
|
||||||
|
"api_key": "your-api-key",
|
||||||
|
"api_secret": "your-api-secret",
|
||||||
|
"verify": False, # set to a CA bundle path or True in production
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, pass the key/secret as the positional `username`/`password`
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
## Implemented getters
|
||||||
|
|
||||||
|
| Getter | Status | OPNsense API Endpoint |
|
||||||
|
|---|---|---|
|
||||||
|
| `get_facts` | ✅ | `GET /api/core/system/status` |
|
||||||
|
| `get_interfaces` | ✅ | `GET /api/interfaces/overview/export` |
|
||||||
|
| `get_interfaces_ip` | ✅ | `GET /api/interfaces/addresses/export` |
|
||||||
|
| `get_interfaces_counters` | ✅ | `GET /api/diagnostics/interface/get_interface_statistics` |
|
||||||
|
| `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` |
|
||||||
|
| `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` |
|
||||||
|
| `get_route_to` | ✅ | `GET /api/diagnostics/interface/get_routes` |
|
||||||
|
| `get_environment` | ✅ | `GET /api/diagnostics/system/system_resources` + `system_temperature` |
|
||||||
|
| `get_lldp_neighbors` | ✅ ¹ | `GET /api/lldpd/service/neighbor` |
|
||||||
|
| `get_lldp_neighbors_detail` | ✅ ¹ | `GET /api/lldpd/service/neighbor` |
|
||||||
|
| `get_ntp_servers` | ✅ | `GET /api/ntpd/service/status` |
|
||||||
|
| `get_config` | ✅ | `GET /api/core/backup/download/this` (XML) |
|
||||||
|
| `is_alive` | ✅ | TCP socket check |
|
||||||
|
| `get_bgp_neighbors` | ✅ ² | `GET /api/quagga/bgp/get` + `GET /api/quagga/diagnostics/bgpneighbors` |
|
||||||
|
| `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` |
|
||||||
|
| `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) |
|
||||||
|
|
||||||
|
> ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed.
|
||||||
|
> ² Requires the `os-frr` (FRR/Quagga) plugin. Returns empty dict if the plugin is not installed or FRR is not running.
|
||||||
|
|
||||||
|
## Config management
|
||||||
|
|
||||||
|
OPNsense does not expose a single generic "push config" endpoint. Config is
|
||||||
|
managed per-module via separate API controllers. This driver implements config
|
||||||
|
management for **static routes** via `/api/routes/routes/`.
|
||||||
|
|
||||||
|
> **Why routes?** Routes are the most common network-automation target on a
|
||||||
|
> firewall, and the OPNsense routes API provides full CRUD operations.
|
||||||
|
|
||||||
|
### Supported methods
|
||||||
|
|
||||||
|
| Method | OPNsense API |
|
||||||
|
|---|---|
|
||||||
|
| `load_merge_candidate(config=...)` | stages routes in memory |
|
||||||
|
| `compare_config()` | diffs against `GET /api/routes/routes/searchroute` |
|
||||||
|
| `commit_config()` | snapshots backup → `POST /api/routes/routes/addroute` × n → `reconfigure` |
|
||||||
|
| `discard_config()` | clears staged candidate |
|
||||||
|
| `rollback()` | `POST /api/core/backup/revert_backup/{id}` — restores the config.xml snapshot taken before the last commit |
|
||||||
|
|
||||||
|
### Config format
|
||||||
|
|
||||||
|
The `load_merge_candidate` `config` parameter must be a **JSON array** of route
|
||||||
|
objects, each with `network` and `gateway` keys. `gateway` must be the
|
||||||
|
**name** of an existing OPNsense gateway (as configured under
|
||||||
|
*System → Gateways → Configuration*), not an IP address.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"network": "10.0.0.0/8",
|
||||||
|
"gateway": "WAN_GW",
|
||||||
|
"descr": "Corporate internal",
|
||||||
|
"disabled": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"network": "0.0.0.0/0",
|
||||||
|
"gateway": "WAN_GW",
|
||||||
|
"descr": "Default route"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
driver = get_network_driver("opnsense")
|
||||||
|
d = driver("192.168.1.1", "user", "pass", optional_args={"api_key": "k", "api_secret": "s"})
|
||||||
|
d.open()
|
||||||
|
|
||||||
|
routes = json.dumps([{"network": "10.0.0.0/8", "gateway": "WAN_GW"}])
|
||||||
|
d.load_merge_candidate(config=routes)
|
||||||
|
|
||||||
|
print(d.compare_config()) # unified diff
|
||||||
|
d.commit_config() # applies routes and calls reconfigure
|
||||||
|
d.rollback() # removes the routes just added
|
||||||
|
d.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- `load_replace_candidate` is not supported (no XML upload endpoint in the JSON API).
|
||||||
|
- Non-route config (firewall rules, DHCP, DNS, interfaces, …) must be managed
|
||||||
|
via OPNsense module-specific controllers — outside the scope of this driver.
|
||||||
|
- `rollback()` reverts the entire `config.xml` to the pre-commit state (not just
|
||||||
|
the routes). OPNsense's backup API has no partial-restore capability.
|
||||||
|
- Rollback uses the backup snapshot taken at `commit_config()` time. If no
|
||||||
|
commit was made in the current session, the most recent available backup is
|
||||||
|
used as a fallback.
|
||||||
|
|
||||||
|
## Optional arguments
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `api_key` | `username` | OPNsense API key |
|
||||||
|
| `api_secret` | `password` | OPNsense API secret |
|
||||||
|
| `base_url` | `https://<hostname>` | Override the base URL |
|
||||||
|
| `verify` | `True` | TLS certificate verification (path or bool) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
This project includes a GitHub Actions workflow that:
|
||||||
|
- Runs unit tests across Python 3.9–3.12
|
||||||
|
- Builds sdist and wheel
|
||||||
|
- Uploads build artifacts
|
||||||
|
|
||||||
|
See [.github/workflows/ci.yml](.github/workflows/ci.yml).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0 — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
driver = OPNsenseDriver(
|
||||||
|
hostname="test.local",
|
||||||
|
username="api_key",
|
||||||
|
password="api_secret",
|
||||||
|
optional_args={
|
||||||
|
"base_url": "https://test.local",
|
||||||
|
"verify": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock API responses to avoid real network calls
|
||||||
|
def fake_get(path: str):
|
||||||
|
if path == "/api/core/system/status":
|
||||||
|
return {
|
||||||
|
"hostname": "opnsense01",
|
||||||
|
"version": "24.7",
|
||||||
|
"model": "OPNsense",
|
||||||
|
"serial": "ABC123",
|
||||||
|
"uptime": 12345,
|
||||||
|
}
|
||||||
|
if path == "/api/interfaces/overview/export":
|
||||||
|
return {
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"name": "em0",
|
||||||
|
"up": True,
|
||||||
|
"enabled": True,
|
||||||
|
"descr": "LAN",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"speed_mbps": 1000,
|
||||||
|
"mtu": 1500,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if path == "/api/interfaces/addresses/export":
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{"interface": "em0", "address": "192.0.2.10", "prefix": 24},
|
||||||
|
{"interface": "em0", "address": "2001:db8::1", "prefix": 64},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Monkeypatch before open()
|
||||||
|
driver._get = fake_get # type: ignore
|
||||||
|
|
||||||
|
driver.open()
|
||||||
|
|
||||||
|
print("Facts:")
|
||||||
|
print(driver.get_facts())
|
||||||
|
|
||||||
|
print("\nInterfaces:")
|
||||||
|
print(driver.get_interfaces())
|
||||||
|
|
||||||
|
print("\nInterfaces IP:")
|
||||||
|
print(driver.get_interfaces_ip())
|
||||||
|
|
||||||
|
driver.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from napalm_opnsense.opnsense import OPNsenseDriver
|
||||||
|
|
||||||
|
__all__ = ["OPNsenseDriver"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "napalm-opnsense"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "NAPALM driver for OPNsense (read-only via REST API)."
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
authors = [
|
||||||
|
{ name = "Christian Manivong" },
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Topic :: Utilities",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"napalm>=4.0.0",
|
||||||
|
"napalm_device_types>=0.2.0",
|
||||||
|
"requests>=2.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"black",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.entry-points."napalm.drivers"]
|
||||||
|
opnsense = "napalm_opnsense.opnsense:OPNsenseDriver"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/napalm-automation-community/napalm-opnsense"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["napalm_opnsense*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user