Add CNAME create update and delete tools

This commit is contained in:
Mikei386
2026-08-28 21:28:45 +02:00
parent 4754626d34
commit c6ab0e48c1
5 changed files with 400 additions and 51 deletions
+33 -31
View File
@@ -1,57 +1,59 @@
# STRATO-DNS-MCP – Read-only-Prototyp # STRATO-DNS-MCP
Dieser Prototyp prüft den undokumentierten HTTP-Leseweg des STRATO- Dieser MCP verwaltet die CNAME-Einträge genau einer konfigurierten DNS-Zone im
Kundenbereichs. Er kann sich anmelden und die CNAME-Einträge genau einer STRATO-Kundenbereich. Er kann Einträge auflisten, anlegen, ändern und löschen.
konfigurierten DNS-Zone auflisten.
**Diese Version kann keine DNS-Einträge erstellen, ändern oder löschen.** Im
Quellcode existiert absichtlich keine Speichermethode.
## Technische Grundlage ## Technische Grundlage
STRATO dokumentiert für normale Hosting-Domains nur die Verwaltung im STRATO dokumentiert für normale Hosting-Domains nur die Verwaltung im
Kunden-Login. Der öffentliche Certbot-Plugin Kunden-Login. Der öffentliche Certbot-Plugin
[`FlixMa/certbot-dns-strato`](https://github.com/FlixMa/certbot-dns-strato) [`FlixMa/certbot-dns-strato`](https://github.com/FlixMa/certbot-dns-strato)
zeigt jedoch einen funktionsfähigen HTTP-Ablauf über zeigt den von STRATO selbst verwendeten HTTP-Ablauf über
`https://www.strato.de/apps/CustomerService`. Der hier verwendete Leseweg wurde `https://www.strato.de/apps/CustomerService`.
ohne Certbot-Abhängigkeit neu und deutlich defensiver implementiert.
Referenzstand der Untersuchung: Der MCP liest vor jeder Änderung das komplette TXT/CNAME-Formular, verändert
genau den gewünschten CNAME, sendet alle übrigen Einträge unverändert zurück
und liest danach erneut zur Kontrolle. Referenzstand der Untersuchung:
`FlixMa/certbot-dns-strato@67df6dcfc3ef0035ec5aa5daf7c5b0bd8310d7fb`. `FlixMa/certbot-dns-strato@67df6dcfc3ef0035ec5aa5daf7c5b0bd8310d7fb`.
## Lokaler Test – noch nicht produktiv installieren ## Werkzeuge
- `strato_connection_status`
- `strato_list_cnames`
- `strato_create_cname`
- `strato_update_cname`
- `strato_delete_cname`
## Konfiguration und Start
1. `strato.env.example` außerhalb von Git nach `strato.env` kopieren. 1. `strato.env.example` außerhalb von Git nach `strato.env` kopieren.
2. Dort Benutzername, Passwort und DNS-Zone eintragen. 2. Benutzername, Passwort und DNS-Zone eintragen.
3. Falls STRATO TOTP verlangt, zusätzlich TOTP-Secret und den bei STRATO 3. Falls STRATO TOTP verlangt, TOTP-Secret und Gerätenamen eintragen.
angezeigten Gerätenamen eintragen. 4. Für Änderungen zusätzlich `STRATO_WRITE_ENABLED=true` setzen. Ohne diesen
4. Image bauen und zunächst ausschließlich `strato_connection_status` sowie Schalter bleiben die drei Schreibwerkzeuge gesperrt.
`strato_list_cnames` testen.
Das Docker-Image wird direkt aus diesem Repository gebaut: Das Docker-Image wird direkt aus diesem Repository gebaut. Der historische
Image-Name bleibt erhalten, damit eine bestehende Unraid-Vorlage ohne Umbau
weiter funktioniert:
```sh ```sh
docker build -t strato-dns-mcp:readonly . docker build -t strato-dns-mcp:readonly .
``` ```
Die Offline-Tests benötigen keine Zugangsdaten und kontaktieren STRATO nicht: Die Offline-Tests benötigen keine Zugangsdaten, kontaktieren STRATO nicht und
prüfen auch, dass bestehende TXT- und CNAME-Einträge erhalten bleiben:
```sh ```sh
python3 -m unittest -v tests/test_strato_readonly.py python3 -m unittest -v tests/test_strato_readonly.py
``` ```
Zugangsdaten, TOTP-Werte, Cookies und STRATO-Session-ID werden weder geloggt Zugangsdaten, TOTP-Werte, Cookies und STRATO-Session-ID werden weder geloggt
noch als Toolausgabe zurückgegeben. Fehlermeldungen enthalten nur eine kurze noch als Toolausgabe zurückgegeben.
Fehlerklasse.
## Noch bewusst nicht enthalten ## Produktiver Funktionstest
- kein Compose-Deployment Echte DNS-Schreibtests werden nicht automatisch ausgeführt. Nach dem Neubau
- keine Unraid-XML des Containers sollte ein eigens dafür vorgesehener Testname einmal angelegt,
- keine Hermes-Registrierung geändert und wieder gelöscht werden. Danach stehen die neuen Werkzeuge in
- keine schreibenden Werkzeuge Hermes nach einer frischen MCP-Verbindung beziehungsweise einer neuen Sitzung
- keine produktive Installation zur Verfügung.
Diese Teile kommen erst, wenn der Read-only-Test gegen das aktuelle STRATO-
Konto funktioniert. Falls sich der Login oder das HTML geändert hat, wird nur
der Parser angepasst; es findet kein Schreibversuch statt.
+3
View File
@@ -10,4 +10,7 @@ STRATO_PACKAGE_ID=
STRATO_TOTP_SECRET= STRATO_TOTP_SECRET=
STRATO_TOTP_DEVICE= STRATO_TOTP_DEVICE=
# Schreibwerkzeuge bleiben ohne diese bewusste Freigabe gesperrt.
STRATO_WRITE_ENABLED=false
STRATO_TIMEOUT_SECONDS=20 STRATO_TIMEOUT_SECONDS=20
+171 -11
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Small read-only HTTP client for STRATO's customer portal. """Small HTTP client for STRATO's customer portal.
STRATO does not publish a DNS-zone API for ordinary hosted domains. This STRATO does not publish a DNS-zone API for ordinary hosted domains. This
client deliberately implements only the minimum read path proven by the client implements the minimum path proven by the public certbot-dns-strato
public certbot-dns-strato project: authenticate, resolve the package that owns project: authenticate, resolve the package that owns one configured DNS zone,
one configured DNS zone, and read its combined TXT/CNAME form. read its combined TXT/CNAME form, and replace that form after a narrowly scoped
CNAME change. Every write is followed by a fresh read for verification.
There is intentionally no method that submits DNS changes.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +28,7 @@ from typing import Callable, Iterable
STRATO_URL = "https://www.strato.de/apps/CustomerService" STRATO_URL = "https://www.strato.de/apps/CustomerService"
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 MAX_RESPONSE_BYTES = 5 * 1024 * 1024
USER_AGENT = "Mozilla/5.0 (compatible; mike-ai-strato-dns-readonly/0.1)" USER_AGENT = "Mozilla/5.0 (compatible; mike-ai-strato-dns-mcp/0.2)"
class StratoError(RuntimeError): class StratoError(RuntimeError):
@@ -44,6 +43,10 @@ class StratoParseError(StratoError):
pass pass
class StratoWriteDisabledError(StratoError):
pass
@dataclass(frozen=True) @dataclass(frozen=True)
class StratoConfig: class StratoConfig:
username: str username: str
@@ -53,6 +56,7 @@ class StratoConfig:
totp_secret: str | None = None totp_secret: str | None = None
totp_device: str | None = None totp_device: str | None = None
timeout_seconds: float = 20.0 timeout_seconds: float = 20.0
write_enabled: bool = False
@classmethod @classmethod
def from_env(cls) -> "StratoConfig": def from_env(cls) -> "StratoConfig":
@@ -77,6 +81,8 @@ class StratoConfig:
totp_secret=os.environ.get("STRATO_TOTP_SECRET", "").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, totp_device=os.environ.get("STRATO_TOTP_DEVICE", "").strip() or None,
timeout_seconds=float(os.environ.get("STRATO_TIMEOUT_SECONDS", "20")), timeout_seconds=float(os.environ.get("STRATO_TIMEOUT_SECONDS", "20")),
write_enabled=os.environ.get("STRATO_WRITE_ENABLED", "false").strip().lower()
in {"1", "true", "yes", "on"},
) )
@@ -90,6 +96,12 @@ class DnsRecord:
return {"type": self.type, "prefix": self.prefix, "value": self.value} return {"type": self.type, "prefix": self.prefix, "value": self.value}
@dataclass(frozen=True)
class DnsForm:
records: tuple[DnsRecord, ...]
submit_value: str
class _FormParser(HTMLParser): class _FormParser(HTMLParser):
"""Extract parallel type/prefix/value fields from STRATO's DNS form.""" """Extract parallel type/prefix/value fields from STRATO's DNS form."""
@@ -103,11 +115,14 @@ class _FormParser(HTMLParser):
self._option_value = "" self._option_value = ""
self._in_value_textarea = False self._in_value_textarea = False
self._textarea_parts: list[str] = [] self._textarea_parts: list[str] = []
self.submit_value: str | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
data = dict(attrs) data = dict(attrs)
if tag == "input" and data.get("name") == "prefix": if tag == "input" and data.get("name") == "prefix":
self.prefixes.append(data.get("value") or "") self.prefixes.append(data.get("value") or "")
elif tag == "input" and data.get("name") == "action_change_txt_records":
self.submit_value = data.get("value") or ""
elif tag == "select" and data.get("name") == "type": elif tag == "select" and data.get("name") == "type":
self._in_type_select = True self._in_type_select = True
elif tag == "option" and self._in_type_select: elif tag == "option" and self._in_type_select:
@@ -176,7 +191,7 @@ def _totp(secret: str, at_time: int | None = None) -> str:
return f"{number % 1_000_000:06d}" return f"{number % 1_000_000:06d}"
def parse_records(html: str) -> list[DnsRecord]: def parse_dns_form(html: str) -> DnsForm:
parser = _FormParser() parser = _FormParser()
parser.feed(html) parser.feed(html)
counts = (len(parser.types), len(parser.prefixes), len(parser.values)) counts = (len(parser.types), len(parser.prefixes), len(parser.values))
@@ -184,9 +199,51 @@ def parse_records(html: str) -> list[DnsRecord]:
raise StratoParseError( raise StratoParseError(
"STRATO DNS form changed: type/prefix/value field counts do not match" "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( if not parser.submit_value:
raise StratoParseError("STRATO DNS form changed: submit action is missing")
records = tuple(DnsRecord(t.upper(), p.strip(), v.strip()) for t, p, v in zip(
parser.types, parser.prefixes, parser.values, strict=True parser.types, parser.prefixes, parser.values, strict=True
)] ))
return DnsForm(records=records, submit_value=parser.submit_value)
def parse_records(html: str) -> list[DnsRecord]:
"""Compatibility helper for callers that only need the record list."""
return list(parse_dns_form(html).records)
def normalize_cname_prefix(prefix: str, zone: str) -> str:
value = prefix.strip().rstrip(".").lower()
zone = zone.lower().rstrip(".")
if value.endswith("." + zone):
value = value[: -(len(zone) + 1)]
if not value or value == "@":
raise StratoError("CNAME prefix must name a subdomain, not the zone apex")
labels = value.split(".")
for index, label in enumerate(labels):
if label == "*":
if index != 0:
raise StratoError("A wildcard is allowed only in the first DNS label")
continue
ascii_label = label.encode("idna").decode("ascii")
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", ascii_label):
raise StratoError("CNAME prefix is not a valid relative DNS name")
return ".".join(label.encode("idna").decode("ascii") for label in labels)
def normalize_cname_target(target: str) -> str:
value = target.strip().rstrip(".").lower()
if not value or "://" in value or "/" in value:
raise StratoError("CNAME target must be a DNS name without scheme or path")
try:
ascii_value = value.encode("idna").decode("ascii")
except UnicodeError as exc:
raise StratoError("CNAME target is not a valid DNS name") from exc
if not re.fullmatch(
r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", ascii_value
) or any(not label or len(label) > 63 for label in ascii_value.split(".")):
raise StratoError("CNAME target is not a valid DNS name")
return ascii_value
def parse_package_id(html: str, domain: str) -> str: def parse_package_id(html: str, domain: str) -> str:
@@ -300,6 +357,9 @@ class StratoClient:
return self.package_id return self.package_id
def list_txt_and_cname_records(self) -> list[DnsRecord]: def list_txt_and_cname_records(self) -> list[DnsRecord]:
return list(self._get_dns_form().records)
def _get_dns_form(self) -> DnsForm:
if not self.session_id: if not self.session_id:
self.login() self.login()
package_id = self.resolve_package() package_id = self.resolve_package()
@@ -313,7 +373,107 @@ class StratoClient:
"vhost": self.config.domain, "vhost": self.config.domain,
}, },
) )
return parse_records(html) return parse_dns_form(html)
def list_cnames(self) -> list[DnsRecord]: def list_cnames(self) -> list[DnsRecord]:
return [record for record in self.list_txt_and_cname_records() if record.type == "CNAME"] return [record for record in self.list_txt_and_cname_records() if record.type == "CNAME"]
def _require_writes(self) -> None:
if not self.config.write_enabled:
raise StratoWriteDisabledError(
"DNS writes are disabled; set STRATO_WRITE_ENABLED=true to enable them"
)
def _push_records(self, form: DnsForm, records: list[DnsRecord]) -> None:
self._require_writes()
package_id = self.resolve_package()
self._request(
"POST",
form={
"sessionID": self.session_id or "",
"cID": package_id,
"node": "ManageDomains",
"vhost": self.config.domain,
"prefix": [record.prefix for record in records],
"type": [record.type for record in records],
"value": [record.value for record in records],
"action_change_txt_records": form.submit_value,
},
)
@staticmethod
def _cname_at(records: Iterable[DnsRecord], prefix: str) -> list[DnsRecord]:
return [
record for record in records
if record.type == "CNAME" and record.prefix.lower() == prefix.lower()
]
def create_cname(self, prefix: str, target: str) -> DnsRecord:
normalized_prefix = normalize_cname_prefix(prefix, self.config.domain)
normalized_target = normalize_cname_target(target)
form = self._get_dns_form()
collisions = [
record for record in form.records
if record.prefix.lower() == normalized_prefix.lower()
]
if collisions:
raise StratoError("A DNS record with this prefix already exists")
created = DnsRecord("CNAME", normalized_prefix, normalized_target)
self._push_records(form, [*form.records, created])
verified = self._cname_at(self._get_dns_form().records, normalized_prefix)
if len(verified) != 1 or normalize_cname_target(verified[0].value) != normalized_target:
raise StratoError("STRATO did not persist the new CNAME record")
return verified[0]
def update_cname(
self,
prefix: str,
target: str,
*,
new_prefix: str | None = None,
) -> tuple[DnsRecord, DnsRecord]:
old_prefix = normalize_cname_prefix(prefix, self.config.domain)
destination_prefix = normalize_cname_prefix(
new_prefix if new_prefix is not None else prefix,
self.config.domain,
)
normalized_target = normalize_cname_target(target)
form = self._get_dns_form()
matches = self._cname_at(form.records, old_prefix)
if len(matches) != 1:
raise StratoError("Exactly one existing CNAME with this prefix is required")
if destination_prefix.lower() != old_prefix.lower() and any(
record.prefix.lower() == destination_prefix.lower() for record in form.records
):
raise StratoError("A DNS record with the new prefix already exists")
replacement = DnsRecord("CNAME", destination_prefix, normalized_target)
updated_records = [
replacement if record is matches[0] else record for record in form.records
]
self._push_records(form, updated_records)
verified_form = self._get_dns_form()
verified = self._cname_at(verified_form.records, destination_prefix)
old_remaining = (
self._cname_at(verified_form.records, old_prefix)
if destination_prefix.lower() != old_prefix.lower()
else []
)
if (
len(verified) != 1
or old_remaining
or normalize_cname_target(verified[0].value) != normalized_target
):
raise StratoError("STRATO did not persist the CNAME update")
return matches[0], verified[0]
def delete_cname(self, prefix: str) -> DnsRecord:
normalized_prefix = normalize_cname_prefix(prefix, self.config.domain)
form = self._get_dns_form()
matches = self._cname_at(form.records, normalized_prefix)
if len(matches) != 1:
raise StratoError("Exactly one existing CNAME with this prefix is required")
remaining = [record for record in form.records if record is not matches[0]]
self._push_records(form, remaining)
if self._cname_at(self._get_dns_form().records, normalized_prefix):
raise StratoError("STRATO did not delete the CNAME record")
return matches[0]
+50 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Read-only STRATO DNS MCP proof of concept.""" """STRATO DNS MCP for a single configured zone."""
from __future__ import annotations from __future__ import annotations
@@ -12,10 +12,10 @@ from strato_client import StratoClient, StratoConfig, StratoError
mcp = FastMCP( mcp = FastMCP(
"strato-dns-readonly", "strato-dns",
instructions=( instructions=(
"Read the configured STRATO DNS zone. This experimental server is " "Read and manage CNAME records in the configured STRATO DNS zone. "
"strictly read-only and cannot create, change, or delete DNS records." "Write tools are available only when STRATO_WRITE_ENABLED=true."
), ),
host="0.0.0.0", host="0.0.0.0",
port=int(os.environ.get("PORT", "8000")), port=int(os.environ.get("PORT", "8000")),
@@ -39,6 +39,7 @@ def strato_connection_status() -> str:
"record_count": len(records), "record_count": len(records),
"cname_count": sum(record.type == "CNAME" for record in records), "cname_count": sum(record.type == "CNAME" for record in records),
"read_only": True, "read_only": True,
"write_enabled": config.write_enabled,
}) })
except StratoError as exc: except StratoError as exc:
return _json({"connected": False, "error": str(exc), "read_only": True}) return _json({"connected": False, "error": str(exc), "read_only": True})
@@ -56,10 +57,55 @@ def strato_list_cnames() -> str:
"records": [record.as_dict() for record in records[:200]], "records": [record.as_dict() for record in records[:200]],
"truncated": len(records) > 200, "truncated": len(records) > 200,
"read_only": True, "read_only": True,
"write_enabled": config.write_enabled,
}) })
except StratoError as exc: except StratoError as exc:
return _json({"error": str(exc), "read_only": True}) return _json({"error": str(exc), "read_only": True})
@mcp.tool()
def strato_create_cname(prefix: str, target: str) -> str:
"""Create one CNAME. Prefix may be relative or end in the configured zone."""
try:
config = StratoConfig.from_env()
record = StratoClient(config).create_cname(prefix, target)
return _json({"created": True, "domain": config.domain, "record": record.as_dict()})
except StratoError as exc:
return _json({"created": False, "error": str(exc)})
@mcp.tool()
def strato_update_cname(prefix: str, target: str, new_prefix: str | None = None) -> str:
"""Change a CNAME target and optionally rename its prefix."""
try:
config = StratoConfig.from_env()
before, after = StratoClient(config).update_cname(
prefix, target, new_prefix=new_prefix
)
return _json({
"updated": True,
"domain": config.domain,
"before": before.as_dict(),
"after": after.as_dict(),
})
except StratoError as exc:
return _json({"updated": False, "error": str(exc)})
@mcp.tool()
def strato_delete_cname(prefix: str) -> str:
"""Delete exactly one existing CNAME identified by its prefix."""
try:
config = StratoConfig.from_env()
deleted = StratoClient(config).delete_cname(prefix)
return _json({
"deleted": True,
"domain": config.domain,
"record": deleted.as_dict(),
})
except StratoError as exc:
return _json({"deleted": False, "error": str(exc)})
if __name__ == "__main__": if __name__ == "__main__":
mcp.run(transport=os.environ.get("MCP_TRANSPORT", "streamable-http")) mcp.run(transport=os.environ.get("MCP_TRANSPORT", "streamable-http"))
+143 -5
View File
@@ -5,6 +5,7 @@ import importlib.util
import os import os
import sys import sys
import unittest import unittest
import urllib.parse
from pathlib import Path from pathlib import Path
@@ -39,6 +40,19 @@ class FakeOpener:
class StratoParserTests(unittest.TestCase): class StratoParserTests(unittest.TestCase):
@staticmethod
def dns_form(*records: tuple[str, str, str]) -> str:
fields = []
for record_type, prefix, value in records:
fields.append(
f'<select name="type"><option value="{record_type}" selected>'
f'{record_type}</option></select>'
f'<input name="prefix" value="{prefix}">'
f'<textarea name="value">{value}</textarea>'
)
fields.append('<input name="action_change_txt_records" value="Speichern">')
return "".join(fields)
def test_dns_form_is_parsed_without_script_or_markup(self) -> None: def test_dns_form_is_parsed_without_script_or_markup(self) -> None:
html = """ html = """
<select name="type"><option value="TXT">TXT</option><option value="CNAME" selected>CNAME</option></select> <select name="type"><option value="TXT">TXT</option><option value="CNAME" selected>CNAME</option></select>
@@ -47,6 +61,7 @@ class StratoParserTests(unittest.TestCase):
<select name="type"><option value="TXT" selected>TXT</option></select> <select name="type"><option value="TXT" selected>TXT</option></select>
<input name="prefix" value="_acme-challenge"> <input name="prefix" value="_acme-challenge">
<textarea name="value">public-validation-value</textarea> <textarea name="value">public-validation-value</textarea>
<input name="action_change_txt_records" value="Speichern">
""" """
records = module.parse_records(html) records = module.parse_records(html)
self.assertEqual(records[0].as_dict(), { self.assertEqual(records[0].as_dict(), {
@@ -91,11 +106,7 @@ class StratoParserTests(unittest.TestCase):
<table id="package_list"><tr><td>example.de Hosting</td> <table id="package_list"><tr><td>example.de Hosting</td>
<td><a href="?node=x&amp;cID=42">open</a></td></tr></table> <td><a href="?node=x&amp;cID=42">open</a></td></tr></table>
""" """
records_html = """ records_html = self.dns_form(("CNAME", "media", "proxy.example.net."))
<select name="type"><option value="CNAME" selected>CNAME</option></select>
<input name="prefix" value="media">
<textarea name="value">proxy.example.net.</textarea>
"""
opener = FakeOpener([ opener = FakeOpener([
FakeResponse(module.STRATO_URL, "login"), FakeResponse(module.STRATO_URL, "login"),
FakeResponse(module.STRATO_URL + "?sessionID=test-session", "welcome"), FakeResponse(module.STRATO_URL + "?sessionID=test-session", "welcome"),
@@ -111,6 +122,133 @@ class StratoParserTests(unittest.TestCase):
self.assertEqual(methods, ["GET", "POST", "GET", "GET"]) self.assertEqual(methods, ["GET", "POST", "GET", "GET"])
self.assertNotIn("secret-value", opener.requests[1][0].full_url) self.assertNotIn("secret-value", opener.requests[1][0].full_url)
def client_for_write(self, responses: list[FakeResponse]) -> tuple[object, FakeOpener]:
opener = FakeOpener(responses)
config = module.StratoConfig(
"customer", "secret-value", "example.de",
package_id="42", write_enabled=True,
)
client = module.StratoClient(config, opener=opener, sleep=lambda _seconds: None)
client.session_id = "test-session"
return client, opener
@staticmethod
def posted_form(opener: FakeOpener) -> dict[str, list[str]]:
posts = [request for request, _timeout in opener.requests if request.method == "POST"]
assert len(posts) == 1
return urllib.parse.parse_qs(posts[0].data.decode(), keep_blank_values=True)
def test_create_cname_preserves_all_existing_records_and_verifies(self) -> None:
before = self.dns_form(
("TXT", "_acme-challenge", "validation-value"),
("CNAME", "media", "proxy.example.net."),
)
after = self.dns_form(
("TXT", "_acme-challenge", "validation-value"),
("CNAME", "media", "proxy.example.net."),
("CNAME", "chat", "gateway.example.net"),
)
client, opener = self.client_for_write([
FakeResponse(module.STRATO_URL, before),
FakeResponse(module.STRATO_URL, "saved"),
FakeResponse(module.STRATO_URL, after),
])
created = client.create_cname("chat.example.de", "gateway.example.net.")
self.assertEqual(created.as_dict(), {
"type": "CNAME", "prefix": "chat", "value": "gateway.example.net"
})
posted = self.posted_form(opener)
self.assertEqual(posted["type"], ["TXT", "CNAME", "CNAME"])
self.assertEqual(posted["prefix"], ["_acme-challenge", "media", "chat"])
self.assertEqual(
posted["value"],
["validation-value", "proxy.example.net.", "gateway.example.net"],
)
self.assertEqual(posted["action_change_txt_records"], ["Speichern"])
def test_update_cname_can_change_target_and_prefix(self) -> None:
before = self.dns_form(
("TXT", "verification", "keep-me"),
("CNAME", "old", "old-target.example.net"),
)
after = self.dns_form(
("TXT", "verification", "keep-me"),
("CNAME", "new", "new-target.example.net"),
)
client, opener = self.client_for_write([
FakeResponse(module.STRATO_URL, before),
FakeResponse(module.STRATO_URL, "saved"),
FakeResponse(module.STRATO_URL, after),
])
old, new = client.update_cname("old", "new-target.example.net", new_prefix="new")
self.assertEqual(old.prefix, "old")
self.assertEqual(new.prefix, "new")
posted = self.posted_form(opener)
self.assertEqual(posted["prefix"], ["verification", "new"])
self.assertEqual(posted["value"], ["keep-me", "new-target.example.net"])
def test_delete_cname_preserves_unrelated_records_and_verifies(self) -> None:
before = self.dns_form(
("TXT", "verification", "keep-me"),
("CNAME", "remove", "old.example.net"),
("CNAME", "keep", "keep.example.net"),
)
after = self.dns_form(
("TXT", "verification", "keep-me"),
("CNAME", "keep", "keep.example.net"),
)
client, opener = self.client_for_write([
FakeResponse(module.STRATO_URL, before),
FakeResponse(module.STRATO_URL, "saved"),
FakeResponse(module.STRATO_URL, after),
])
deleted = client.delete_cname("remove")
self.assertEqual(deleted.prefix, "remove")
posted = self.posted_form(opener)
self.assertEqual(posted["prefix"], ["verification", "keep"])
self.assertEqual(posted["value"], ["keep-me", "keep.example.net"])
def test_write_is_blocked_without_explicit_enable_flag(self) -> None:
opener = FakeOpener([
FakeResponse(module.STRATO_URL, self.dns_form(
("CNAME", "media", "proxy.example.net")
)),
])
config = module.StratoConfig(
"customer", "secret-value", "example.de", package_id="42"
)
client = module.StratoClient(config, opener=opener)
client.session_id = "test-session"
with self.assertRaises(module.StratoWriteDisabledError):
client.delete_cname("media")
self.assertEqual([request.method for request, _ in opener.requests], ["GET"])
def test_invalid_names_and_collisions_fail_before_post(self) -> None:
with self.assertRaises(module.StratoError):
module.normalize_cname_prefix("@", "example.de")
with self.assertRaises(module.StratoError):
module.normalize_cname_prefix("foo.*", "example.de")
with self.assertRaises(module.StratoError):
module.normalize_cname_target("https://target.example.net/path")
with self.assertRaises(module.StratoError):
module.normalize_cname_target("target..example.net")
client, opener = self.client_for_write([
FakeResponse(module.STRATO_URL, self.dns_form(
("TXT", "chat", "already-used")
)),
])
with self.assertRaisesRegex(module.StratoError, "already exists"):
client.create_cname("chat", "target.example.net")
self.assertEqual([request.method for request, _ in opener.requests], ["GET"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()