sonicwall-sdk 0.1.0__tar.gz
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.
- sonicwall_sdk-0.1.0/.gitignore +73 -0
- sonicwall_sdk-0.1.0/PKG-INFO +86 -0
- sonicwall_sdk-0.1.0/README.md +53 -0
- sonicwall_sdk-0.1.0/pyproject.toml +81 -0
- sonicwall_sdk-0.1.0/src/sonicwall/__init__.py +71 -0
- sonicwall_sdk-0.1.0/src/sonicwall/_auth.py +316 -0
- sonicwall_sdk-0.1.0/src/sonicwall/_client.py +393 -0
- sonicwall_sdk-0.1.0/src/sonicwall/_commit.py +121 -0
- sonicwall_sdk-0.1.0/src/sonicwall/_exceptions.py +97 -0
- sonicwall_sdk-0.1.0/src/sonicwall/_http.py +229 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/__init__.py +32 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/access_rule.py +173 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/address_object.py +176 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/dhcp.py +37 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/interface.py +71 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/nat_policy.py +122 -0
- sonicwall_sdk-0.1.0/src/sonicwall/models/service_object.py +85 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/__init__.py +17 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/_base.py +74 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/_normalize.py +54 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/access_rules.py +232 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/address_objects.py +162 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/dhcp.py +74 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/interfaces.py +48 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/nat_policies.py +205 -0
- sonicwall_sdk-0.1.0/src/sonicwall/resources/service_objects.py +174 -0
- sonicwall_sdk-0.1.0/tests/__init__.py +1 -0
- sonicwall_sdk-0.1.0/tests/conftest.py +181 -0
- sonicwall_sdk-0.1.0/tests/test_address_objects.py +417 -0
- sonicwall_sdk-0.1.0/tests/test_auth.py +266 -0
- sonicwall_sdk-0.1.0/tests/test_commit_context.py +56 -0
- sonicwall_sdk-0.1.0/tests/test_dhcp.py +69 -0
- sonicwall_sdk-0.1.0/tests/test_model_parsing_quirks.py +44 -0
- sonicwall_sdk-0.1.0/tests/test_normalize_utils.py +28 -0
- sonicwall_sdk-0.1.0/tests/test_resource_get_fallbacks.py +129 -0
- sonicwall_sdk-0.1.0/tests/test_write_schema_fallbacks.py +143 -0
- sonicwall_sdk-0.1.0/uv.lock +751 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
ENV/
|
|
10
|
+
env/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
.eggs/
|
|
15
|
+
*.egg
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
htmlcov/
|
|
20
|
+
.coverage
|
|
21
|
+
coverage.xml
|
|
22
|
+
*.cover
|
|
23
|
+
.hypothesis/
|
|
24
|
+
|
|
25
|
+
# Local pnpm tool cache (CI uses PNPM_CACHE_DIR)
|
|
26
|
+
.pnpm-home/
|
|
27
|
+
|
|
28
|
+
# Project-local uv binary (shell CI; UV_INSTALL_DIR)
|
|
29
|
+
.uv-install/
|
|
30
|
+
|
|
31
|
+
# Contract captures from local devices (optional tooling)
|
|
32
|
+
packages/python/contract-captures/
|
|
33
|
+
|
|
34
|
+
# Node / TypeScript
|
|
35
|
+
node_modules/
|
|
36
|
+
packages/typescript/dist/
|
|
37
|
+
packages/typescript/coverage/
|
|
38
|
+
packages/typescript/*.tsbuildinfo
|
|
39
|
+
.turbo/
|
|
40
|
+
.pnpm-store/
|
|
41
|
+
.pnpm-tool/
|
|
42
|
+
.pnpm-home/
|
|
43
|
+
|
|
44
|
+
# Local contract captures (live device debugging)
|
|
45
|
+
packages/python/contract-captures/
|
|
46
|
+
|
|
47
|
+
# Go
|
|
48
|
+
packages/go/bin/
|
|
49
|
+
packages/go/vendor/
|
|
50
|
+
.golangci-bin/
|
|
51
|
+
.go-toolchain/
|
|
52
|
+
|
|
53
|
+
# IDE
|
|
54
|
+
.idea/
|
|
55
|
+
.vscode/
|
|
56
|
+
*.swp
|
|
57
|
+
*.swo
|
|
58
|
+
.DS_Store
|
|
59
|
+
|
|
60
|
+
# Secrets
|
|
61
|
+
.env
|
|
62
|
+
.env.*
|
|
63
|
+
!.env.example
|
|
64
|
+
*.key
|
|
65
|
+
*.pem
|
|
66
|
+
*.p12
|
|
67
|
+
*.pfx
|
|
68
|
+
secrets/
|
|
69
|
+
|
|
70
|
+
# Build artifacts
|
|
71
|
+
*.tar.gz
|
|
72
|
+
*.whl
|
|
73
|
+
dist-info/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonicwall-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the SonicOS REST API
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/gandiva-tech/sonicwall-sdk
|
|
6
|
+
Project-URL: Documentation, https://gitlab.com/gandiva-tech/sonicwall-sdk/-/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://gitlab.com/gandiva-tech/sonicwall-sdk
|
|
8
|
+
Project-URL: Bug Tracker, https://gitlab.com/gandiva-tech/sonicwall-sdk/-/issues
|
|
9
|
+
Author-email: Gandiva Tech <engineering@gandiva.tech>
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
Keywords: api,firewall,sdk,sonicos,sonicwall
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: System :: Networking :: Firewalls
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: anyio>=4.0
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: pydantic>=2.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# SonicWall SDK for Python
|
|
35
|
+
|
|
36
|
+
Python client for SonicOS REST API with async-first APIs, sync wrapper, pending
|
|
37
|
+
config transaction helpers, and typed exceptions.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install sonicwall-sdk
|
|
43
|
+
# or
|
|
44
|
+
uv add sonicwall-sdk
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start (async)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from sonicwall import SonicWallClient
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
async with SonicWallClient(
|
|
55
|
+
host="192.168.1.1",
|
|
56
|
+
username="admin",
|
|
57
|
+
password="secret",
|
|
58
|
+
verify_ssl=False,
|
|
59
|
+
) as client:
|
|
60
|
+
objs = await client.address_objects.list()
|
|
61
|
+
print(f"address objects: {len(objs)}")
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Authentication
|
|
67
|
+
|
|
68
|
+
On SonicOS 7.x, the SDK performs Digest `auth-int` login handshake on
|
|
69
|
+
`POST /auth`, then sends `Authorization: Bearer <token>` for authenticated API
|
|
70
|
+
calls. This is automatic; no manual auth header or cookie handling is needed.
|
|
71
|
+
|
|
72
|
+
## Transactions
|
|
73
|
+
|
|
74
|
+
SonicOS stages writes in pending config. Use `pending()` to auto-commit on
|
|
75
|
+
success and auto-rollback on exceptions:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
async with client.pending():
|
|
79
|
+
await client.address_objects.create(obj)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## More docs
|
|
83
|
+
|
|
84
|
+
- Root guide: `README.md`
|
|
85
|
+
- Python guide: `docs/python.md`
|
|
86
|
+
- SonicOS quirks: `docs/sonicwall-quirks.md`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SonicWall SDK for Python
|
|
2
|
+
|
|
3
|
+
Python client for SonicOS REST API with async-first APIs, sync wrapper, pending
|
|
4
|
+
config transaction helpers, and typed exceptions.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install sonicwall-sdk
|
|
10
|
+
# or
|
|
11
|
+
uv add sonicwall-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start (async)
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import asyncio
|
|
18
|
+
from sonicwall import SonicWallClient
|
|
19
|
+
|
|
20
|
+
async def main() -> None:
|
|
21
|
+
async with SonicWallClient(
|
|
22
|
+
host="192.168.1.1",
|
|
23
|
+
username="admin",
|
|
24
|
+
password="secret",
|
|
25
|
+
verify_ssl=False,
|
|
26
|
+
) as client:
|
|
27
|
+
objs = await client.address_objects.list()
|
|
28
|
+
print(f"address objects: {len(objs)}")
|
|
29
|
+
|
|
30
|
+
asyncio.run(main())
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Authentication
|
|
34
|
+
|
|
35
|
+
On SonicOS 7.x, the SDK performs Digest `auth-int` login handshake on
|
|
36
|
+
`POST /auth`, then sends `Authorization: Bearer <token>` for authenticated API
|
|
37
|
+
calls. This is automatic; no manual auth header or cookie handling is needed.
|
|
38
|
+
|
|
39
|
+
## Transactions
|
|
40
|
+
|
|
41
|
+
SonicOS stages writes in pending config. Use `pending()` to auto-commit on
|
|
42
|
+
success and auto-rollback on exceptions:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
async with client.pending():
|
|
46
|
+
await client.address_objects.create(obj)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## More docs
|
|
50
|
+
|
|
51
|
+
- Root guide: `README.md`
|
|
52
|
+
- Python guide: `docs/python.md`
|
|
53
|
+
- SonicOS quirks: `docs/sonicwall-quirks.md`
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonicwall-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the SonicOS REST API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Gandiva Tech", email = "engineering@gandiva.tech" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["sonicwall", "sonicos", "firewall", "sdk", "api"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: System :: Networking :: Firewalls",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
"pydantic>=2.0",
|
|
30
|
+
"anyio>=4.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"pytest-cov>=5.0",
|
|
38
|
+
"respx>=0.21",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
"mypy>=1.10",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://gitlab.com/gandiva-tech/sonicwall-sdk"
|
|
45
|
+
Documentation = "https://gitlab.com/gandiva-tech/sonicwall-sdk/-/tree/main/docs"
|
|
46
|
+
Repository = "https://gitlab.com/gandiva-tech/sonicwall-sdk"
|
|
47
|
+
"Bug Tracker" = "https://gitlab.com/gandiva-tech/sonicwall-sdk/-/issues"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/sonicwall"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
src = ["src", "tests"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "UP", "B", "N", "ANN", "RUF"]
|
|
59
|
+
ignore = ["ANN401", "E501", "RUF022", "RUF100", "UP037", "E402"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.per-file-ignores]
|
|
62
|
+
"tests/**" = ["ANN", "S"]
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
python_version = "3.10"
|
|
66
|
+
strict = true
|
|
67
|
+
warn_return_any = true
|
|
68
|
+
warn_unused_configs = true
|
|
69
|
+
plugins = ["pydantic.mypy"]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
asyncio_mode = "auto"
|
|
73
|
+
testpaths = ["tests"]
|
|
74
|
+
markers = ["integration: tests requiring a real SonicWall device"]
|
|
75
|
+
|
|
76
|
+
[tool.coverage.run]
|
|
77
|
+
source = ["src/sonicwall"]
|
|
78
|
+
omit = ["*/tests/*"]
|
|
79
|
+
|
|
80
|
+
[tool.coverage.report]
|
|
81
|
+
show_missing = true
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""SonicWall SDK — Python client for the SonicOS REST API."""
|
|
2
|
+
|
|
3
|
+
from ._client import SonicWallClient, SonicWallClientSync
|
|
4
|
+
from ._exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
CommitError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
ConnectionError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
RollbackError,
|
|
13
|
+
SessionExpiredError,
|
|
14
|
+
SonicWallError,
|
|
15
|
+
SonicWallHTTPError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
AccessRule,
|
|
19
|
+
AccessRuleAction,
|
|
20
|
+
AddressObject,
|
|
21
|
+
AddressObjectType,
|
|
22
|
+
DhcpLease,
|
|
23
|
+
IcmpSpec,
|
|
24
|
+
Interface,
|
|
25
|
+
IPAssignment,
|
|
26
|
+
NatPolicy,
|
|
27
|
+
PortRange,
|
|
28
|
+
RuleAddress,
|
|
29
|
+
RulePriority,
|
|
30
|
+
RuleService,
|
|
31
|
+
ServiceObject,
|
|
32
|
+
ServiceProtocol,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Version
|
|
39
|
+
"__version__",
|
|
40
|
+
# Clients
|
|
41
|
+
"SonicWallClient",
|
|
42
|
+
"SonicWallClientSync",
|
|
43
|
+
# Exceptions
|
|
44
|
+
"SonicWallError",
|
|
45
|
+
"SonicWallHTTPError",
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"AuthorizationError",
|
|
48
|
+
"SessionExpiredError",
|
|
49
|
+
"NotFoundError",
|
|
50
|
+
"ConflictError",
|
|
51
|
+
"RateLimitError",
|
|
52
|
+
"CommitError",
|
|
53
|
+
"RollbackError",
|
|
54
|
+
"ConnectionError",
|
|
55
|
+
# Models
|
|
56
|
+
"AddressObject",
|
|
57
|
+
"AddressObjectType",
|
|
58
|
+
"AccessRule",
|
|
59
|
+
"AccessRuleAction",
|
|
60
|
+
"RuleAddress",
|
|
61
|
+
"RulePriority",
|
|
62
|
+
"RuleService",
|
|
63
|
+
"Interface",
|
|
64
|
+
"IPAssignment",
|
|
65
|
+
"NatPolicy",
|
|
66
|
+
"ServiceObject",
|
|
67
|
+
"ServiceProtocol",
|
|
68
|
+
"PortRange",
|
|
69
|
+
"IcmpSpec",
|
|
70
|
+
"DhcpLease",
|
|
71
|
+
]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Session authentication manager for the SonicOS REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._exceptions import AuthenticationError, SessionExpiredError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# SonicOS status code indicating the session has expired
|
|
19
|
+
_SESSION_EXPIRED_CODE = 1085
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Digest auth-int implementation
|
|
24
|
+
# httpx supports qop=auth but NOT qop=auth-int (body-included integrity).
|
|
25
|
+
# SonicWall TZ270 (SonicOS 7.x) requires auth-int, so we implement it here.
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_digest_challenge(www_auth: str) -> dict[str, str]:
|
|
30
|
+
"""Parse a single WWW-Authenticate: Digest ... header into a dict."""
|
|
31
|
+
# Strip leading 'Digest ' token
|
|
32
|
+
body = re.sub(r"^[Dd]igest\s+", "", www_auth.strip())
|
|
33
|
+
params: dict[str, str] = {}
|
|
34
|
+
for m in re.finditer(r'(\w+)=(?:"([^"]*?)"|([^,\s]+))', body):
|
|
35
|
+
params[m.group(1)] = m.group(2) if m.group(2) is not None else m.group(3)
|
|
36
|
+
return params
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pick_challenge(response: httpx.Response) -> dict[str, str] | None:
|
|
40
|
+
"""Return the best Digest challenge from WWW-Authenticate headers.
|
|
41
|
+
|
|
42
|
+
Prefers SHA-256 over SHA-256-sess over MD5. Requires qop to include
|
|
43
|
+
auth-int (SonicOS requirement).
|
|
44
|
+
"""
|
|
45
|
+
challenges: list[dict[str, str]] = []
|
|
46
|
+
for value in response.headers.get_list("www-authenticate"):
|
|
47
|
+
if value.lower().startswith("digest"):
|
|
48
|
+
c = _parse_digest_challenge(value)
|
|
49
|
+
if "auth-int" in c.get("qop", ""):
|
|
50
|
+
challenges.append(c)
|
|
51
|
+
|
|
52
|
+
if not challenges:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def _priority(c: dict[str, str]) -> int:
|
|
56
|
+
alg = c.get("algorithm", "MD5").upper()
|
|
57
|
+
if alg == "SHA-256":
|
|
58
|
+
return 0
|
|
59
|
+
if alg == "SHA-256-SESS":
|
|
60
|
+
return 1
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
return min(challenges, key=_priority)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_digest_auth_header(
|
|
67
|
+
method: str,
|
|
68
|
+
url: str,
|
|
69
|
+
body: bytes,
|
|
70
|
+
username: str,
|
|
71
|
+
password: str,
|
|
72
|
+
challenge: dict[str, str],
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Build an Authorization: Digest header for qop=auth-int."""
|
|
75
|
+
algorithm = challenge.get("algorithm", "MD5").upper()
|
|
76
|
+
realm = challenge["realm"]
|
|
77
|
+
nonce = challenge["nonce"]
|
|
78
|
+
opaque = challenge.get("opaque", "")
|
|
79
|
+
|
|
80
|
+
# URI is path + query only
|
|
81
|
+
parsed = urlparse(url)
|
|
82
|
+
uri = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
|
83
|
+
|
|
84
|
+
# Choose hash function
|
|
85
|
+
if "SHA-256" in algorithm:
|
|
86
|
+
|
|
87
|
+
def h(s: str) -> str:
|
|
88
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
89
|
+
|
|
90
|
+
def hb(b: bytes) -> str:
|
|
91
|
+
return hashlib.sha256(b).hexdigest()
|
|
92
|
+
else:
|
|
93
|
+
|
|
94
|
+
def h(s: str) -> str:
|
|
95
|
+
return hashlib.md5(s.encode()).hexdigest() # noqa: S324
|
|
96
|
+
|
|
97
|
+
def hb(b: bytes) -> str:
|
|
98
|
+
return hashlib.md5(b).hexdigest() # noqa: S324
|
|
99
|
+
|
|
100
|
+
cnonce = os.urandom(8).hex()
|
|
101
|
+
nc = "00000001"
|
|
102
|
+
|
|
103
|
+
# HA1
|
|
104
|
+
ha1 = h(f"{username}:{realm}:{password}")
|
|
105
|
+
if "SESS" in algorithm:
|
|
106
|
+
ha1 = h(f"{ha1}:{nonce}:{cnonce}")
|
|
107
|
+
|
|
108
|
+
# HA2 — auth-int includes hash of request body
|
|
109
|
+
ha2 = h(f"{method}:{uri}:{hb(body)}")
|
|
110
|
+
|
|
111
|
+
# Final response
|
|
112
|
+
digest_response = h(f"{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}")
|
|
113
|
+
|
|
114
|
+
header = (
|
|
115
|
+
f'Digest username="{username}", realm="{realm}", '
|
|
116
|
+
f'nonce="{nonce}", uri="{uri}", '
|
|
117
|
+
f"algorithm={algorithm}, "
|
|
118
|
+
f'qop=auth-int, nc={nc}, cnonce="{cnonce}", '
|
|
119
|
+
f'response="{digest_response}"'
|
|
120
|
+
)
|
|
121
|
+
if opaque:
|
|
122
|
+
header += f', opaque="{opaque}"'
|
|
123
|
+
return header
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Cookie / session helpers
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_bearer_token(response: httpx.Response) -> str | None:
|
|
132
|
+
"""Extract the bearer_token from a successful SonicOS /auth response body."""
|
|
133
|
+
try:
|
|
134
|
+
body = response.json()
|
|
135
|
+
info_list = body.get("status", {}).get("info", [])
|
|
136
|
+
for item in info_list:
|
|
137
|
+
token = item.get("bearer_token")
|
|
138
|
+
if token:
|
|
139
|
+
return str(token)
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
pass
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_session_expired(response: httpx.Response) -> bool:
|
|
146
|
+
"""Return True if the response body indicates SonicOS session expiry."""
|
|
147
|
+
try:
|
|
148
|
+
body = response.json()
|
|
149
|
+
info_list = body.get("status", {}).get("info", [])
|
|
150
|
+
for item in info_list:
|
|
151
|
+
if item.get("code") == _SESSION_EXPIRED_CODE:
|
|
152
|
+
return True
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
pass
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# AuthManager
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AuthManager:
|
|
164
|
+
"""Manages SonicOS session authentication.
|
|
165
|
+
|
|
166
|
+
SonicOS 7.x requires HTTP Digest auth with qop=auth-int (body integrity).
|
|
167
|
+
httpx does not support auth-int, so we implement the handshake manually:
|
|
168
|
+
1. POST /auth without credentials → 401 with Digest challenge
|
|
169
|
+
2. Compute Authorization header with auth-int
|
|
170
|
+
3. POST /auth again with the computed header → 200 + JWT bearer_token in body
|
|
171
|
+
Subsequent requests use Authorization: Bearer <token>.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, base_url: str, username: str, password: str) -> None:
|
|
175
|
+
self._base_url = base_url.rstrip("/")
|
|
176
|
+
self._username = username
|
|
177
|
+
self._password = password
|
|
178
|
+
self._bearer_token: str | None = None
|
|
179
|
+
self._lock = asyncio.Lock()
|
|
180
|
+
self._authenticated = False
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def is_authenticated(self) -> bool:
|
|
184
|
+
return self._authenticated and self._bearer_token is not None
|
|
185
|
+
|
|
186
|
+
async def authenticate(self, client: httpx.AsyncClient) -> None:
|
|
187
|
+
"""Perform Digest auth-int handshake against POST /auth."""
|
|
188
|
+
auth_url = f"{self._base_url}/auth"
|
|
189
|
+
body = b"{}"
|
|
190
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
191
|
+
|
|
192
|
+
logger.debug("Authenticating to %s (step 1 — get challenge)", auth_url)
|
|
193
|
+
|
|
194
|
+
# Step 1: unauthenticated request to get the Digest challenge
|
|
195
|
+
challenge_response = await client.post(auth_url, headers=headers, content=body)
|
|
196
|
+
|
|
197
|
+
if challenge_response.status_code != 401:
|
|
198
|
+
raise AuthenticationError(
|
|
199
|
+
status_code=challenge_response.status_code,
|
|
200
|
+
message=f"Expected 401 Digest challenge, got {challenge_response.status_code}",
|
|
201
|
+
response_body=self._safe_json(challenge_response),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
challenge = _pick_challenge(challenge_response)
|
|
205
|
+
if not challenge:
|
|
206
|
+
raise AuthenticationError(
|
|
207
|
+
status_code=401,
|
|
208
|
+
message="Server returned 401 but no usable Digest auth-int challenge",
|
|
209
|
+
response_body=self._safe_json(challenge_response),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
logger.debug(
|
|
213
|
+
"Got Digest challenge: algorithm=%s realm=%s",
|
|
214
|
+
challenge.get("algorithm"),
|
|
215
|
+
challenge.get("realm"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Step 2: authenticated request with computed Digest header
|
|
219
|
+
auth_header = _build_digest_auth_header(
|
|
220
|
+
method="POST",
|
|
221
|
+
url=auth_url,
|
|
222
|
+
body=body,
|
|
223
|
+
username=self._username,
|
|
224
|
+
password=self._password,
|
|
225
|
+
challenge=challenge,
|
|
226
|
+
)
|
|
227
|
+
authed_headers = {**headers, "Authorization": auth_header}
|
|
228
|
+
|
|
229
|
+
logger.debug("Authenticating to %s (step 2 — send credentials)", auth_url)
|
|
230
|
+
response = await client.post(auth_url, headers=authed_headers, content=body)
|
|
231
|
+
|
|
232
|
+
if response.status_code == 401:
|
|
233
|
+
raise AuthenticationError(
|
|
234
|
+
status_code=401,
|
|
235
|
+
message="Authentication failed: invalid username or password",
|
|
236
|
+
response_body=self._safe_json(response),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not response.is_success:
|
|
240
|
+
raise AuthenticationError(
|
|
241
|
+
status_code=response.status_code,
|
|
242
|
+
message=f"Authentication failed with status {response.status_code}",
|
|
243
|
+
response_body=self._safe_json(response),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# SonicOS 7.x returns a JWT bearer_token in the response body (not a cookie)
|
|
247
|
+
token = _extract_bearer_token(response)
|
|
248
|
+
if not token:
|
|
249
|
+
raise AuthenticationError(
|
|
250
|
+
status_code=response.status_code,
|
|
251
|
+
message="Authentication succeeded but no bearer_token in response body",
|
|
252
|
+
response_body=self._safe_json(response),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
self._bearer_token = token
|
|
256
|
+
self._authenticated = True
|
|
257
|
+
logger.debug("Authenticated; bearer token acquired")
|
|
258
|
+
|
|
259
|
+
async def ensure_authenticated(self, client: httpx.AsyncClient) -> None:
|
|
260
|
+
"""Ensure a valid session exists, re-authenticating if necessary."""
|
|
261
|
+
if self.is_authenticated:
|
|
262
|
+
return
|
|
263
|
+
async with self._lock:
|
|
264
|
+
if not self.is_authenticated:
|
|
265
|
+
await self.authenticate(client)
|
|
266
|
+
|
|
267
|
+
async def reauthenticate(self, client: httpx.AsyncClient) -> None:
|
|
268
|
+
"""Force re-authentication, discarding the existing token."""
|
|
269
|
+
async with self._lock:
|
|
270
|
+
self._authenticated = False
|
|
271
|
+
self._bearer_token = None
|
|
272
|
+
await self.authenticate(client)
|
|
273
|
+
|
|
274
|
+
async def logout(self, client: httpx.AsyncClient) -> None:
|
|
275
|
+
"""Destroy the current session via DELETE /auth."""
|
|
276
|
+
if not self.is_authenticated:
|
|
277
|
+
return
|
|
278
|
+
auth_url = f"{self._base_url}/auth"
|
|
279
|
+
try:
|
|
280
|
+
await client.delete(
|
|
281
|
+
auth_url,
|
|
282
|
+
headers={**self._auth_headers(), "Accept": "application/json"},
|
|
283
|
+
)
|
|
284
|
+
logger.debug("Logged out successfully")
|
|
285
|
+
except Exception as exc: # noqa: BLE001
|
|
286
|
+
logger.warning("Logout request failed (ignoring): %s", exc)
|
|
287
|
+
finally:
|
|
288
|
+
self._bearer_token = None
|
|
289
|
+
self._authenticated = False
|
|
290
|
+
|
|
291
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
292
|
+
if self._bearer_token:
|
|
293
|
+
return {"Authorization": f"Bearer {self._bearer_token}"}
|
|
294
|
+
return {}
|
|
295
|
+
|
|
296
|
+
def check_response_for_session_expiry(self, response: httpx.Response) -> bool:
|
|
297
|
+
if response.status_code == 401 and _is_session_expired(response):
|
|
298
|
+
return True
|
|
299
|
+
if response.is_success and _is_session_expired(response):
|
|
300
|
+
return True
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
def raise_if_session_expired(self, response: httpx.Response) -> None:
|
|
304
|
+
if self.check_response_for_session_expiry(response):
|
|
305
|
+
raise SessionExpiredError(
|
|
306
|
+
status_code=response.status_code,
|
|
307
|
+
message="SonicOS session expired; re-authentication required",
|
|
308
|
+
response_body=self._safe_json(response),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _safe_json(response: httpx.Response) -> dict: # type: ignore[type-arg]
|
|
313
|
+
try:
|
|
314
|
+
return response.json() # type: ignore[no-any-return]
|
|
315
|
+
except Exception: # noqa: BLE001
|
|
316
|
+
return {}
|