#!/usr/bin/env python3 """Small read-only HTTP client for STRATO's customer portal. STRATO does not publish a DNS-zone API for ordinary hosted domains. This client deliberately implements only the minimum read path proven by the public certbot-dns-strato project: authenticate, resolve the package that owns one configured DNS zone, and read its combined TXT/CNAME form. There is intentionally no method that submits DNS changes. """ from __future__ import annotations import base64 import hashlib import hmac import os import re import struct import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from html.parser import HTMLParser from http.cookiejar import CookieJar from typing import Callable, Iterable STRATO_URL = "https://www.strato.de/apps/CustomerService" MAX_RESPONSE_BYTES = 5 * 1024 * 1024 USER_AGENT = "Mozilla/5.0 (compatible; mike-ai-strato-dns-readonly/0.1)" class StratoError(RuntimeError): """Short credential-free error suitable for an MCP response.""" class StratoAuthenticationError(StratoError): pass class StratoParseError(StratoError): pass @dataclass(frozen=True) class StratoConfig: username: str password: str domain: str package_id: str | None = None totp_secret: str | None = None totp_device: str | None = None timeout_seconds: float = 20.0 @classmethod def from_env(cls) -> "StratoConfig": values = { "username": os.environ.get("STRATO_USERNAME", "").strip(), "password": os.environ.get("STRATO_PASSWORD", ""), "domain": os.environ.get("STRATO_DOMAIN", "").strip().rstrip("."), } missing = [name.upper() for name, value in values.items() if not value] if missing: raise StratoError( "Missing configuration: " + ", ".join(f"STRATO_{name}" for name in missing) ) domain = values["domain"].encode("idna").decode("ascii").lower() if not re.fullmatch(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain): raise StratoError("STRATO_DOMAIN is not a valid DNS zone name") return cls( username=values["username"], password=values["password"], domain=domain, package_id=os.environ.get("STRATO_PACKAGE_ID", "").strip() or None, totp_secret=os.environ.get("STRATO_TOTP_SECRET", "").strip() or None, totp_device=os.environ.get("STRATO_TOTP_DEVICE", "").strip() or None, timeout_seconds=float(os.environ.get("STRATO_TIMEOUT_SECONDS", "20")), ) @dataclass(frozen=True) class DnsRecord: type: str prefix: str value: str def as_dict(self) -> dict[str, str]: return {"type": self.type, "prefix": self.prefix, "value": self.value} class _FormParser(HTMLParser): """Extract parallel type/prefix/value fields from STRATO's DNS form.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.prefixes: list[str] = [] self.types: list[str] = [] self.values: list[str] = [] self._in_type_select = False self._selected_option = False self._option_value = "" self._in_value_textarea = False self._textarea_parts: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: data = dict(attrs) if tag == "input" and data.get("name") == "prefix": self.prefixes.append(data.get("value") or "") elif tag == "select" and data.get("name") == "type": self._in_type_select = True elif tag == "option" and self._in_type_select: self._selected_option = "selected" in data self._option_value = data.get("value") or "" if self._selected_option: self.types.append(self._option_value) elif tag == "textarea" and data.get("name") == "value": self._in_value_textarea = True self._textarea_parts = [] def handle_endtag(self, tag: str) -> None: if tag == "select": self._in_type_select = False elif tag == "option": self._selected_option = False elif tag == "textarea" and self._in_value_textarea: self.values.append("".join(self._textarea_parts)) self._in_value_textarea = False def handle_data(self, data: str) -> None: if self._in_value_textarea: self._textarea_parts.append(data) class _PackageParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.rows: list[tuple[str, list[str]]] = [] self._depth = 0 self._text: list[str] = [] self._links: list[str] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag == "tr": if self._depth == 0: self._text, self._links = [], [] self._depth += 1 if self._depth and tag == "a": href = dict(attrs).get("href") if href: self._links.append(href) def handle_endtag(self, tag: str) -> None: if tag == "tr" and self._depth: self._depth -= 1 if self._depth == 0: self.rows.append((" ".join(self._text), list(self._links))) def handle_data(self, data: str) -> None: if self._depth and data.strip(): self._text.append(data.strip()) def _totp(secret: str, at_time: int | None = None) -> str: """Generate a standard six-digit SHA-1 TOTP without third-party modules.""" normalized = re.sub(r"\s+", "", secret).upper() try: key = base64.b32decode(normalized + "=" * ((8 - len(normalized) % 8) % 8)) except Exception as exc: raise StratoAuthenticationError("STRATO_TOTP_SECRET is not valid base32") from exc counter = int((at_time if at_time is not None else time.time()) // 30) digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() offset = digest[-1] & 0x0F number = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF return f"{number % 1_000_000:06d}" def parse_records(html: str) -> list[DnsRecord]: parser = _FormParser() parser.feed(html) counts = (len(parser.types), len(parser.prefixes), len(parser.values)) if len(set(counts)) != 1: raise StratoParseError( "STRATO DNS form changed: type/prefix/value field counts do not match" ) return [DnsRecord(t.upper(), p.strip(), v.strip()) for t, p, v in zip( parser.types, parser.prefixes, parser.values, strict=True )] def parse_package_id(html: str, domain: str) -> str: parser = _PackageParser() parser.feed(html) for text, links in parser.rows: if domain.lower() not in text.lower(): continue for link in links: package = urllib.parse.parse_qs(urllib.parse.urlparse(link).query).get("cID") if package and package[0].isdigit(): return package[0] raise StratoParseError(f"Configured domain {domain} was not found in STRATO packages") class StratoClient: def __init__( self, config: StratoConfig, *, opener: object | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: self.config = config self.opener = opener or urllib.request.build_opener( urllib.request.HTTPCookieProcessor(CookieJar()) ) self.sleep = sleep self.session_id: str | None = None self.package_id: str | None = config.package_id def _request( self, method: str, *, params: dict[str, object] | None = None, form: dict[str, object] | None = None, ) -> tuple[str, str]: url = STRATO_URL if params: url += "?" + urllib.parse.urlencode(params, doseq=True) body = urllib.parse.urlencode(form, doseq=True).encode() if form is not None else None request = urllib.request.Request( url, data=body, method=method, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"}, ) try: response = self.opener.open(request, timeout=self.config.timeout_seconds) raw = response.read(MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as exc: raise StratoError(f"STRATO returned HTTP {exc.code}") from None except (urllib.error.URLError, TimeoutError, OSError): raise StratoError("STRATO could not be reached") from None if len(raw) > MAX_RESPONSE_BYTES: raise StratoError("STRATO response exceeded the size limit") return response.geturl(), raw.decode("utf-8", errors="replace") def login(self) -> None: self._request("GET") self.sleep(1.0) url, html = self._request( "POST", form={ "identifier": self.config.username, "passwd": self.config.password, "action_customer_login.x": "Login", }, ) if re.search(r"Zwei.Faktor.Authentifizierung", html, flags=re.IGNORECASE): if not self.config.totp_secret or not self.config.totp_device: raise StratoAuthenticationError( "STRATO requested 2FA; configure STRATO_TOTP_SECRET and STRATO_TOTP_DEVICE" ) token = re.search(r'name=["\']totp_token["\'][^>]*value=["\']([^"\']+)', html) device = re.search( rf']*>' rf'\s*{re.escape(self.config.totp_device)}\s*', html, flags=re.IGNORECASE, ) if not token or not device: raise StratoParseError("STRATO 2FA form could not be understood") self.sleep(1.0) url, html = self._request( "POST", form={ "identifier": self.config.username, "totp_token": token.group(1), "pw_id": device.group(1), "totp": _totp(self.config.totp_secret), "action_customer_login.x": 1, }, ) session = urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get("sessionID") if not session: raise StratoAuthenticationError("STRATO login was not accepted") self.session_id = session[0] def resolve_package(self) -> str: if self.package_id: return self.package_id if not self.session_id: raise StratoAuthenticationError("STRATO session is not initialized") _, html = self._request( "GET", params={"sessionID": self.session_id, "cID": 0, "node": "kds_CustomerEntryPage"}, ) self.package_id = parse_package_id(html, self.config.domain) return self.package_id def list_txt_and_cname_records(self) -> list[DnsRecord]: if not self.session_id: self.login() package_id = self.resolve_package() _, html = self._request( "GET", params={ "sessionID": self.session_id or "", "cID": package_id, "node": "ManageDomains", "action_show_txt_records": "", "vhost": self.config.domain, }, ) return parse_records(html) def list_cnames(self) -> list[DnsRecord]: return [record for record in self.list_txt_and_cname_records() if record.type == "CNAME"]