@@ -76,22 +76,75 @@ def __init__(self, url: str, user: str, password: str):
7676 self ._cookie_header = None
7777
7878 def login (self ) -> None :
79- """Authenticate via base64 credentials in query string.
80-
81- The CM8200A auth flow:
82- 1. GET /cmconnectionstatus.html?{base64creds} returns a bare
83- session token string (~31 alphanumeric chars, not HTML).
84- 2. The modem's Set-Cookie response header is malformed (firmware
85- bug). The browser JS works around this by writing the literal
86- Set-Cookie value + token into document.cookie, producing:
87- Cookie: HttpOnly: true, Secure: true; credential=<token>
88- 3. Subsequent requests must include this Cookie header.
89-
90- Note: the modem has a 5-minute brute-force lockout with no visible
91- error. It returns the login page (HTTP 200, ~4170 bytes) instead
92- of the status page when locked out.
79+ """Authenticate with the CM8200A, reusing IP-based sessions.
80+
81+ The CM8200A uses IP-based session management. After a successful
82+ credential GET, subsequent bare GETs from the same IP return the
83+ status page without needing credentials again. The modem has a
84+ brute-force lockout (~5 minutes) that triggers after a few
85+ credential GETs in quick succession, so we minimise credential
86+ requests by trying to reuse the existing session first.
87+
88+ Flow:
89+ 1. Probe: bare GET /cmconnectionstatus.html (no credentials).
90+ If the modem still recognises our IP, it returns the status
91+ page (~12 kB) and we skip credential auth entirely.
92+ 2. If the probe returns the login page (~4 kB), check the
93+ lockout endpoint before sending credentials.
94+ 3. Full auth: GET /cmconnectionstatus.html?{base64creds} to
95+ obtain a session token, then GET the status page.
9396 """
9497 self ._status_html = None
98+
99+ # Phase 1: try reusing existing IP-based session
100+ if self ._try_session_reuse ():
101+ return
102+
103+ # Phase 2: check lockout before sending credentials
104+ self ._check_lockout ()
105+
106+ # Phase 3: full credential-based auth
107+ self ._credential_auth ()
108+
109+ def _try_session_reuse (self ) -> bool :
110+ """Attempt to fetch the status page without sending credentials.
111+
112+ Returns True if the session is still valid and _status_html is set.
113+ """
114+ try :
115+ headers = {"Cookie" : self ._cookie_header } if self ._cookie_header else {}
116+ r = self ._session .get (
117+ f"{ self ._url } /cmconnectionstatus.html" ,
118+ headers = headers ,
119+ timeout = 30 ,
120+ )
121+ r .raise_for_status ()
122+ if len (r .text ) > 5000 and "downstream bonded" in r .text .lower ():
123+ self ._status_html = r .text
124+ log .info ("CM8200 session reused" )
125+ return True
126+ log .debug ("CM8200 session expired, re-authenticating" )
127+ except requests .RequestException :
128+ log .debug ("CM8200 session probe failed, re-authenticating" )
129+ return False
130+
131+ def _check_lockout (self ) -> None :
132+ """Check the modem's brute-force lockout status before sending credentials."""
133+ try :
134+ r = self ._session .get (
135+ f"{ self ._url } /Admin_Login_Lock.txt" ,
136+ timeout = 10 ,
137+ )
138+ if r .text .strip () == "Locked" :
139+ raise RuntimeError (
140+ "CM8200 modem is in brute-force lockout. "
141+ "Wait 5 minutes or reboot the modem."
142+ )
143+ except requests .RequestException :
144+ pass # If the check fails, proceed with auth attempt
145+
146+ def _credential_auth (self ) -> None :
147+ """Full credential-based authentication flow."""
95148 creds = base64 .b64encode (f"{ self ._user } :{ self ._password } " .encode ()).decode ()
96149 for attempt in range (2 ):
97150 try :
@@ -195,49 +248,20 @@ def _fetch_status_page(self) -> BeautifulSoup:
195248 ``get_device_info()`` and ``get_docsis_data()`` share the same
196249 snapshot without triggering a second auth round-trip.
197250
198- If no cache is available (e.g. session expired mid-poll), falls
199- back to a full re-auth with the same validation as ``login()`` .
251+ If no cache is available (e.g. session expired mid-poll), tries
252+ session reuse first, then falls back to full credential auth .
200253 """
201254 if self ._status_html :
202255 log .debug ("CM8200 using cached status HTML (%d bytes)" , len (self ._status_html ))
203256 return BeautifulSoup (self ._status_html , "html.parser" )
204257
205- creds = base64 .b64encode (f"{ self ._user } :{ self ._password } " .encode ()).decode ()
206- try :
207- r1 = self ._session .get (
208- f"{ self ._url } /cmconnectionstatus.html?{ creds } " ,
209- timeout = 30 ,
210- )
211- token = r1 .text .strip ()
212-
213- if len (token ) > 64 or not token .isalnum ():
214- raise RuntimeError (
215- "CM8200 re-auth failed: expected session token but received "
216- f"{ len (token )} bytes (wrong credentials or brute-force "
217- "lockout, wait 5 minutes)"
218- )
219-
220- self ._cookie_header = f"HttpOnly: true, Secure: true; credential={ token } "
221-
222- r = self ._session .get (
223- f"{ self ._url } /cmconnectionstatus.html" ,
224- headers = {"Cookie" : self ._cookie_header },
225- timeout = 30 ,
226- )
227- r .raise_for_status ()
228-
229- if len (r .text ) < 5000 or "downstream bonded" not in r .text .lower ():
230- raise RuntimeError (
231- "CM8200 re-auth succeeded but status page not returned "
232- f"({ len (r .text )} bytes). Modem may be in brute-force "
233- "lockout (wait 5 minutes)."
234- )
235- except requests .RequestException as e :
236- raise RuntimeError (f"CM8200 status page retrieval failed: { e } " )
258+ # Try session reuse before sending credentials
259+ if self ._try_session_reuse ():
260+ return BeautifulSoup (self ._status_html , "html.parser" )
237261
238- self ._status_html = r . text
239- log . debug ( "CM8200 status page fetched (%d bytes)" , len ( r . text ) )
240- return BeautifulSoup (r . text , "html.parser" )
262+ self ._check_lockout ()
263+ self . _credential_auth ( )
264+ return BeautifulSoup (self . _status_html , "html.parser" )
241265
242266 @staticmethod
243267 def _find_channel_tables (soup ) -> tuple :
0 commit comments