UGOS 1.0.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.
Files changed (67) hide show
  1. ugos-1.0.0/LICENSE +16 -0
  2. ugos-1.0.0/PKG-INFO +139 -0
  3. ugos-1.0.0/README.md +113 -0
  4. ugos-1.0.0/pyproject.toml +47 -0
  5. ugos-1.0.0/src/ugos/__about__.py +6 -0
  6. ugos-1.0.0/src/ugos/__init__.py +92 -0
  7. ugos-1.0.0/src/ugos/_auth.py +293 -0
  8. ugos-1.0.0/src/ugos/_client_base.py +52 -0
  9. ugos-1.0.0/src/ugos/_client_calls.py +200 -0
  10. ugos-1.0.0/src/ugos/_client_helpers.py +217 -0
  11. ugos-1.0.0/src/ugos/_client_lifecycle.py +144 -0
  12. ugos-1.0.0/src/ugos/_client_resources.py +192 -0
  13. ugos-1.0.0/src/ugos/_client_transfer.py +501 -0
  14. ugos-1.0.0/src/ugos/_codec.py +528 -0
  15. ugos-1.0.0/src/ugos/_config.py +183 -0
  16. ugos-1.0.0/src/ugos/_data/__init__.py +1 -0
  17. ugos-1.0.0/src/ugos/_data/action_contracts.json +325 -0
  18. ugos-1.0.0/src/ugos/_data/catalog.json +1256 -0
  19. ugos-1.0.0/src/ugos/_dynamic.py +221 -0
  20. ugos-1.0.0/src/ugos/_encoding.py +159 -0
  21. ugos-1.0.0/src/ugos/_pagination.py +158 -0
  22. ugos-1.0.0/src/ugos/_redaction.py +158 -0
  23. ugos-1.0.0/src/ugos/_transport.py +446 -0
  24. ugos-1.0.0/src/ugos/catalog.py +184 -0
  25. ugos-1.0.0/src/ugos/client.py +113 -0
  26. ugos-1.0.0/src/ugos/exceptions.py +137 -0
  27. ugos-1.0.0/src/ugos/models/__init__.py +37 -0
  28. ugos-1.0.0/src/ugos/models/common.py +87 -0
  29. ugos-1.0.0/src/ugos/models/entities.py +124 -0
  30. ugos-1.0.0/src/ugos/models/users.py +43 -0
  31. ugos-1.0.0/src/ugos/py.typed +0 -0
  32. ugos-1.0.0/src/ugos/resources/__init__.py +55 -0
  33. ugos-1.0.0/src/ugos/resources/administrators.py +71 -0
  34. ugos-1.0.0/src/ugos/resources/application.py +107 -0
  35. ugos-1.0.0/src/ugos/resources/base.py +38 -0
  36. ugos-1.0.0/src/ugos/resources/certificates.py +62 -0
  37. ugos-1.0.0/src/ugos/resources/contentrules.py +98 -0
  38. ugos-1.0.0/src/ugos/resources/contenttypes.py +9 -0
  39. ugos-1.0.0/src/ugos/resources/fwrules.py +103 -0
  40. ugos-1.0.0/src/ugos/resources/generic.py +283 -0
  41. ugos-1.0.0/src/ugos/resources/generic_controllers.py +169 -0
  42. ugos-1.0.0/src/ugos/resources/groups.py +94 -0
  43. ugos-1.0.0/src/ugos/resources/icap.py +132 -0
  44. ugos-1.0.0/src/ugos/resources/idps.py +125 -0
  45. ugos-1.0.0/src/ugos/resources/interfaces.py +40 -0
  46. ugos-1.0.0/src/ugos/resources/l7.py +110 -0
  47. ugos-1.0.0/src/ugos/resources/licensing.py +39 -0
  48. ugos-1.0.0/src/ugos/resources/mailsecurity.py +101 -0
  49. ugos-1.0.0/src/ugos/resources/networks.py +82 -0
  50. ugos-1.0.0/src/ugos/resources/netzones.py +78 -0
  51. ugos-1.0.0/src/ugos/resources/nlist.py +72 -0
  52. ugos-1.0.0/src/ugos/resources/overridedomains.py +47 -0
  53. ugos-1.0.0/src/ugos/resources/overrides.py +109 -0
  54. ugos-1.0.0/src/ugos/resources/overrides_content.py +254 -0
  55. ugos-1.0.0/src/ugos/resources/overrides_content_a.py +187 -0
  56. ugos-1.0.0/src/ugos/resources/overrides_lists.py +294 -0
  57. ugos-1.0.0/src/ugos/resources/overrides_misc.py +342 -0
  58. ugos-1.0.0/src/ugos/resources/overrides_network.py +291 -0
  59. ugos-1.0.0/src/ugos/resources/pcap.py +64 -0
  60. ugos-1.0.0/src/ugos/resources/registry.py +55 -0
  61. ugos-1.0.0/src/ugos/resources/settings.py +72 -0
  62. ugos-1.0.0/src/ugos/resources/sysinfo.py +39 -0
  63. ugos-1.0.0/src/ugos/resources/twofa.py +62 -0
  64. ugos-1.0.0/src/ugos/resources/urlcategory.py +69 -0
  65. ugos-1.0.0/src/ugos/resources/urlfiltering.py +23 -0
  66. ugos-1.0.0/src/ugos/resources/urllists.py +9 -0
  67. ugos-1.0.0/src/ugos/resources/users.py +74 -0
ugos-1.0.0/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT No Attribution
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
13
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
14
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16
+ SOFTWARE.
ugos-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: UGOS
3
+ Version: 1.0.0
4
+ Summary: Python client for the UserGate NGFW web-console API
5
+ Project-URL: Repository, https://codeberg.org/owldog/ugos
6
+ License-Expression: MIT-0
7
+ License-File: LICENSE
8
+ Keywords: api,firewall,httpx,ngfw,ugos
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Networking :: Firewalls
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx<0.29,>=0.27
25
+ Description-Content-Type: text/markdown
26
+
27
+ # UGOS
28
+
29
+ Synchronous Python client for the UserGate NGFW web-console API.
30
+
31
+ - Python 3.10+
32
+ - `httpx` transport
33
+ - compatible with UserGate NGFW 7.x
34
+ - typed resources plus exact access to every catalog action
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ uv add UGOS
40
+ ```
41
+
42
+ From a local wheel:
43
+
44
+ ```bash
45
+ uv add ./dist/ugos-*.whl
46
+ ```
47
+
48
+ ## Quick start
49
+
50
+ ### Environment variables
51
+
52
+ ```bash
53
+ export UGOS_URL=https://ngfw.example:8001
54
+ export UGOS_USERNAME=Admin
55
+ export UGOS_PASSWORD=...
56
+ export UGOS_VERIFY=true
57
+ export UGOS_TIMEOUT=30
58
+ ```
59
+
60
+ ```python
61
+ from ugos import UGOSClient
62
+
63
+ with UGOSClient.from_env() as client:
64
+ client.login()
65
+
66
+ print(client.licensing.get_info())
67
+ print(client.users.list(limit=20).total)
68
+
69
+ for user in client.users.iter_all(page_size=100):
70
+ print(user["name"])
71
+ ```
72
+
73
+ `from_env()` reads:
74
+
75
+ | Parameter | Environment variable | Default |
76
+ |---|---|---|
77
+ | `base_url` | `UGOS_URL` | required |
78
+ | `username` | `UGOS_USERNAME` | `None` |
79
+ | `password` | `UGOS_PASSWORD` | `None` |
80
+ | `verify` | `UGOS_CA_BUNDLE`, `UGOS_VERIFY` | `True` |
81
+ | `timeout` | `UGOS_TIMEOUT` | `30.0` |
82
+ | `allow_insecure_http` | `UGOS_ALLOW_INSECURE_HTTP` | `False` |
83
+
84
+ `UGOS_CA_BUNDLE` takes precedence over `UGOS_VERIFY` and accepts a path to a
85
+ CA bundle. `UGOS_VERIFY` accepts `true`, `false`, `1`, `0`, `yes`, `no`,
86
+ `on`, `off`, or a CA path.
87
+
88
+ ### Explicit configuration
89
+
90
+ ```python
91
+ with UGOSClient(
92
+ base_url="https://ngfw.example:8001",
93
+ username="Admin",
94
+ password="...",
95
+ verify="/path/to/ca.pem",
96
+ timeout=30,
97
+ ) as client:
98
+ client.login()
99
+ print(client.users.list(limit=20).total)
100
+ ```
101
+
102
+ `from_env()` also accepts keyword overrides, which take precedence over
103
+ environment variables:
104
+
105
+ ```python
106
+ with UGOSClient.from_env(password="...", verify=False) as client:
107
+ client.login()
108
+ ```
109
+
110
+ ## Generic and typed access
111
+
112
+ ```python
113
+ client.users.list(limit=50) # typed helper
114
+ client.users.actions.UsersList(limit=50) # exact action
115
+ client.call("users", "UsersList", limit=50) # raw call
116
+ client.dhcp.list() # generic resource
117
+ ```
118
+
119
+ ## Errors
120
+
121
+ ```python
122
+ from ugos import NotFoundError, UGOSError
123
+
124
+ try:
125
+ client.users.get(999)
126
+ except NotFoundError as exc:
127
+ print(exc.code, exc.message)
128
+ except UGOSError as exc:
129
+ print(exc)
130
+ ```
131
+
132
+ ## Compatibility
133
+
134
+ Compatible with UserGate NGFW 7.x. For builds with a different action set, pass
135
+ `catalog_check=False` or `strict=False`.
136
+
137
+ ## License
138
+
139
+ MIT-0. See `LICENSE`.
ugos-1.0.0/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # UGOS
2
+
3
+ Synchronous Python client for the UserGate NGFW web-console API.
4
+
5
+ - Python 3.10+
6
+ - `httpx` transport
7
+ - compatible with UserGate NGFW 7.x
8
+ - typed resources plus exact access to every catalog action
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ uv add UGOS
14
+ ```
15
+
16
+ From a local wheel:
17
+
18
+ ```bash
19
+ uv add ./dist/ugos-*.whl
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ### Environment variables
25
+
26
+ ```bash
27
+ export UGOS_URL=https://ngfw.example:8001
28
+ export UGOS_USERNAME=Admin
29
+ export UGOS_PASSWORD=...
30
+ export UGOS_VERIFY=true
31
+ export UGOS_TIMEOUT=30
32
+ ```
33
+
34
+ ```python
35
+ from ugos import UGOSClient
36
+
37
+ with UGOSClient.from_env() as client:
38
+ client.login()
39
+
40
+ print(client.licensing.get_info())
41
+ print(client.users.list(limit=20).total)
42
+
43
+ for user in client.users.iter_all(page_size=100):
44
+ print(user["name"])
45
+ ```
46
+
47
+ `from_env()` reads:
48
+
49
+ | Parameter | Environment variable | Default |
50
+ |---|---|---|
51
+ | `base_url` | `UGOS_URL` | required |
52
+ | `username` | `UGOS_USERNAME` | `None` |
53
+ | `password` | `UGOS_PASSWORD` | `None` |
54
+ | `verify` | `UGOS_CA_BUNDLE`, `UGOS_VERIFY` | `True` |
55
+ | `timeout` | `UGOS_TIMEOUT` | `30.0` |
56
+ | `allow_insecure_http` | `UGOS_ALLOW_INSECURE_HTTP` | `False` |
57
+
58
+ `UGOS_CA_BUNDLE` takes precedence over `UGOS_VERIFY` and accepts a path to a
59
+ CA bundle. `UGOS_VERIFY` accepts `true`, `false`, `1`, `0`, `yes`, `no`,
60
+ `on`, `off`, or a CA path.
61
+
62
+ ### Explicit configuration
63
+
64
+ ```python
65
+ with UGOSClient(
66
+ base_url="https://ngfw.example:8001",
67
+ username="Admin",
68
+ password="...",
69
+ verify="/path/to/ca.pem",
70
+ timeout=30,
71
+ ) as client:
72
+ client.login()
73
+ print(client.users.list(limit=20).total)
74
+ ```
75
+
76
+ `from_env()` also accepts keyword overrides, which take precedence over
77
+ environment variables:
78
+
79
+ ```python
80
+ with UGOSClient.from_env(password="...", verify=False) as client:
81
+ client.login()
82
+ ```
83
+
84
+ ## Generic and typed access
85
+
86
+ ```python
87
+ client.users.list(limit=50) # typed helper
88
+ client.users.actions.UsersList(limit=50) # exact action
89
+ client.call("users", "UsersList", limit=50) # raw call
90
+ client.dhcp.list() # generic resource
91
+ ```
92
+
93
+ ## Errors
94
+
95
+ ```python
96
+ from ugos import NotFoundError, UGOSError
97
+
98
+ try:
99
+ client.users.get(999)
100
+ except NotFoundError as exc:
101
+ print(exc.code, exc.message)
102
+ except UGOSError as exc:
103
+ print(exc)
104
+ ```
105
+
106
+ ## Compatibility
107
+
108
+ Compatible with UserGate NGFW 7.x. For builds with a different action set, pass
109
+ `catalog_check=False` or `strict=False`.
110
+
111
+ ## License
112
+
113
+ MIT-0. See `LICENSE`.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "UGOS"
7
+ dynamic = ["version"]
8
+ description = "Python client for the UserGate NGFW web-console API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT-0"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["ugos", "ngfw", "firewall", "api", "httpx"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Typing :: Typed",
27
+ "Topic :: System :: Networking :: Firewalls",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+ dependencies = ["httpx>=0.27,<0.29"]
31
+
32
+ [project.urls]
33
+ Repository = "https://codeberg.org/owldog/ugos"
34
+
35
+ [tool.hatch.version]
36
+ path = "src/ugos/__about__.py"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/ugos"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = [
43
+ "/src",
44
+ "/README.md",
45
+ "/LICENSE",
46
+ "/pyproject.toml",
47
+ ]
@@ -0,0 +1,6 @@
1
+ """Package metadata."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ SUPPORTED_MAJOR_VERSION = 7
6
+ SUPPORTED_NGFW = f"{SUPPORTED_MAJOR_VERSION}.x"
@@ -0,0 +1,92 @@
1
+ """Python client for the UGOS NGFW web-console API."""
2
+
3
+ from .__about__ import SUPPORTED_MAJOR_VERSION, SUPPORTED_NGFW, __version__
4
+ from .catalog import Catalog, load_catalog
5
+ from .client import UGOSClient
6
+ from .exceptions import (
7
+ AccessDeniedError,
8
+ APIError,
9
+ AuthenticationError,
10
+ CatalogError,
11
+ CleanupError,
12
+ ConfigurationError,
13
+ ConflictError,
14
+ CSRFError,
15
+ DecodeError,
16
+ DownloadError,
17
+ DuplicateError,
18
+ HTTPStatusError,
19
+ NotFoundError,
20
+ ProtocolError,
21
+ ReadOnlyListError,
22
+ RequestTimeoutError,
23
+ ServerError,
24
+ SessionExpiredError,
25
+ TransportError,
26
+ UnknownActionError,
27
+ UnknownControllerError,
28
+ UGOSError,
29
+ ValidationError,
30
+ )
31
+ from .models.common import DownloadedFile, ListResult, SessionInfo
32
+ from .models.entities import (
33
+ Administrator,
34
+ Certificate,
35
+ FirewallRule,
36
+ Group,
37
+ Interface,
38
+ LicenseInfo,
39
+ NetworkList,
40
+ PcapFilter,
41
+ PcapRule,
42
+ Tag,
43
+ Zone,
44
+ )
45
+ from .resources.base import Resource
46
+
47
+ __all__ = [
48
+ "SUPPORTED_MAJOR_VERSION",
49
+ "SUPPORTED_NGFW",
50
+ "APIError",
51
+ "AccessDeniedError",
52
+ "Administrator",
53
+ "AuthenticationError",
54
+ "CSRFError",
55
+ "Catalog",
56
+ "CatalogError",
57
+ "Certificate",
58
+ "CleanupError",
59
+ "ConfigurationError",
60
+ "ConflictError",
61
+ "DecodeError",
62
+ "DownloadError",
63
+ "DownloadedFile",
64
+ "DuplicateError",
65
+ "FirewallRule",
66
+ "Group",
67
+ "HTTPStatusError",
68
+ "Interface",
69
+ "LicenseInfo",
70
+ "ListResult",
71
+ "NetworkList",
72
+ "NotFoundError",
73
+ "PcapFilter",
74
+ "PcapRule",
75
+ "ProtocolError",
76
+ "ReadOnlyListError",
77
+ "RequestTimeoutError",
78
+ "Resource",
79
+ "ServerError",
80
+ "SessionExpiredError",
81
+ "SessionInfo",
82
+ "Tag",
83
+ "TransportError",
84
+ "UnknownActionError",
85
+ "UnknownControllerError",
86
+ "UGOSClient",
87
+ "UGOSError",
88
+ "ValidationError",
89
+ "Zone",
90
+ "__version__",
91
+ "load_catalog",
92
+ ]
@@ -0,0 +1,293 @@
1
+ """Console authentication, CSRF handling and session lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping
8
+ from typing import Any, cast
9
+
10
+ import httpx
11
+
12
+ from ._codec import is_error_payload, parse_response
13
+ from ._encoding import encode_params
14
+ from ._redaction import redact_mapping
15
+ from ._transport import FORM_HEADERS, HTML_HEADERS, HttpTransport
16
+ from .exceptions import (
17
+ AuthenticationError,
18
+ CSRFError,
19
+ DecodeError,
20
+ HTTPStatusError,
21
+ ProtocolError,
22
+ SessionExpiredError,
23
+ UGOSError,
24
+ )
25
+ from .models.common import SessionInfo
26
+
27
+ _CSRF_RE = re.compile(r"CSRF_TOKEN\s*=\s*['\"]([^'\"]+)['\"]")
28
+ _CSRF_INPUT_RE = re.compile(
29
+ r"name=['\"]csrfmiddlewaretoken['\"][^>]*value=['\"]([^'\"]+)['\"]",
30
+ re.IGNORECASE,
31
+ )
32
+ _CSRF_META_RE = re.compile(
33
+ r"name=['\"]csrf-token['\"][^>]*content=['\"]([^'\"]+)['\"]",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ class AuthManager:
39
+ """Owns login/logout, cookies and the CSRF token."""
40
+
41
+ def __init__(
42
+ self,
43
+ transport: HttpTransport,
44
+ *,
45
+ username: str | None = None,
46
+ password: str | None = None,
47
+ login_path: str = "utm/login",
48
+ logout_path: str = "utm/CoreLogout",
49
+ auto_reauth: bool = True,
50
+ ) -> None:
51
+ self._transport = transport
52
+ self._username = username
53
+ self._password = password
54
+ self._login_path = login_path
55
+ self._logout_path = logout_path
56
+ self._auto_reauth = auto_reauth
57
+ self._session: SessionInfo | None = None
58
+ self._csrf: str | None = None
59
+ self._generation = 0
60
+
61
+ @property
62
+ def session(self) -> SessionInfo | None:
63
+ return self._session
64
+
65
+ @property
66
+ def authenticated(self) -> bool:
67
+ return self._session is not None
68
+
69
+ @property
70
+ def csrf_token(self) -> str | None:
71
+ return self._csrf
72
+
73
+ @property
74
+ def generation(self) -> int:
75
+ return self._generation
76
+
77
+ def invalidate(self) -> None:
78
+ self._session = None
79
+ self._csrf = None
80
+ self._generation += 1
81
+ self._transport.clear_cookies(("_console_session", "csrf_token"))
82
+
83
+ @property
84
+ def auto_reauth(self) -> bool:
85
+ return self._auto_reauth
86
+
87
+ def ensure_csrf(self) -> str:
88
+ if self._csrf:
89
+ return self._csrf
90
+ token = self._transport.get_cookie("csrf_token")
91
+ if token:
92
+ self._csrf = token
93
+ return token
94
+ return self.refresh_csrf()
95
+
96
+ @staticmethod
97
+ def _is_login_redirect(status_code: int, location: str) -> bool:
98
+ if status_code not in {301, 302, 303, 307, 308}:
99
+ return False
100
+ return "utm/login" in location or location.rstrip("/").endswith("/login")
101
+
102
+ def _raise_csrf_status(self, response: httpx.Response) -> None:
103
+ location = response.headers.get("location", "")
104
+ login_redirect = self._is_login_redirect(response.status_code, location)
105
+ if response.status_code == 401 or login_redirect:
106
+ raise SessionExpiredError(
107
+ "session expired while refreshing CSRF",
108
+ status_code=response.status_code,
109
+ )
110
+ if response.status_code >= 400:
111
+ raise CSRFError(
112
+ f"CSRF refresh failed with HTTP {response.status_code}",
113
+ status_code=response.status_code,
114
+ )
115
+
116
+ def _extract_csrf_token(self, response: httpx.Response) -> str:
117
+ token = self._transport.get_cookie("csrf_token") or _extract_csrf(response.text)
118
+ if not token:
119
+ raise CSRFError("the login page did not contain a CSRF token")
120
+ return token
121
+
122
+ def refresh_csrf(self) -> str:
123
+ path = "/" if self.authenticated else self._login_path
124
+ try:
125
+ response = self._transport.request(
126
+ "GET",
127
+ path,
128
+ headers=HTML_HEADERS,
129
+ idempotent=True,
130
+ )
131
+ except UGOSError:
132
+ raise
133
+ except Exception as exc:
134
+ raise CSRFError(f"failed to obtain a CSRF token: {exc}") from exc
135
+ self._raise_csrf_status(response)
136
+ self.note_response(response)
137
+ self._csrf = self._extract_csrf_token(response)
138
+ return self._csrf
139
+
140
+ def _reset_session(self) -> None:
141
+ self._transport.clear_cookies(("_console_session", "csrf_token"))
142
+ self._session = None
143
+ self._csrf = None
144
+
145
+ def _login_page_token(self) -> str:
146
+ page = self._transport.request(
147
+ "GET", self._login_path, headers=HTML_HEADERS, idempotent=True
148
+ )
149
+ self.note_response(page)
150
+ return self._extract_csrf_token(page)
151
+
152
+ def _post_login(self, token: str) -> httpx.Response:
153
+ body = encode_params(
154
+ {
155
+ "username": self._username,
156
+ "password": self._password,
157
+ "csrfmiddlewaretoken": token,
158
+ }
159
+ )
160
+ response = self._transport.request(
161
+ "POST",
162
+ self._login_path,
163
+ data=body,
164
+ headers=FORM_HEADERS,
165
+ idempotent=False,
166
+ follow_redirects=False,
167
+ )
168
+ self.note_response(response)
169
+ return response
170
+
171
+ def _parse_login_response(self, response: httpx.Response) -> Mapping[str, Any]:
172
+ try:
173
+ payload = parse_response(response, expect="json")
174
+ except DecodeError as exc:
175
+ raise AuthenticationError(
176
+ "login returned a non-JSON response",
177
+ status_code=response.status_code,
178
+ ) from exc
179
+ except (HTTPStatusError, ProtocolError) as exc:
180
+ raise AuthenticationError(
181
+ f"login failed: {exc}",
182
+ status_code=getattr(exc, "status_code", response.status_code),
183
+ payload=getattr(exc, "payload", None),
184
+ ) from exc
185
+ if not isinstance(payload, Mapping) or not payload.get("auth_token"):
186
+ raise AuthenticationError("login was rejected by the appliance")
187
+ return payload
188
+
189
+ def _build_session(self, payload: Mapping[str, Any], token: str) -> SessionInfo:
190
+ session_cookie = self._transport.get_cookie("_console_session")
191
+ if not session_cookie:
192
+ raise ProtocolError("login succeeded but _console_session cookie is absent")
193
+ self._csrf = self._transport.get_cookie("csrf_token") or token
194
+ self._session = SessionInfo(
195
+ username=cast(str, self._username),
196
+ admin_id=_as_int(payload.get("admin_id")),
197
+ product=_as_str(payload.get("product")),
198
+ version=_as_str(payload.get("version")),
199
+ auth_token=_as_bool(payload.get("auth_token")),
200
+ fingerprint=hashlib.sha256(session_cookie.encode()).hexdigest()[:12],
201
+ raw=redact_mapping(payload),
202
+ )
203
+ self._generation += 1
204
+ return self._session
205
+
206
+ def _has_reusable_session(self, force: bool) -> bool:
207
+ return self._session is not None and not force
208
+
209
+ def _credentials_missing(self) -> bool:
210
+ return not self._username or self._password is None
211
+
212
+ def _should_reset_session(self, force: bool) -> bool:
213
+ return force or self._session is not None
214
+
215
+ def login(self, *, force: bool = False) -> SessionInfo:
216
+ if self._has_reusable_session(force):
217
+ return self._session # type: ignore[return-value]
218
+ if self._credentials_missing():
219
+ raise AuthenticationError("username and password are required for login")
220
+ if self._should_reset_session(force):
221
+ self._reset_session()
222
+ token = self._login_page_token()
223
+ response = self._post_login(token)
224
+ return self._build_session(self._parse_login_response(response), token)
225
+
226
+ def _logout_request(self) -> httpx.Response:
227
+ token = self._csrf or self.ensure_csrf()
228
+ response = self._transport.request(
229
+ "POST",
230
+ self._logout_path,
231
+ data=encode_params({"csrfmiddlewaretoken": token}),
232
+ headers=FORM_HEADERS,
233
+ idempotent=False,
234
+ )
235
+ self.note_response(response)
236
+ return response
237
+
238
+ def _logout_result(self, response: httpx.Response) -> bool:
239
+ try:
240
+ payload = parse_response(response, expect="auto")
241
+ except SessionExpiredError:
242
+ return True
243
+ return not is_error_payload(payload)
244
+
245
+ def logout(self, *, best_effort: bool = True) -> bool:
246
+ if self._session is None:
247
+ self._generation += 1
248
+ self._transport.clear_cookies(("_console_session", "csrf_token"))
249
+ return True
250
+ ok = False
251
+ try:
252
+ ok = self._logout_result(self._logout_request())
253
+ except UGOSError:
254
+ if not best_effort:
255
+ raise
256
+ finally:
257
+ self._generation += 1
258
+ self._reset_session()
259
+ return ok
260
+
261
+ def note_response(self, response: httpx.Response) -> None:
262
+ token = self._transport.get_cookie("csrf_token")
263
+ if token:
264
+ self._csrf = token
265
+ # Session state is invalidated by the guarded re-auth path, not here:
266
+ # a stale response from another thread must not wipe a fresh session.
267
+
268
+
269
+ def _extract_csrf(html: str) -> str | None:
270
+ for pattern in (_CSRF_RE, _CSRF_INPUT_RE, _CSRF_META_RE):
271
+ match = pattern.search(html)
272
+ if match:
273
+ return match.group(1)
274
+ return None
275
+
276
+
277
+ def _as_int(value: Any) -> int | None:
278
+ try:
279
+ return int(value) if value is not None else None
280
+ except (TypeError, ValueError):
281
+ return None
282
+
283
+
284
+ def _as_str(value: Any) -> str | None:
285
+ return str(value) if value is not None else None
286
+
287
+
288
+ def _as_bool(value: Any) -> bool:
289
+ if isinstance(value, bool):
290
+ return value
291
+ if isinstance(value, str):
292
+ return value.strip().lower() in {"1", "true", "yes", "on"}
293
+ return bool(value)