Add external adapter plugin boundary
This commit is contained in:
+16
-5
@@ -13,7 +13,12 @@ from .engine import (
|
||||
from .executor import execute_endpoint_plans
|
||||
from .orchestrator import discover_reconcile_plan
|
||||
from .persistence import SQLiteStateStore
|
||||
from .registry import load_adapter, load_provider, load_source
|
||||
from .registry import (
|
||||
enabled_plugins,
|
||||
load_adapter,
|
||||
load_provider,
|
||||
load_source,
|
||||
)
|
||||
from .reporting import print_endpoint_plans, print_plan
|
||||
|
||||
|
||||
@@ -140,10 +145,16 @@ def run_sync(args):
|
||||
|
||||
|
||||
def _configured_adapters(config):
|
||||
endpoints = (
|
||||
*config.npm_endpoints,
|
||||
*config.verizon_endpoints,
|
||||
)
|
||||
enabled = enabled_plugins()
|
||||
|
||||
endpoints = []
|
||||
|
||||
if "npm" in enabled:
|
||||
endpoints.extend(config.npm_endpoints)
|
||||
|
||||
if "verizon" in enabled:
|
||||
endpoints.extend(config.verizon_endpoints)
|
||||
|
||||
return [
|
||||
load_adapter(endpoint.plugin, config, endpoint)
|
||||
for endpoint in endpoints
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
from ...adapters import (
|
||||
DNS_RECORDS,
|
||||
ApplyResult,
|
||||
Capability,
|
||||
CapabilityOperation,
|
||||
DiscoveredObject,
|
||||
NormalizedObject,
|
||||
PlanAction,
|
||||
PlanOperation,
|
||||
)
|
||||
from ...models import DesiredRecord
|
||||
from ..providers.verizon import Plugin as VerizonProvider
|
||||
|
||||
|
||||
class Plugin:
|
||||
name = "verizon"
|
||||
capabilities = frozenset(
|
||||
{
|
||||
Capability(
|
||||
DNS_RECORDS,
|
||||
CapabilityOperation.DISCOVER,
|
||||
),
|
||||
Capability(
|
||||
DNS_RECORDS,
|
||||
CapabilityOperation.APPLY,
|
||||
),
|
||||
Capability(
|
||||
DNS_RECORDS,
|
||||
CapabilityOperation.VERIFY,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, config, endpoint):
|
||||
if endpoint.plugin != self.name:
|
||||
raise RuntimeError(
|
||||
"Verizon adapter requires a verizon endpoint"
|
||||
)
|
||||
|
||||
self.config = config
|
||||
self.endpoint = endpoint
|
||||
self.provider = VerizonProvider(config, endpoint)
|
||||
|
||||
def connect(self) -> None:
|
||||
self.provider.connect()
|
||||
|
||||
def discover(
|
||||
self,
|
||||
object_type: str,
|
||||
) -> list[DiscoveredObject]:
|
||||
if object_type != DNS_RECORDS:
|
||||
raise RuntimeError(
|
||||
f"{self.endpoint.identity} cannot discover "
|
||||
f"object type {object_type!r}"
|
||||
)
|
||||
|
||||
discovered: list[DiscoveredObject] = []
|
||||
records = sorted(
|
||||
self.provider.list_records().values(),
|
||||
key=lambda record: record.hostname.lower(),
|
||||
)
|
||||
|
||||
for record in records:
|
||||
slot = record.metadata.get("slot")
|
||||
if isinstance(slot, bool) or not isinstance(slot, int):
|
||||
raise RuntimeError(
|
||||
f"Verizon DNS record {record.hostname!r} "
|
||||
"has no valid endpoint-local slot"
|
||||
)
|
||||
|
||||
discovered.append(
|
||||
DiscoveredObject(
|
||||
object_type=DNS_RECORDS,
|
||||
local_key=str(slot),
|
||||
payload={
|
||||
"hostname": record.hostname,
|
||||
"ip": record.ip,
|
||||
"record_type": record.record_type,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return discovered
|
||||
|
||||
def _desired_record(
|
||||
self,
|
||||
object: NormalizedObject,
|
||||
) -> DesiredRecord:
|
||||
if object.object_type != DNS_RECORDS:
|
||||
raise RuntimeError(
|
||||
f"{self.endpoint.identity} cannot handle "
|
||||
f"object type {object.object_type!r}"
|
||||
)
|
||||
|
||||
payload = object.payload
|
||||
hostname = str(payload.get("hostname") or "")
|
||||
if hostname != object.logical_key:
|
||||
raise RuntimeError(
|
||||
"DNS object hostname does not match its logical key"
|
||||
)
|
||||
|
||||
record_type = str(
|
||||
payload.get("record_type") or ""
|
||||
).upper()
|
||||
if record_type != "A":
|
||||
raise RuntimeError(
|
||||
f"Verizon DNS does not support {record_type!r} records"
|
||||
)
|
||||
|
||||
wildcard = payload.get("wildcard") is True
|
||||
if wildcard:
|
||||
raise RuntimeError(
|
||||
"Verizon DNS does not support wildcard records"
|
||||
)
|
||||
|
||||
return DesiredRecord(
|
||||
hostname=hostname,
|
||||
ip=str(payload.get("ip") or ""),
|
||||
record_type=record_type,
|
||||
wildcard=False,
|
||||
)
|
||||
|
||||
def apply(self, action: PlanAction) -> ApplyResult | None:
|
||||
if action.endpoint_id != self.endpoint.identity:
|
||||
raise RuntimeError(
|
||||
"Plan action belongs to a different endpoint"
|
||||
)
|
||||
|
||||
if action.operation in {
|
||||
PlanOperation.NOOP,
|
||||
PlanOperation.SKIP,
|
||||
}:
|
||||
return None
|
||||
|
||||
if action.desired is None:
|
||||
raise RuntimeError(
|
||||
"Verizon apply action requires desired state"
|
||||
)
|
||||
|
||||
record = self._desired_record(action.desired)
|
||||
|
||||
if action.operation == PlanOperation.CREATE:
|
||||
detail = self.provider.add_record(record)
|
||||
elif action.operation == PlanOperation.UPDATE:
|
||||
detail = self.provider.update_record(record)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Verizon adapter does not support "
|
||||
f"{action.operation.value} actions"
|
||||
)
|
||||
|
||||
records = self.provider.list_records()
|
||||
applied_record = records.get(record.hostname.lower())
|
||||
if applied_record is None:
|
||||
applied_record = records.get(record.hostname)
|
||||
|
||||
if applied_record is None:
|
||||
raise RuntimeError(
|
||||
"Verizon mutation succeeded but the applied DNS "
|
||||
f"record {record.hostname!r} could not be rediscovered"
|
||||
)
|
||||
|
||||
slot = applied_record.metadata.get("slot")
|
||||
if isinstance(slot, bool) or not isinstance(slot, int):
|
||||
raise RuntimeError(
|
||||
f"Verizon DNS record {record.hostname!r} "
|
||||
"has no valid endpoint-local slot after apply"
|
||||
)
|
||||
|
||||
applied = NormalizedObject(
|
||||
object_type=action.desired.object_type,
|
||||
logical_key=action.desired.logical_key,
|
||||
local_key=str(slot),
|
||||
payload=dict(action.desired.payload),
|
||||
references=action.desired.references,
|
||||
)
|
||||
|
||||
return ApplyResult(
|
||||
object=applied,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
def verify(self, object: NormalizedObject) -> bool:
|
||||
return self.provider.verify_record(
|
||||
self._desired_record(object)
|
||||
)
|
||||
@@ -1,232 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from ...adapters import Endpoint
|
||||
from ...models import ProviderCapabilities, ProviderRecord
|
||||
from ...normalizer import normalize_hostname, normalize_ipv4
|
||||
|
||||
LUCI_USERNAME = (
|
||||
"edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761"
|
||||
"afc91731d8d8ba6cbb188b2ed5bfb465b9f3d30231eb0430b9f90fe91d136648"
|
||||
)
|
||||
CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);')
|
||||
|
||||
|
||||
class _Slot:
|
||||
def __init__(self, slot):
|
||||
self.slot = slot
|
||||
self.hostname = ""
|
||||
self.ip = ""
|
||||
self.name_token = ""
|
||||
self.ip_token = ""
|
||||
|
||||
|
||||
class Plugin:
|
||||
name = "verizon"
|
||||
capabilities = ProviderCapabilities(
|
||||
record_types=frozenset({"A"}),
|
||||
wildcard_records=False,
|
||||
supports_create=True,
|
||||
supports_update=True,
|
||||
supports_delete=True,
|
||||
max_records=64,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
endpoint: Endpoint | None = None,
|
||||
):
|
||||
self.config = config
|
||||
self.endpoint = endpoint
|
||||
|
||||
if endpoint is None:
|
||||
self.router_url = os.getenv(
|
||||
"ROUTER_URL",
|
||||
"https://192.168.1.1",
|
||||
).rstrip("/")
|
||||
else:
|
||||
if endpoint.plugin != "verizon":
|
||||
raise RuntimeError(
|
||||
"Verizon provider requires a verizon endpoint"
|
||||
)
|
||||
self.router_url = f"https://{endpoint.address}"
|
||||
|
||||
self.password = os.getenv("ROUTER_PASSWORD")
|
||||
if not self.password:
|
||||
raise RuntimeError("ROUTER_PASSWORD is required when DNS_PROVIDER=verizon")
|
||||
|
||||
self.timeout = int(os.getenv("ROUTER_TIMEOUT", "10"))
|
||||
self.session = requests.Session()
|
||||
self.session.verify = False
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.auth_token = None
|
||||
self._last_slot_count = 0
|
||||
|
||||
def _url(self, path):
|
||||
return f"{self.router_url}{path}"
|
||||
|
||||
def connect(self):
|
||||
response = self.session.get(self._url("/loginStatus.cgi"), timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
login_token = response.json().get("loginToken")
|
||||
if not login_token:
|
||||
raise RuntimeError("Router did not return loginToken")
|
||||
|
||||
md5_password = hashlib.md5(self.password.encode("utf-8")).hexdigest()
|
||||
sha512_md5 = hashlib.sha512(md5_password.encode("ascii")).hexdigest()
|
||||
luci_password = hashlib.sha512((login_token + sha512_md5).encode("ascii")).hexdigest()
|
||||
|
||||
response = self.session.post(
|
||||
self._url("/login.cgi"),
|
||||
data={
|
||||
"luci_username": LUCI_USERNAME,
|
||||
"luci_password": luci_password,
|
||||
"luci_view": "Desktop",
|
||||
"luci_token": login_token,
|
||||
"luci_keep_login": "0",
|
||||
},
|
||||
allow_redirects=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if response.status_code not in (200, 302):
|
||||
raise RuntimeError(f"Router login failed: HTTP {response.status_code}")
|
||||
|
||||
response = self.session.get(self._url("/loginStatus.cgi"), timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
self.auth_token = response.json().get("token")
|
||||
if not self.auth_token:
|
||||
raise RuntimeError("Router login succeeded but authenticated token was missing")
|
||||
if "sysauth" not in self.session.cookies:
|
||||
raise RuntimeError("Router login succeeded but sysauth cookie was missing")
|
||||
|
||||
def _fetch_slots(self):
|
||||
if not self.auth_token:
|
||||
self.connect()
|
||||
|
||||
response = self.session.get(
|
||||
self._url("/cgi/cgi_dns_server.js"),
|
||||
timeout=self.timeout,
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
slots = {}
|
||||
for kind, slot_s, token, value in CFG_RE.findall(response.text):
|
||||
slot_num = int(slot_s)
|
||||
slot = slots.setdefault(slot_num, _Slot(slot_num))
|
||||
if kind == "ip":
|
||||
slot.ip = value
|
||||
slot.ip_token = token
|
||||
else:
|
||||
slot.hostname = value
|
||||
slot.name_token = token
|
||||
|
||||
if not slots:
|
||||
raise RuntimeError(
|
||||
"No Verizon dns_ip/dns_name slots were found; authentication may have expired or the router format changed"
|
||||
)
|
||||
self._last_slot_count = len(slots)
|
||||
return slots
|
||||
|
||||
@staticmethod
|
||||
def _find(slots, hostname):
|
||||
target = hostname.rstrip(".").lower()
|
||||
for slot in slots.values():
|
||||
if slot.hostname.rstrip(".").lower() == target:
|
||||
return slot
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _first_empty(slots):
|
||||
for slot_num in sorted(slots):
|
||||
slot = slots[slot_num]
|
||||
if not slot.hostname and not slot.ip:
|
||||
return slot
|
||||
return None
|
||||
|
||||
def list_records(self):
|
||||
slots = self._fetch_slots()
|
||||
result = {}
|
||||
for slot in slots.values():
|
||||
if not slot.hostname:
|
||||
continue
|
||||
hostname, wildcard = normalize_hostname(slot.hostname, allow_wildcard=True)
|
||||
if wildcard:
|
||||
# The firmware UI has no wildcard support, but normalize defensively.
|
||||
pass
|
||||
ip = normalize_ipv4(slot.ip, hostname)
|
||||
result[hostname.lower()] = ProviderRecord(
|
||||
hostname=hostname,
|
||||
ip=ip,
|
||||
record_type="A",
|
||||
metadata={"slot": slot.slot},
|
||||
)
|
||||
return result
|
||||
|
||||
def capacity(self):
|
||||
slots = self._fetch_slots()
|
||||
used = sum(1 for slot in slots.values() if slot.hostname or slot.ip)
|
||||
return used, len(slots) - used
|
||||
|
||||
def _write_slot(self, slot, hostname, ip):
|
||||
if not self.auth_token:
|
||||
raise RuntimeError("Verizon provider is not authenticated")
|
||||
if not slot.ip_token or not slot.name_token:
|
||||
raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens")
|
||||
|
||||
response = self.session.post(
|
||||
self._url("/apply_abstract.cgi"),
|
||||
data={
|
||||
"token": self.auth_token,
|
||||
"action": "dnsmasq_reload",
|
||||
slot.ip_token: ip,
|
||||
slot.name_token: hostname,
|
||||
},
|
||||
headers={"Origin": self.router_url, "Referer": f"{self.router_url}/"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def add_record(self, record):
|
||||
slots = self._fetch_slots() # fresh opaque tokens before every mutation
|
||||
existing = self._find(slots, record.hostname)
|
||||
if existing:
|
||||
raise RuntimeError(f"{record.hostname} already exists in Verizon slot {existing.slot}")
|
||||
slot = self._first_empty(slots)
|
||||
if slot is None:
|
||||
raise RuntimeError("No empty Verizon IPv4 DNS slots are available")
|
||||
self._write_slot(slot, record.hostname, record.ip)
|
||||
return f"slot {slot.slot}"
|
||||
|
||||
def update_record(self, record):
|
||||
slots = self._fetch_slots() # fresh opaque tokens before every mutation
|
||||
slot = self._find(slots, record.hostname)
|
||||
if slot is None:
|
||||
raise RuntimeError(f"{record.hostname} does not exist in Verizon DNS")
|
||||
self._write_slot(slot, record.hostname, record.ip)
|
||||
return f"slot {slot.slot}"
|
||||
|
||||
def delete_record(self, hostname):
|
||||
slots = self._fetch_slots()
|
||||
slot = self._find(slots, hostname)
|
||||
if slot is None:
|
||||
return None
|
||||
self._write_slot(slot, "", "")
|
||||
return f"slot {slot.slot}"
|
||||
|
||||
def verify_record(self, record):
|
||||
attempts = int(os.getenv("VERIFY_ATTEMPTS", "5"))
|
||||
delay = float(os.getenv("VERIFY_DELAY", "1"))
|
||||
for _ in range(attempts):
|
||||
slots = self._fetch_slots()
|
||||
slot = self._find(slots, record.hostname)
|
||||
if slot is not None and slot.ip == record.ip:
|
||||
return True
|
||||
time.sleep(delay)
|
||||
return False
|
||||
+174
-10
@@ -1,25 +1,184 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def _load(kind: str, name: str, config, *args):
|
||||
if not name or not name.replace("_", "").replace("-", "").isalnum():
|
||||
raise RuntimeError(f"Invalid {kind} plugin name: {name!r}")
|
||||
_EXTERNAL_NAMESPACE = "dns_sync_external_plugins"
|
||||
|
||||
|
||||
def _validate_plugin_name(kind: str, name: str) -> str:
|
||||
normalized = name.strip().lower()
|
||||
|
||||
if (
|
||||
not normalized
|
||||
or not normalized.replace("_", "").replace("-", "").isalnum()
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Invalid {kind} plugin name: {name!r}"
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def enabled_plugins() -> frozenset[str]:
|
||||
raw = os.getenv("ENABLED_PLUGINS", "npm")
|
||||
|
||||
names = [
|
||||
item.strip().lower()
|
||||
for item in raw.split(",")
|
||||
]
|
||||
|
||||
if not names or any(not name for name in names):
|
||||
raise RuntimeError(
|
||||
"ENABLED_PLUGINS contains an empty plugin name"
|
||||
)
|
||||
|
||||
validated = tuple(
|
||||
_validate_plugin_name("adapter", name)
|
||||
for name in names
|
||||
)
|
||||
|
||||
if len(set(validated)) != len(validated):
|
||||
raise RuntimeError(
|
||||
"ENABLED_PLUGINS contains duplicate plugin names"
|
||||
)
|
||||
|
||||
return frozenset(validated)
|
||||
|
||||
|
||||
def _external_plugin_root() -> Path:
|
||||
return Path(
|
||||
os.getenv(
|
||||
"DNS_SYNC_PLUGIN_PATH",
|
||||
"/plugins",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_external_namespace() -> None:
|
||||
if _EXTERNAL_NAMESPACE in sys.modules:
|
||||
return
|
||||
|
||||
namespace = ModuleType(_EXTERNAL_NAMESPACE)
|
||||
namespace.__package__ = _EXTERNAL_NAMESPACE
|
||||
namespace.__path__ = [
|
||||
str(_external_plugin_root())
|
||||
]
|
||||
sys.modules[_EXTERNAL_NAMESPACE] = namespace
|
||||
|
||||
|
||||
def _load_external_adapter_module(name: str):
|
||||
module_name = name.replace("-", "_")
|
||||
package_path = (
|
||||
_external_plugin_root()
|
||||
/ module_name
|
||||
/ "__init__.py"
|
||||
)
|
||||
|
||||
if not package_path.is_file():
|
||||
return None
|
||||
|
||||
_ensure_external_namespace()
|
||||
|
||||
qualified_name = (
|
||||
f"{_EXTERNAL_NAMESPACE}.{module_name}"
|
||||
)
|
||||
|
||||
existing = sys.modules.get(qualified_name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
qualified_name,
|
||||
package_path,
|
||||
submodule_search_locations=[
|
||||
str(package_path.parent)
|
||||
],
|
||||
)
|
||||
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(
|
||||
f"Could not load external adapter plugin "
|
||||
f"{name!r} from {package_path}"
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[qualified_name] = module
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(qualified_name, None)
|
||||
raise
|
||||
|
||||
return module
|
||||
|
||||
|
||||
def _load_builtin(kind: str, name: str):
|
||||
module_name = name.replace("-", "_")
|
||||
path = f"dns_sync.plugins.{kind}.{module_name}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(path)
|
||||
return importlib.import_module(path)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name == path:
|
||||
raise RuntimeError(f"Unknown {kind[:-1]} plugin: {name}") from exc
|
||||
return None
|
||||
raise
|
||||
|
||||
plugin_class = getattr(module, "Plugin", None)
|
||||
if plugin_class is None:
|
||||
raise RuntimeError(f"Plugin module {path} does not export Plugin")
|
||||
|
||||
return plugin_class(config, *args)
|
||||
def _plugin_class(module, display_path: str):
|
||||
plugin_class = getattr(module, "Plugin", None)
|
||||
|
||||
if plugin_class is None:
|
||||
raise RuntimeError(
|
||||
f"Plugin module {display_path} does not export Plugin"
|
||||
)
|
||||
|
||||
return plugin_class
|
||||
|
||||
|
||||
def _load(kind: str, name: str, config, *args):
|
||||
normalized = _validate_plugin_name(kind, name)
|
||||
|
||||
if kind == "adapters":
|
||||
enabled = enabled_plugins()
|
||||
|
||||
if normalized not in enabled:
|
||||
raise RuntimeError(
|
||||
f"Adapter plugin {normalized!r} is not enabled"
|
||||
)
|
||||
|
||||
module = _load_builtin(kind, normalized)
|
||||
|
||||
if module is not None:
|
||||
plugin_class = _plugin_class(
|
||||
module,
|
||||
f"dns_sync.plugins.{kind}.{normalized}",
|
||||
)
|
||||
return plugin_class(config, *args)
|
||||
|
||||
if kind == "adapters":
|
||||
module = _load_external_adapter_module(
|
||||
normalized
|
||||
)
|
||||
|
||||
if module is not None:
|
||||
plugin_class = _plugin_class(
|
||||
module,
|
||||
str(
|
||||
_external_plugin_root()
|
||||
/ normalized
|
||||
/ "__init__.py"
|
||||
),
|
||||
)
|
||||
return plugin_class(config, *args)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unknown {kind[:-1]} plugin: {normalized}"
|
||||
)
|
||||
|
||||
|
||||
def load_source(name: str, config):
|
||||
@@ -31,4 +190,9 @@ def load_provider(name: str, config):
|
||||
|
||||
|
||||
def load_adapter(name: str, config, endpoint):
|
||||
return _load("adapters", name, config, endpoint)
|
||||
return _load(
|
||||
"adapters",
|
||||
name,
|
||||
config,
|
||||
endpoint,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user