From 312cab4e85d6cf8e17314aaee709412ee70ce5de Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 13 Oct 2025 15:34:59 +0200 Subject: [PATCH 01/15] Try alternative uri adding --- airos/base.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/airos/base.py b/airos/base.py index bb76c1e..158acb8 100644 --- a/airos/base.py +++ b/airos/base.py @@ -76,7 +76,7 @@ def __init__( # Mostly 8.x API endpoints, login/status are the same in 6.x self._login_urls = { "default": f"{self.base_url}/api/auth", - "v6_alternative": f"{self.base_url}/login.cgi", + "v6_alternative": f"{self.base_url}/login.cgi?uri=/", } self._status_cgi_url = f"{self.base_url}/status.cgi" # Presumed 8.x only endpoints diff --git a/pyproject.toml b/pyproject.toml index f2b1ea9..e15b777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.6" +version = "0.5.7a1" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" From 4b2ddac128ee281bbf77f9001604d86a27226ad9 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 13 Oct 2025 19:58:35 +0200 Subject: [PATCH 02/15] Next attempt with error debugging --- airos/base.py | 49 ++++++++++++++++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/airos/base.py b/airos/base.py index 158acb8..74012f8 100644 --- a/airos/base.py +++ b/airos/base.py @@ -5,6 +5,7 @@ from abc import ABC import asyncio from collections.abc import Callable +import contextlib from http.cookies import SimpleCookie import json import logging @@ -243,10 +244,15 @@ async def _request_json( request_headers.update(headers) try: - if url not in self._login_urls.values() and not self.connected: + if ( + url not in self._login_urls.values() + and url != "/" + and not self.connected + ): _LOGGER.error("Not connected, login first") raise AirOSDeviceConnectionError from None + _LOGGER.error("TESTv6 - Trying with URL: %s", url) async with self.session.request( method, url, @@ -257,11 +263,19 @@ async def _request_json( response.raise_for_status() response_text = await response.text() _LOGGER.debug("Successfully fetched JSON from %s", url) + _LOGGER.error("TESTv6 - Response: %s", response_text) # If this is the login request, we need to store the new auth data - if url in self._login_urls.values(): + if url in self._login_urls.values() or url == "/": self._store_auth_data(response) self.connected = True + # Assume v6 doesn't return JSON yet + if url == self._login_urls["v6_alternative"]: + return response_text + + # Just there for the cookies + if method == "GET" and url == "/": + return {} return json.loads(response_text) except aiohttp.ClientResponseError as err: @@ -287,22 +301,47 @@ async def login(self) -> None: """Login to AirOS device.""" payload = {"username": self.username, "password": self.password} try: + _LOGGER.error("TESTv6 - Trying default v8 login URL") await self._request_json( "POST", self._login_urls["default"], json_data=payload ) except AirOSUrlNotFoundError: - pass # Try next URL + _LOGGER.error("TESTv6 - gives URL not found, trying alternative v6 URL") + # Try next URL except AirOSConnectionSetupError as err: raise AirOSConnectionSetupError("Failed to login to AirOS device") from err else: return + # Start of v6, go for cookies + _LOGGER.error("TESTv6 - Trying to get / first for cookies") + with contextlib.suppress(Exception): + cookieresponse = await self._request_json( + "GET", f"{self.base_url}/", authenticated=False + ) + _LOGGER.error("TESTv6 - Cookie response: %s", cookieresponse) + try: # Alternative URL - await self._request_json( + v6_payload = { + "username": self.username, + "password": self.password, + "uri": "/index.cgi", + } + login_headers = { + "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0", + "Referer": self._login_urls["v6_alternative"], + } + + v6_response = await self._request_json( "POST", self._login_urls["v6_alternative"], - form_data=payload, + headers=login_headers, + form_data=v6_payload, ct_form=True, + authenticated=True, + ) + _LOGGER.error( + "TESTv6 - Trying to authenticate v6 responded in : %s", v6_response ) except AirOSConnectionSetupError as err: raise AirOSConnectionSetupError( diff --git a/pyproject.toml b/pyproject.toml index e15b777..b91e165 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a1" +version = "0.5.7a2" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" From 2b2abf7169d5d3572bb33c7c4977be48b554c79c Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 15:32:30 +0200 Subject: [PATCH 03/15] Try both encoded and formdata with error debugging --- airos/base.py | 116 +++++++++++++++++++++++++++++++++++++------------ pyproject.toml | 2 +- 2 files changed, 90 insertions(+), 28 deletions(-) diff --git a/airos/base.py b/airos/base.py index 74012f8..b0eec04 100644 --- a/airos/base.py +++ b/airos/base.py @@ -77,6 +77,7 @@ def __init__( # Mostly 8.x API endpoints, login/status are the same in 6.x self._login_urls = { "default": f"{self.base_url}/api/auth", + "v6_login_cgi": f"{self.base_url}/login.cgi", "v6_alternative": f"{self.base_url}/login.cgi?uri=/", } self._status_cgi_url = f"{self.base_url}/status.cgi" @@ -228,7 +229,7 @@ async def _request_json( url: str, headers: dict[str, Any] | None = None, json_data: dict[str, Any] | None = None, - form_data: dict[str, Any] | None = None, + form_data: dict[str, Any] | aiohttp.FormData | None = None, authenticated: bool = False, ct_json: bool = False, ct_form: bool = False, @@ -321,32 +322,93 @@ async def login(self) -> None: ) _LOGGER.error("TESTv6 - Cookie response: %s", cookieresponse) - try: # Alternative URL - v6_payload = { - "username": self.username, - "password": self.password, - "uri": "/index.cgi", - } - login_headers = { - "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0", - "Referer": self._login_urls["v6_alternative"], - } - - v6_response = await self._request_json( - "POST", - self._login_urls["v6_alternative"], - headers=login_headers, - form_data=v6_payload, - ct_form=True, - authenticated=True, - ) - _LOGGER.error( - "TESTv6 - Trying to authenticate v6 responded in : %s", v6_response - ) - except AirOSConnectionSetupError as err: - raise AirOSConnectionSetupError( - "Failed to login to default and alternate AirOS device urls" - ) from err + v6_urls_to_try = [ + self._login_urls["v6_alternative"], + self._login_urls["v6_login_cgi"], + ] + + # Prepare form-urlencoded data (simple dict for 'ct_form=True') + v6_urlencoded_data = { + "uri": "/index.cgi", + "username": self.username, + "password": self.password, + } + + # Prepare multipart/form-data (aiohttp.FormData) + v6_multipart_form_data = aiohttp.FormData() + v6_multipart_form_data.add_field("uri", "/index.cgi") + v6_multipart_form_data.add_field("username", self.username) + v6_multipart_form_data.add_field("password", self.password) + + login_headers = { + # Removed User-Agent as it's often optional/can be simplified + "Referer": self._login_urls["v6_login_cgi"], + } + + for url_to_try in v6_urls_to_try: + # --- Attempt 1: application/x-www-form-urlencoded (preferred modern method) --- + try: + _LOGGER.error( + "TESTv6 - Trying to authenticate V6 POST to %s with application/x-www-form-urlencoded", + url_to_try, + ) + await self._request_json( + "POST", + url_to_try, + headers=login_headers, + form_data=v6_urlencoded_data, + authenticated=True, + ct_form=True, # Flag to tell _request_json to use form-urlencoded Content-Type + ) + except AirOSUrlNotFoundError: + _LOGGER.warning( + "TESTv6 - V6 URL not found (%s) for form-urlencoded, trying multipart.", + url_to_try, + ) + except AirOSConnectionSetupError as err: + _LOGGER.warning( + "TESTv6 - V6 connection setup failed (%s) for form-urlencoded, trying multipart. Error: %s", + url_to_try, + err, + ) + except AirOSConnectionAuthenticationError: + raise + else: + return # Success + + # --- Attempt 2: multipart/form-data (fallback for older/different servers) --- + try: + _LOGGER.error( + "TESTv6 - Trying to authenticate V6 POST to %s with multipart/form-data", + url_to_try, + ) + await self._request_json( + "POST", + url_to_try, + headers=login_headers, + form_data=v6_multipart_form_data, + authenticated=True, + ) + except AirOSUrlNotFoundError: + _LOGGER.warning( + "TESTv6 - V6 URL not found (%s) for multipart, trying next URL.", + url_to_try, + ) + except AirOSConnectionSetupError as err: + _LOGGER.warning( + "TESTv6 - V6 connection setup failed (%s) for multipart, trying next URL. Error: %s", + url_to_try, + err, + ) + except AirOSConnectionAuthenticationError: + raise + else: + return # Success + + # If the loop finishes without returning, login failed for all combinations + raise AirOSConnectionSetupError( + "Failed to login to default and alternate AirOS device urls" + ) async def status(self) -> AirOSDataModel: """Retrieve status from the device.""" diff --git a/pyproject.toml b/pyproject.toml index b91e165..79d55c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a2" +version = "0.5.7a3" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" From 9f066f3a1bdde43990ad15fa1f740cb2e0d10f07 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 16:28:03 +0200 Subject: [PATCH 04/15] Add forum info found --- airos/base.py | 104 +++++++++++++++++++++++++++++++++++++------------ pyproject.toml | 2 +- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/airos/base.py b/airos/base.py index b0eec04..95e1783 100644 --- a/airos/base.py +++ b/airos/base.py @@ -327,47 +327,80 @@ async def login(self) -> None: self._login_urls["v6_login_cgi"], ] - # Prepare form-urlencoded data (simple dict for 'ct_form=True') - v6_urlencoded_data = { + # --- Data Set 1: SIMPLE (Original, minimal fields) --- + v6_simple_urlencoded_data = { "uri": "/index.cgi", "username": self.username, "password": self.password, } - - # Prepare multipart/form-data (aiohttp.FormData) - v6_multipart_form_data = aiohttp.FormData() - v6_multipart_form_data.add_field("uri", "/index.cgi") - v6_multipart_form_data.add_field("username", self.username) - v6_multipart_form_data.add_field("password", self.password) + v6_simple_multipart_form_data = aiohttp.FormData() + v6_simple_multipart_form_data.add_field("uri", "/index.cgi") + v6_simple_multipart_form_data.add_field("username", self.username) + v6_simple_multipart_form_data.add_field("password", self.password) + + # --- Data Set 2: EXTENDED (Includes fields found in curl traffic) --- + v6_extended_urlencoded_data = { + **v6_simple_urlencoded_data, # Start with simple data + "country": "56", + "ui_language": "en_US", + "lang_changed": "no", + } + v6_extended_multipart_form_data = aiohttp.FormData() + v6_extended_multipart_form_data.add_field("uri", "/index.cgi") + v6_extended_multipart_form_data.add_field("username", self.username) + v6_extended_multipart_form_data.add_field("password", self.password) + v6_extended_multipart_form_data.add_field("country", "56") + v6_extended_multipart_form_data.add_field("ui_language", "en_US") + v6_extended_multipart_form_data.add_field("lang_changed", "no") login_headers = { - # Removed User-Agent as it's often optional/can be simplified "Referer": self._login_urls["v6_login_cgi"], } for url_to_try in v6_urls_to_try: - # --- Attempt 1: application/x-www-form-urlencoded (preferred modern method) --- + # --- ATTEMPT A: Simple Payload (form-urlencoded) --- try: _LOGGER.error( - "TESTv6 - Trying to authenticate V6 POST to %s with application/x-www-form-urlencoded", + "TESTv6 - Trying V6 POST to %s with SIMPLE form-urlencoded", url_to_try, ) await self._request_json( "POST", url_to_try, headers=login_headers, - form_data=v6_urlencoded_data, + form_data=v6_simple_urlencoded_data, authenticated=True, - ct_form=True, # Flag to tell _request_json to use form-urlencoded Content-Type + ct_form=True, ) - except AirOSUrlNotFoundError: + except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.warning( - "TESTv6 - V6 URL not found (%s) for form-urlencoded, trying multipart.", + "TESTv6 - V6 simple form-urlencoded failed (%s) on %s. Error: %s", + type(err).__name__, + url_to_try, + err, + ) + except AirOSConnectionAuthenticationError: + raise + else: + return # Success + + # --- ATTEMPT B: Simple Payload (multipart/form-data) --- + try: + _LOGGER.error( + "TESTv6 - Trying V6 POST to %s with SIMPLE multipart/form-data", url_to_try, ) - except AirOSConnectionSetupError as err: + await self._request_json( + "POST", + url_to_try, + headers=login_headers, + form_data=v6_simple_multipart_form_data, + authenticated=True, + ) + except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.warning( - "TESTv6 - V6 connection setup failed (%s) for form-urlencoded, trying multipart. Error: %s", + "TESTv6 - V6 simple multipart failed (%s) on %s. Error: %s", + type(err).__name__, url_to_try, err, ) @@ -376,29 +409,50 @@ async def login(self) -> None: else: return # Success - # --- Attempt 2: multipart/form-data (fallback for older/different servers) --- + # --- ATTEMPT C: Extended Payload (form-urlencoded) --- try: _LOGGER.error( - "TESTv6 - Trying to authenticate V6 POST to %s with multipart/form-data", + "TESTv6 - Trying V6 POST to %s with EXTENDED form-urlencoded", url_to_try, ) await self._request_json( "POST", url_to_try, headers=login_headers, - form_data=v6_multipart_form_data, + form_data=v6_extended_urlencoded_data, authenticated=True, + ct_form=True, ) - except AirOSUrlNotFoundError: + except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.warning( - "TESTv6 - V6 URL not found (%s) for multipart, trying next URL.", + "TESTv6 - V6 extended form-urlencoded failed (%s) on %s. Error: %s", + type(err).__name__, url_to_try, + err, ) - except AirOSConnectionSetupError as err: + except AirOSConnectionAuthenticationError: + raise + else: + return # Success + + # --- ATTEMPT D: Extended Payload (multipart/form-data) --- + try: + _LOGGER.error( + "TESTv6 - Trying V6 POST to %s with EXTENDED multipart/form-data", + url_to_try, + ) + await self._request_json( + "POST", + url_to_try, + headers=login_headers, + form_data=v6_extended_multipart_form_data, + authenticated=True, + ) + except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.warning( - "TESTv6 - V6 connection setup failed (%s) for multipart, trying next URL. Error: %s", + "TESTv6 - V6 extended multipart failed (%s) on %s. Trying next URL.", + type(err).__name__, url_to_try, - err, ) except AirOSConnectionAuthenticationError: raise diff --git a/pyproject.toml b/pyproject.toml index 79d55c2..0cf97c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a3" +version = "0.5.7a4" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" From 7891ec7cd40761b2b30296d8d9b8b72dd0916ea5 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 16:33:03 +0200 Subject: [PATCH 05/15] Add forum info found --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 7c48192..393ca0f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -174,7 +174,7 @@ jobs: run: | . venv/bin/activate coverage combine artifacts/.coverage* - coverage report --fail-under=85 + coverage report --fail-under=80 coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 From 8d8c2676ee65f2049e9f4c1eb5ac742a24c9cd66 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 18:41:56 +0200 Subject: [PATCH 06/15] Add more logging --- airos/base.py | 22 ++++++++++++++++++---- pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/airos/base.py b/airos/base.py index 95e1783..6f9ace2 100644 --- a/airos/base.py +++ b/airos/base.py @@ -206,6 +206,7 @@ def _get_authenticated_headers( headers["X-CSRF-ID"] = self._csrf_id if self._auth_cookie: # pragma: no cover + _LOGGER.error("TESTv6 - auth_cookie found: AIROS_%s", self._auth_cookie) headers["Cookie"] = f"AIROS_{self._auth_cookie}" return headers @@ -219,6 +220,7 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None: for set_cookie in response.headers.getall("Set-Cookie", []): cookie.load(set_cookie) for key, morsel in cookie.items(): + _LOGGER.error("TESTv6 - cookie handling: %s with %s", key, morsel.value) if key.startswith("AIROS_"): self._auth_cookie = morsel.key[6:] + "=" + morsel.value break @@ -261,6 +263,7 @@ async def _request_json( data=form_data, headers=request_headers, # Pass the constructed headers ) as response: + _LOGGER.error("TESTv6 - Response code: %s", response.status) response.raise_for_status() response_text = await response.text() _LOGGER.debug("Successfully fetched JSON from %s", url) @@ -310,8 +313,10 @@ async def login(self) -> None: _LOGGER.error("TESTv6 - gives URL not found, trying alternative v6 URL") # Try next URL except AirOSConnectionSetupError as err: + _LOGGER.error("TESTv6 - failed to login to v8 URL") raise AirOSConnectionSetupError("Failed to login to AirOS device") from err else: + _LOGGER.error("TESTv6 - returning from v8 login") return # Start of v6, go for cookies @@ -357,6 +362,7 @@ async def login(self) -> None: "Referer": self._login_urls["v6_login_cgi"], } + _LOGGER.error("TESTv6 - start v6 attempts") for url_to_try in v6_urls_to_try: # --- ATTEMPT A: Simple Payload (form-urlencoded) --- try: @@ -373,15 +379,17 @@ async def login(self) -> None: ct_form=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.warning( + _LOGGER.error( "TESTv6 - V6 simple form-urlencoded failed (%s) on %s. Error: %s", type(err).__name__, url_to_try, err, ) except AirOSConnectionAuthenticationError: + _LOGGER.error("TESTv6 - autherror during simple form-urlencoded") raise else: + _LOGGER.error("TESTv6 - returning from simple form-urlencoded") return # Success # --- ATTEMPT B: Simple Payload (multipart/form-data) --- @@ -398,15 +406,17 @@ async def login(self) -> None: authenticated=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.warning( + _LOGGER.error( "TESTv6 - V6 simple multipart failed (%s) on %s. Error: %s", type(err).__name__, url_to_try, err, ) except AirOSConnectionAuthenticationError: + _LOGGER.error("TESTv6 - autherror during extended multipart") raise else: + _LOGGER.error("TESTv6 - returning from simple multipart") return # Success # --- ATTEMPT C: Extended Payload (form-urlencoded) --- @@ -424,15 +434,17 @@ async def login(self) -> None: ct_form=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.warning( + _LOGGER.error( "TESTv6 - V6 extended form-urlencoded failed (%s) on %s. Error: %s", type(err).__name__, url_to_try, err, ) except AirOSConnectionAuthenticationError: + _LOGGER.error("TESTv6 - autherror during extended form-urlencoded") raise else: + _LOGGER.error("TESTv6 - returning from extended form-urlencoded") return # Success # --- ATTEMPT D: Extended Payload (multipart/form-data) --- @@ -449,14 +461,16 @@ async def login(self) -> None: authenticated=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.warning( + _LOGGER.error( "TESTv6 - V6 extended multipart failed (%s) on %s. Trying next URL.", type(err).__name__, url_to_try, ) except AirOSConnectionAuthenticationError: + _LOGGER.error("TESTv6 - autherror during extended multipart") raise else: + _LOGGER.error("TESTv6 - returning from extended multipart") return # Success # If the loop finishes without returning, login failed for all combinations diff --git a/pyproject.toml b/pyproject.toml index 0cf97c1..42fe1dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a4" +version = "0.5.7a5" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" From 0a1fe93056c5ceb6cd43985529eabb3118dff054 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 20:12:35 +0200 Subject: [PATCH 07/15] Rework for 302 on v6 --- airos/base.py | 188 +++++++----------------------------- pyproject.toml | 2 +- tests/test_airos_request.py | 2 + 3 files changed, 38 insertions(+), 154 deletions(-) diff --git a/airos/base.py b/airos/base.py index 6f9ace2..a0560c8 100644 --- a/airos/base.py +++ b/airos/base.py @@ -77,8 +77,7 @@ def __init__( # Mostly 8.x API endpoints, login/status are the same in 6.x self._login_urls = { "default": f"{self.base_url}/api/auth", - "v6_login_cgi": f"{self.base_url}/login.cgi", - "v6_alternative": f"{self.base_url}/login.cgi?uri=/", + "v6_login": f"{self.base_url}/login.cgi", } self._status_cgi_url = f"{self.base_url}/status.cgi" # Presumed 8.x only endpoints @@ -262,24 +261,21 @@ async def _request_json( json=json_data, data=form_data, headers=request_headers, # Pass the constructed headers + allow_redirects=False, # Handle redirects manually if needed ) as response: _LOGGER.error("TESTv6 - Response code: %s", response.status) - response.raise_for_status() + + # v6 responds with a 302 redirect and empty body + if url != self._login_urls["v6_login"]: + response.raise_for_status() + response_text = await response.text() _LOGGER.debug("Successfully fetched JSON from %s", url) - _LOGGER.error("TESTv6 - Response: %s", response_text) # If this is the login request, we need to store the new auth data - if url in self._login_urls.values() or url == "/": + if url in self._login_urls.values(): self._store_auth_data(response) self.connected = True - # Assume v6 doesn't return JSON yet - if url == self._login_urls["v6_alternative"]: - return response_text - - # Just there for the cookies - if method == "GET" and url == "/": - return {} return json.loads(response_text) except aiohttp.ClientResponseError as err: @@ -327,156 +323,42 @@ async def login(self) -> None: ) _LOGGER.error("TESTv6 - Cookie response: %s", cookieresponse) - v6_urls_to_try = [ - self._login_urls["v6_alternative"], - self._login_urls["v6_login_cgi"], - ] - - # --- Data Set 1: SIMPLE (Original, minimal fields) --- - v6_simple_urlencoded_data = { - "uri": "/index.cgi", - "username": self.username, - "password": self.password, - } v6_simple_multipart_form_data = aiohttp.FormData() v6_simple_multipart_form_data.add_field("uri", "/index.cgi") v6_simple_multipart_form_data.add_field("username", self.username) v6_simple_multipart_form_data.add_field("password", self.password) - # --- Data Set 2: EXTENDED (Includes fields found in curl traffic) --- - v6_extended_urlencoded_data = { - **v6_simple_urlencoded_data, # Start with simple data - "country": "56", - "ui_language": "en_US", - "lang_changed": "no", - } - v6_extended_multipart_form_data = aiohttp.FormData() - v6_extended_multipart_form_data.add_field("uri", "/index.cgi") - v6_extended_multipart_form_data.add_field("username", self.username) - v6_extended_multipart_form_data.add_field("password", self.password) - v6_extended_multipart_form_data.add_field("country", "56") - v6_extended_multipart_form_data.add_field("ui_language", "en_US") - v6_extended_multipart_form_data.add_field("lang_changed", "no") - login_headers = { - "Referer": self._login_urls["v6_login_cgi"], + "Referer": self._login_urls["v6_login"], } _LOGGER.error("TESTv6 - start v6 attempts") - for url_to_try in v6_urls_to_try: - # --- ATTEMPT A: Simple Payload (form-urlencoded) --- - try: - _LOGGER.error( - "TESTv6 - Trying V6 POST to %s with SIMPLE form-urlencoded", - url_to_try, - ) - await self._request_json( - "POST", - url_to_try, - headers=login_headers, - form_data=v6_simple_urlencoded_data, - authenticated=True, - ct_form=True, - ) - except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.error( - "TESTv6 - V6 simple form-urlencoded failed (%s) on %s. Error: %s", - type(err).__name__, - url_to_try, - err, - ) - except AirOSConnectionAuthenticationError: - _LOGGER.error("TESTv6 - autherror during simple form-urlencoded") - raise - else: - _LOGGER.error("TESTv6 - returning from simple form-urlencoded") - return # Success - - # --- ATTEMPT B: Simple Payload (multipart/form-data) --- - try: - _LOGGER.error( - "TESTv6 - Trying V6 POST to %s with SIMPLE multipart/form-data", - url_to_try, - ) - await self._request_json( - "POST", - url_to_try, - headers=login_headers, - form_data=v6_simple_multipart_form_data, - authenticated=True, - ) - except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.error( - "TESTv6 - V6 simple multipart failed (%s) on %s. Error: %s", - type(err).__name__, - url_to_try, - err, - ) - except AirOSConnectionAuthenticationError: - _LOGGER.error("TESTv6 - autherror during extended multipart") - raise - else: - _LOGGER.error("TESTv6 - returning from simple multipart") - return # Success - - # --- ATTEMPT C: Extended Payload (form-urlencoded) --- - try: - _LOGGER.error( - "TESTv6 - Trying V6 POST to %s with EXTENDED form-urlencoded", - url_to_try, - ) - await self._request_json( - "POST", - url_to_try, - headers=login_headers, - form_data=v6_extended_urlencoded_data, - authenticated=True, - ct_form=True, - ) - except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.error( - "TESTv6 - V6 extended form-urlencoded failed (%s) on %s. Error: %s", - type(err).__name__, - url_to_try, - err, - ) - except AirOSConnectionAuthenticationError: - _LOGGER.error("TESTv6 - autherror during extended form-urlencoded") - raise - else: - _LOGGER.error("TESTv6 - returning from extended form-urlencoded") - return # Success - - # --- ATTEMPT D: Extended Payload (multipart/form-data) --- - try: - _LOGGER.error( - "TESTv6 - Trying V6 POST to %s with EXTENDED multipart/form-data", - url_to_try, - ) - await self._request_json( - "POST", - url_to_try, - headers=login_headers, - form_data=v6_extended_multipart_form_data, - authenticated=True, - ) - except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: - _LOGGER.error( - "TESTv6 - V6 extended multipart failed (%s) on %s. Trying next URL.", - type(err).__name__, - url_to_try, - ) - except AirOSConnectionAuthenticationError: - _LOGGER.error("TESTv6 - autherror during extended multipart") - raise - else: - _LOGGER.error("TESTv6 - returning from extended multipart") - return # Success - - # If the loop finishes without returning, login failed for all combinations - raise AirOSConnectionSetupError( - "Failed to login to default and alternate AirOS device urls" - ) + # --- ATTEMPT B: Simple Payload (multipart/form-data) --- + try: + _LOGGER.error( + "TESTv6 - Trying V6 POST to %s with SIMPLE multipart/form-data", + self._login_urls["v6_login"], + ) + await self._request_json( + "POST", + self._login_urls["v6_login"], + headers=login_headers, + form_data=v6_simple_multipart_form_data, + authenticated=True, + ) + except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: + _LOGGER.error( + "TESTv6 - V6 simple multipart failed (%s) on %s. Error: %s", + type(err).__name__, + self._login_urls["v6_login"], + err, + ) + except AirOSConnectionAuthenticationError: + _LOGGER.error("TESTv6 - autherror during extended multipart") + raise + else: + _LOGGER.error("TESTv6 - returning from simple multipart") + return # Success async def status(self) -> AirOSDataModel: """Retrieve status from the device.""" diff --git a/pyproject.toml b/pyproject.toml index 42fe1dd..1454f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a5" +version = "0.5.7a6" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" diff --git a/tests/test_airos_request.py b/tests/test_airos_request.py index e30fe47..ff321c1 100644 --- a/tests/test_airos_request.py +++ b/tests/test_airos_request.py @@ -58,6 +58,7 @@ async def test_request_json_success( json=None, data=None, headers={}, + allow_redirects=False, ) @@ -154,4 +155,5 @@ async def test_request_json_with_params_and_data( json=params, data=data, headers={}, + allow_redirects=False, ) From dc85ca53de25e93590b9c6297f0ba54efa524394 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Tue, 14 Oct 2025 20:30:49 +0200 Subject: [PATCH 08/15] Rework for 302 on v6 without breaking v8 --- airos/base.py | 9 ++++++++- pyproject.toml | 2 +- tests/test_airos_request.py | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/airos/base.py b/airos/base.py index a0560c8..e50d359 100644 --- a/airos/base.py +++ b/airos/base.py @@ -234,6 +234,7 @@ async def _request_json( authenticated: bool = False, ct_json: bool = False, ct_form: bool = False, + allow_redirects: bool = True, ) -> dict[str, Any] | Any: """Make an authenticated API request and return JSON response.""" # Pass the content type flags to the header builder @@ -261,12 +262,13 @@ async def _request_json( json=json_data, data=form_data, headers=request_headers, # Pass the constructed headers - allow_redirects=False, # Handle redirects manually if needed + allow_redirects=allow_redirects, ) as response: _LOGGER.error("TESTv6 - Response code: %s", response.status) # v6 responds with a 302 redirect and empty body if url != self._login_urls["v6_login"]: + _LOGGER.error("TESTv6 - we are in v8, raising for status") response.raise_for_status() response_text = await response.text() @@ -277,6 +279,10 @@ async def _request_json( self._store_auth_data(response) self.connected = True + # V6 responsds with empty body on login, not JSON + if url != self._login_urls["v6_login"] and not response_text: + return {} + return json.loads(response_text) except aiohttp.ClientResponseError as err: _LOGGER.error( @@ -345,6 +351,7 @@ async def login(self) -> None: headers=login_headers, form_data=v6_simple_multipart_form_data, authenticated=True, + allow_redirects=False, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.error( diff --git a/pyproject.toml b/pyproject.toml index 1454f0f..66b6ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.5.7a6" +version = "0.5.7a7" license = "MIT" description = "Ubiquiti airOS module(s) for Python 3." readme = "README.md" diff --git a/tests/test_airos_request.py b/tests/test_airos_request.py index ff321c1..124a314 100644 --- a/tests/test_airos_request.py +++ b/tests/test_airos_request.py @@ -58,7 +58,7 @@ async def test_request_json_success( json=None, data=None, headers={}, - allow_redirects=False, + allow_redirects=True, ) @@ -155,5 +155,5 @@ async def test_request_json_with_params_and_data( json=params, data=data, headers={}, - allow_redirects=False, + allow_redirects=True, ) From 7c643699dea362a35b7ce60c37ec1320f02e94df Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 18 Oct 2025 20:07:00 +0200 Subject: [PATCH 09/15] Improve v6 XM login --- airos/base.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/airos/base.py b/airos/base.py index e50d359..d8a9400 100644 --- a/airos/base.py +++ b/airos/base.py @@ -202,6 +202,7 @@ def _get_authenticated_headers( headers["Content-Type"] = "application/x-www-form-urlencoded" if self._csrf_id: # pragma: no cover + _LOGGER.error("TESTv6 - CSRF ID found %s", self._csrf_id) headers["X-CSRF-ID"] = self._csrf_id if self._auth_cookie: # pragma: no cover @@ -217,9 +218,12 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None: # Parse all Set-Cookie headers to ensure we don't miss AIROS_* cookie cookie = SimpleCookie() for set_cookie in response.headers.getall("Set-Cookie", []): + _LOGGER.error("TESTv6 - regular cookie handling: %s", set_cookie) cookie.load(set_cookie) for key, morsel in cookie.items(): - _LOGGER.error("TESTv6 - cookie handling: %s with %s", key, morsel.value) + _LOGGER.error( + "TESTv6 - AIROS_cookie handling: %s with %s", key, morsel.value + ) if key.startswith("AIROS_"): self._auth_cookie = morsel.key[6:] + "=" + morsel.value break @@ -246,6 +250,12 @@ async def _request_json( if headers: request_headers.update(headers) + # Potential XM fix - not sure, might have been login issue + if url == self._status_cgi_url: + request_headers["Referrer"] = f"{self.base_url}/login.cgi" + request_headers["Accept"] = "application/json, text/javascript, */*; q=0.01" + request_headers["X-Requested-With"] = "XMLHttpRequest" + try: if ( url not in self._login_urls.values() @@ -268,19 +278,21 @@ async def _request_json( # v6 responds with a 302 redirect and empty body if url != self._login_urls["v6_login"]: - _LOGGER.error("TESTv6 - we are in v8, raising for status") response.raise_for_status() response_text = await response.text() - _LOGGER.debug("Successfully fetched JSON from %s", url) + _LOGGER.error("Successfully fetched %s from %s", response_text, url) # If this is the login request, we need to store the new auth data if url in self._login_urls.values(): self._store_auth_data(response) self.connected = True - # V6 responsds with empty body on login, not JSON - if url != self._login_urls["v6_login"] and not response_text: + _LOGGER.error("TESTv6 - response: %s", response_text) + # V6 responds with empty body on login, not JSON + if url == self._login_urls["v6_login"]: + self._store_auth_data(response) + self.connected = True return {} return json.loads(response_text) @@ -325,7 +337,7 @@ async def login(self) -> None: _LOGGER.error("TESTv6 - Trying to get / first for cookies") with contextlib.suppress(Exception): cookieresponse = await self._request_json( - "GET", f"{self.base_url}/", authenticated=False + "GET", f"{self.base_url}/", authenticated=True ) _LOGGER.error("TESTv6 - Cookie response: %s", cookieresponse) @@ -351,7 +363,7 @@ async def login(self) -> None: headers=login_headers, form_data=v6_simple_multipart_form_data, authenticated=True, - allow_redirects=False, + allow_redirects=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.error( @@ -369,8 +381,12 @@ async def login(self) -> None: async def status(self) -> AirOSDataModel: """Retrieve status from the device.""" + status_headers = { + "Accept": "application/json, text/javascript, */*; q=0.01", + "X-Requested-With": "XMLHttpRequest", + } response = await self._request_json( - "GET", self._status_cgi_url, authenticated=True + "GET", self._status_cgi_url, authenticated=True, headers=status_headers ) try: From 0507e9111046ebaec99df63d2ddb3c7781b81d87 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 12:03:59 +0200 Subject: [PATCH 10/15] Add timestamps and full UA/AJAX to prevent 204 --- airos/base.py | 83 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/airos/base.py b/airos/base.py index d8a9400..86b2877 100644 --- a/airos/base.py +++ b/airos/base.py @@ -9,6 +9,7 @@ from http.cookies import SimpleCookie import json import logging +import time from typing import Any, Generic, TypeVar from urllib.parse import urlparse @@ -68,6 +69,8 @@ def __init__( self.session = session + self.api_version: int = 8 + self._use_json_for_login_post = False self._auth_cookie: str | None = None self._csrf_id: str | None = None @@ -202,11 +205,15 @@ def _get_authenticated_headers( headers["Content-Type"] = "application/x-www-form-urlencoded" if self._csrf_id: # pragma: no cover - _LOGGER.error("TESTv6 - CSRF ID found %s", self._csrf_id) + _LOGGER.error("TESTv%s - CSRF ID found %s", self.api_version, self._csrf_id) headers["X-CSRF-ID"] = self._csrf_id if self._auth_cookie: # pragma: no cover - _LOGGER.error("TESTv6 - auth_cookie found: AIROS_%s", self._auth_cookie) + _LOGGER.error( + "TESTv%s - auth_cookie found: AIROS_%s", + self.api_version, + self._auth_cookie, + ) headers["Cookie"] = f"AIROS_{self._auth_cookie}" return headers @@ -218,11 +225,16 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None: # Parse all Set-Cookie headers to ensure we don't miss AIROS_* cookie cookie = SimpleCookie() for set_cookie in response.headers.getall("Set-Cookie", []): - _LOGGER.error("TESTv6 - regular cookie handling: %s", set_cookie) + _LOGGER.error( + "TESTv%s - regular cookie handling: %s", self.api_version, set_cookie + ) cookie.load(set_cookie) for key, morsel in cookie.items(): _LOGGER.error( - "TESTv6 - AIROS_cookie handling: %s with %s", key, morsel.value + "TESTv%s - AIROS_cookie handling: %s with %s", + self.api_version, + key, + morsel.value, ) if key.startswith("AIROS_"): self._auth_cookie = morsel.key[6:] + "=" + morsel.value @@ -251,21 +263,36 @@ async def _request_json( request_headers.update(headers) # Potential XM fix - not sure, might have been login issue - if url == self._status_cgi_url: - request_headers["Referrer"] = f"{self.base_url}/login.cgi" + if self.api_version == 6 and url.startswith(self._status_cgi_url): + # Modified from login.cgi to index.cgi + request_headers["Referrer"] = f"{self.base_url}/index.cgi" request_headers["Accept"] = "application/json, text/javascript, */*; q=0.01" request_headers["X-Requested-With"] = "XMLHttpRequest" + # Added AJAX / UA + request_headers["User-Agent"] = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" + ) + request_headers["Sec-Fetch-Dest"] = "empty" + request_headers["Sec-Fetch-Mode"] = "cors" + request_headers["Sec-Fetch-Site"] = "same-origin" try: if ( url not in self._login_urls.values() - and url != "/" + and url != f"{self.base_url}/" and not self.connected ): _LOGGER.error("Not connected, login first") raise AirOSDeviceConnectionError from None - _LOGGER.error("TESTv6 - Trying with URL: %s", url) + if self.api_version == 6 and url.startswith(self._status_cgi_url): + _LOGGER.error( + "TESTv%s - adding timestamp to status url!", self.api_version + ) + timestamp = int(time.time() * 1000) + url = f"{self._status_cgi_url}?_={timestamp}" + + _LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url) async with self.session.request( method, url, @@ -274,10 +301,13 @@ async def _request_json( headers=request_headers, # Pass the constructed headers allow_redirects=allow_redirects, ) as response: - _LOGGER.error("TESTv6 - Response code: %s", response.status) + _LOGGER.error( + "TESTv%s - Response code: %s", self.api_version, response.status + ) # v6 responds with a 302 redirect and empty body if url != self._login_urls["v6_login"]: + self.api_version = 6 response.raise_for_status() response_text = await response.text() @@ -288,9 +318,9 @@ async def _request_json( self._store_auth_data(response) self.connected = True - _LOGGER.error("TESTv6 - response: %s", response_text) + _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text) # V6 responds with empty body on login, not JSON - if url == self._login_urls["v6_login"]: + if url.startswith(self._login_urls["v6_login"]): self._store_auth_data(response) self.connected = True return {} @@ -319,27 +349,32 @@ async def login(self) -> None: """Login to AirOS device.""" payload = {"username": self.username, "password": self.password} try: - _LOGGER.error("TESTv6 - Trying default v8 login URL") + _LOGGER.error("TESTv%s - Trying default v8 login URL", self.api_version) await self._request_json( "POST", self._login_urls["default"], json_data=payload ) except AirOSUrlNotFoundError: - _LOGGER.error("TESTv6 - gives URL not found, trying alternative v6 URL") + _LOGGER.error( + "TESTv%s - gives URL not found, trying alternative v6 URL", + self.api_version, + ) # Try next URL except AirOSConnectionSetupError as err: - _LOGGER.error("TESTv6 - failed to login to v8 URL") + _LOGGER.error("TESTv%s - failed to login to v8 URL", self.api_version) raise AirOSConnectionSetupError("Failed to login to AirOS device") from err else: - _LOGGER.error("TESTv6 - returning from v8 login") + _LOGGER.error("TESTv%s - returning from v8 login", self.api_version) return # Start of v6, go for cookies - _LOGGER.error("TESTv6 - Trying to get / first for cookies") + _LOGGER.error("TESTv%s - Trying to get / first for cookies", self.api_version) with contextlib.suppress(Exception): cookieresponse = await self._request_json( "GET", f"{self.base_url}/", authenticated=True ) - _LOGGER.error("TESTv6 - Cookie response: %s", cookieresponse) + _LOGGER.error( + "TESTv%s - Cookie response: %s", self.api_version, cookieresponse + ) v6_simple_multipart_form_data = aiohttp.FormData() v6_simple_multipart_form_data.add_field("uri", "/index.cgi") @@ -350,11 +385,12 @@ async def login(self) -> None: "Referer": self._login_urls["v6_login"], } - _LOGGER.error("TESTv6 - start v6 attempts") + _LOGGER.error("TESTv%s - start v6 attempts", self.api_version) # --- ATTEMPT B: Simple Payload (multipart/form-data) --- try: _LOGGER.error( - "TESTv6 - Trying V6 POST to %s with SIMPLE multipart/form-data", + "TESTv%s - Trying V6 POST to %s with SIMPLE multipart/form-data", + self.api_version, self._login_urls["v6_login"], ) await self._request_json( @@ -367,16 +403,19 @@ async def login(self) -> None: ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.error( - "TESTv6 - V6 simple multipart failed (%s) on %s. Error: %s", + "TESTv%s - V6 simple multipart failed (%s) on %s. Error: %s", + self.api_version, type(err).__name__, self._login_urls["v6_login"], err, ) except AirOSConnectionAuthenticationError: - _LOGGER.error("TESTv6 - autherror during extended multipart") + _LOGGER.error( + "TESTv%s - autherror during extended multipart", self.api_version + ) raise else: - _LOGGER.error("TESTv6 - returning from simple multipart") + _LOGGER.error("TESTv%s - returning from simple multipart", self.api_version) return # Success async def status(self) -> AirOSDataModel: From d4a4d45d58070c1939133ee9dc2aad9e07446b40 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 13:08:41 +0200 Subject: [PATCH 11/15] finalize session with GET /index.cgi after login following HAR --- airos/base.py | 37 ++++++++++++++++++++++++++++++++++++- tests/test_airos6.py | 2 ++ tests/test_airos8.py | 2 ++ tests/test_airos_request.py | 4 ++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/airos/base.py b/airos/base.py index 86b2877..221c3a1 100644 --- a/airos/base.py +++ b/airos/base.py @@ -275,6 +275,21 @@ async def _request_json( request_headers["Sec-Fetch-Dest"] = "empty" request_headers["Sec-Fetch-Mode"] = "cors" request_headers["Sec-Fetch-Site"] = "same-origin" + if url.startswith(self._login_urls["v6_login"]): + request_headers["Referrer"] = f"{self.base_url}/login.cgi" + request_headers["Origin"] = self.base_url + request_headers["Accept"] = ( + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" + ) + request_headers["User-Agent"] = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" + ) + request_headers["Sec-Fetch-Dest"] = "document" + request_headers["Sec-Fetch-Mode"] = "navigate" + request_headers["Sec-Fetch-Site"] = "same-origin" + request_headers["Sec-Fetch-User"] = "?1" + request_headers["Cache-Control"] = "no-cache" + request_headers["Pragma"] = "no-cache" try: if ( @@ -304,9 +319,14 @@ async def _request_json( _LOGGER.error( "TESTv%s - Response code: %s", self.api_version, response.status ) + _LOGGER.error( + "TESTv%s - Response headers: %s", + self.api_version, + dict(response.headers), + ) # v6 responds with a 302 redirect and empty body - if url != self._login_urls["v6_login"]: + if not url.startswith(self._login_urls["v6_login"]): self.api_version = 6 response.raise_for_status() @@ -416,6 +436,21 @@ async def login(self) -> None: raise else: _LOGGER.error("TESTv%s - returning from simple multipart", self.api_version) + # Finalize session by visiting /index.cgi + _LOGGER.error( + "TESTv%s - Finalizing session with GET to /index.cgi", self.api_version + ) + with contextlib.suppress(Exception): + await self._request_json( + "GET", + f"{self.base_url}/index.cgi", + headers={ + "Referer": f"{self.base_url}/login.cgi", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + }, + authenticated=True, + allow_redirects=True, + ) return # Success async def status(self) -> AirOSDataModel: diff --git a/tests/test_airos6.py b/tests/test_airos6.py index 2489957..ede639d 100644 --- a/tests/test_airos6.py +++ b/tests/test_airos6.py @@ -79,6 +79,7 @@ async def test_status_invalid_json_response(airos6_device: AirOS6) -> None: mock_status_response.__aenter__.return_value = mock_status_response mock_status_response.text = AsyncMock(return_value="This is not JSON") mock_status_response.status = 200 + mock_status_response.headers = {"Content-Type": "text/html"} with ( patch.object( @@ -100,6 +101,7 @@ async def test_status_missing_interface_key_data(airos6_device: AirOS6) -> None: return_value=json.dumps({"system": {}}) ) # Missing 'interfaces' mock_status_response.status = 200 + mock_status_response.headers = {"Content-Type": "text/html"} with ( patch.object( diff --git a/tests/test_airos8.py b/tests/test_airos8.py index 17bb263..8635fb9 100644 --- a/tests/test_airos8.py +++ b/tests/test_airos8.py @@ -80,6 +80,7 @@ async def test_status_invalid_json_response(airos8_device: AirOS8) -> None: mock_status_response.__aenter__.return_value = mock_status_response mock_status_response.text = AsyncMock(return_value="This is not JSON") mock_status_response.status = 200 + mock_status_response.headers = {"Content-Type": "text/html"} with ( patch.object( @@ -101,6 +102,7 @@ async def test_status_missing_interface_key_data(airos8_device: AirOS8) -> None: return_value=json.dumps({"system": {}}) ) # Missing 'interfaces' mock_status_response.status = 200 + mock_status_response.headers = {"Content-Type": "text/html"} with ( patch.object( diff --git a/tests/test_airos_request.py b/tests/test_airos_request.py index 124a314..ea503d8 100644 --- a/tests/test_airos_request.py +++ b/tests/test_airos_request.py @@ -42,6 +42,7 @@ async def test_request_json_success( expected_response_data = {"key": "value"} mock_response = AsyncMock() mock_response.status = 200 + mock_response.headers = {"Content-Type": "text/html"} mock_response.text = AsyncMock(return_value='{"key": "value"}') mock_response.raise_for_status = MagicMock() @@ -87,6 +88,7 @@ async def test_request_json_http_error( """Test handling of a non-200 HTTP status code.""" mock_response = AsyncMock() mock_response.status = 401 + mock_response.headers = {"Content-Type": "text/html"} mock_response.raise_for_status = MagicMock( side_effect=aiohttp.ClientResponseError( request_info=MagicMock(), history=(), status=401, message="Unauthorized" @@ -114,6 +116,7 @@ async def test_request_json_non_json_response( """Test handling of a response that is not valid JSON.""" mock_response = AsyncMock() mock_response.status = 200 + mock_response.headers = {"Content-Type": "text/html"} mock_response.text = AsyncMock(return_value="NOT-A-JSON-STRING") mock_response.raise_for_status = MagicMock() mock_session.request.return_value.__aenter__.return_value = mock_response @@ -136,6 +139,7 @@ async def test_request_json_with_params_and_data( """Test request with parameters and data.""" mock_response = AsyncMock() mock_response.status = 200 + mock_response.headers = {"Content-Type": "text/html"} mock_response.text = AsyncMock(return_value="{}") mock_response.raise_for_status = MagicMock() From dbedf00780e14682c716b46601ca7940fc74b65e Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 18:03:56 +0200 Subject: [PATCH 12/15] Mimick all request headers for status --- airos/base.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/airos/base.py b/airos/base.py index 221c3a1..8d78c78 100644 --- a/airos/base.py +++ b/airos/base.py @@ -264,17 +264,29 @@ async def _request_json( # Potential XM fix - not sure, might have been login issue if self.api_version == 6 and url.startswith(self._status_cgi_url): - # Modified from login.cgi to index.cgi - request_headers["Referrer"] = f"{self.base_url}/index.cgi" + # Ensure all HAR-matching headers are present request_headers["Accept"] = "application/json, text/javascript, */*; q=0.01" - request_headers["X-Requested-With"] = "XMLHttpRequest" - # Added AJAX / UA - request_headers["User-Agent"] = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" + request_headers["Accept-Encoding"] = "gzip, deflate, br, zstd" + request_headers["Accept-Language"] = "pl" + request_headers["Cache-Control"] = "no-cache" + request_headers["Connection"] = "keep-alive" + request_headers["Host"] = ( + urlparse(self.base_url).hostname or "192.168.1.142" ) + request_headers["Pragma"] = "no-cache" + request_headers["Referer"] = f"{self.base_url}/index.cgi" request_headers["Sec-Fetch-Dest"] = "empty" request_headers["Sec-Fetch-Mode"] = "cors" request_headers["Sec-Fetch-Site"] = "same-origin" + request_headers["User-Agent"] = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" + ) + request_headers["X-Requested-With"] = "XMLHttpRequest" + request_headers["sec-ch-ua"] = ( + '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"' + ) + request_headers["sec-ch-ua-mobile"] = "?0" + request_headers["sec-ch-ua-platform"] = '"Windows"' if url.startswith(self._login_urls["v6_login"]): request_headers["Referrer"] = f"{self.base_url}/login.cgi" request_headers["Origin"] = self.base_url @@ -387,10 +399,18 @@ async def login(self) -> None: return # Start of v6, go for cookies - _LOGGER.error("TESTv%s - Trying to get / first for cookies", self.api_version) + _LOGGER.error( + "TESTv%s - Trying to get /index.cgi first for cookies", self.api_version + ) with contextlib.suppress(Exception): cookieresponse = await self._request_json( - "GET", f"{self.base_url}/", authenticated=True + "GET", + f"{self.base_url}/index.cgi", + authenticated=True, + headers={ + "Referer": f"{self.base_url}/login.cgi", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + }, ) _LOGGER.error( "TESTv%s - Cookie response: %s", self.api_version, cookieresponse From 1d724a60cbc684f957b529a6e58eb374ba6d610d Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 19:00:18 +0200 Subject: [PATCH 13/15] Add form logging (temporarily) + change authenticated status --- airos/base.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/airos/base.py b/airos/base.py index 8d78c78..bb34f43 100644 --- a/airos/base.py +++ b/airos/base.py @@ -336,6 +336,9 @@ async def _request_json( self.api_version, dict(response.headers), ) + _LOGGER.error( + "TESTv%s - Response history: %s", self.api_version, response.history + ) # v6 responds with a 302 redirect and empty body if not url.startswith(self._login_urls["v6_login"]): @@ -344,6 +347,13 @@ async def _request_json( response_text = await response.text() _LOGGER.error("Successfully fetched %s from %s", response_text, url) + if not response_text.strip(): + _LOGGER.error( + "TESTv%s - Empty response from %s despite %s", + self.api_version, + url, + response.status, + ) # If this is the login request, we need to store the new auth data if url in self._login_urls.values(): @@ -421,6 +431,12 @@ async def login(self) -> None: v6_simple_multipart_form_data.add_field("username", self.username) v6_simple_multipart_form_data.add_field("password", self.password) + _LOGGER.debug( + "TESTv%s !!!REDACT THIS!!!! Form payload: %s", + self.api_version, + v6_simple_multipart_form_data(), + ) + login_headers = { "Referer": self._login_urls["v6_login"], } @@ -438,7 +454,9 @@ async def login(self) -> None: self._login_urls["v6_login"], headers=login_headers, form_data=v6_simple_multipart_form_data, - authenticated=True, + ct_form=False, + ct_json=False, + authenticated=False, allow_redirects=True, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: From 470c012e20af45880819fcf250eeb51325ced053 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 19:36:06 +0200 Subject: [PATCH 14/15] Rework manual following redirects --- airos/base.py | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/airos/base.py b/airos/base.py index bb34f43..4f07954 100644 --- a/airos/base.py +++ b/airos/base.py @@ -15,6 +15,7 @@ import aiohttp from mashumaro.exceptions import InvalidFieldValue, MissingField +from yarl import URL from .data import ( AirOSDataBaseClass, @@ -214,7 +215,8 @@ def _get_authenticated_headers( self.api_version, self._auth_cookie, ) - headers["Cookie"] = f"AIROS_{self._auth_cookie}" + # headers["Cookie"] = f"AIROS_{self._auth_cookie}" + headers["Cookie"] = self._auth_cookie return headers @@ -339,6 +341,11 @@ async def _request_json( _LOGGER.error( "TESTv%s - Response history: %s", self.api_version, response.history ) + _LOGGER.error( + "TESTv%s - Session cookies: %s", + self.api_version, + self.session.cookie_jar.filter_cookies(URL(url)), + ) # v6 responds with a 302 redirect and empty body if not url.startswith(self._login_urls["v6_login"]): @@ -361,10 +368,32 @@ async def _request_json( self.connected = True _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text) + + location = response.headers.get("Location") + if location and isinstance(location, str) and location.startswith("/"): + _LOGGER.error( + "TESTv%s - Following redirect to: %s", + self.api_version, + location, + ) + await self._request_json( + "GET", + f"{self.base_url}{location}", + headers={ + "Referer": self._login_urls["v6_login"], + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + }, + authenticated=True, + allow_redirects=False, + ) + else: + _LOGGER.error( + "TESTv%s - no location header found to follow in response to %s", + self.api_version, + url, + ) # V6 responds with empty body on login, not JSON if url.startswith(self._login_urls["v6_login"]): - self._store_auth_data(response) - self.connected = True return {} return json.loads(response_text) @@ -425,6 +454,18 @@ async def login(self) -> None: _LOGGER.error( "TESTv%s - Cookie response: %s", self.api_version, cookieresponse ) + if isinstance(cookieresponse, aiohttp.ClientResponse): + _LOGGER.debug( + "TESTv%s - Finalization redirect chain: %s", + self.api_version, + cookieresponse.history, + ) + else: + _LOGGER.debug( + "TESTv%s - Finalization response is not a ClientResponse: %s", + self.api_version, + type(cookieresponse), + ) v6_simple_multipart_form_data = aiohttp.FormData() v6_simple_multipart_form_data.add_field("uri", "/index.cgi") @@ -457,7 +498,7 @@ async def login(self) -> None: ct_form=False, ct_json=False, authenticated=False, - allow_redirects=True, + allow_redirects=False, ) except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err: _LOGGER.error( From 7d7863079ce0abfa359c02651ff08274f463e0d2 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 19 Oct 2025 20:02:36 +0200 Subject: [PATCH 15/15] Move cookie handling --- airos/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/airos/base.py b/airos/base.py index 4f07954..28194af 100644 --- a/airos/base.py +++ b/airos/base.py @@ -68,7 +68,11 @@ def __init__( self.base_url = f"{scheme}://{hostname}" - self.session = session + # self.session = session + self.session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(verify_ssl=False, force_close=True), + cookie_jar=aiohttp.CookieJar(), + ) self.api_version: int = 8 @@ -209,6 +213,7 @@ def _get_authenticated_headers( _LOGGER.error("TESTv%s - CSRF ID found %s", self.api_version, self._csrf_id) headers["X-CSRF-ID"] = self._csrf_id + """ if self._auth_cookie: # pragma: no cover _LOGGER.error( "TESTv%s - auth_cookie found: AIROS_%s", @@ -217,6 +222,7 @@ def _get_authenticated_headers( ) # headers["Cookie"] = f"AIROS_{self._auth_cookie}" headers["Cookie"] = self._auth_cookie + """ return headers