Add CNAME create update and delete tools
This commit is contained in:
@@ -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.
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"))
|
||||||
|
|||||||
@@ -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&cID=42">open</a></td></tr></table>
|
<td><a href="?node=x&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()
|
||||||
|
|||||||
Reference in New Issue
Block a user