15 KiB
dns-sync modular
A plugin-based DNS reconciliation service.
Architecture
dns-sync separates generic reconciliation from application-specific endpoint behavior.
native endpoint
|
v
<App>Client
|
| native objects
v
Plugin
|
| DiscoveredObject
v
dns-sync core
|
| normalization
| observations
| desired state
| persistence
| reconciliation
| planning
v
PlanAction
|
v
Plugin
|
| native translation
v
<App>Client
|
v
native endpoint
The core owns orchestration truth.
Plugins own application semantics.
Clients own endpoint-native communication.
Plugin contract
The reconciliation runtime is adapter-based.
Every adapter package must export:
configured_endpoints(config)Plugin
The registry calls:
configured_endpoints(config)
to obtain the endpoints owned by the plugin.
For each configured endpoint, the registry constructs:
Plugin(config, endpoint)
Every endpoint returned by configured_endpoints(config) must identify the
plugin that returned it. The registry rejects cross-plugin endpoint ownership.
Plugins declare object-specific capabilities using the generic
Capability model from dns_sync.adapters.
Capabilities currently describe:
- discovery
- apply
- verification
for a particular generic object type.
Built-in adapters live under:
dns_sync/plugins/adapters/
External adapters are loaded from:
DNS_SYNC_PLUGIN_PATH
and must be explicitly enabled through:
ENABLED_PLUGINS
Canonical plugin structure
Application integrations should use this package structure:
<plugin_name>/
├── __init__.py
└── client.py
The repository contains a copyable example under:
examples/plugin_template/
The example is documentation only and is not loaded from that location.
Adapter boundary: __init__.py
__init__.py is the dns-sync-facing plugin boundary.
It owns:
- endpoint configuration through
configured_endpoints(config) - generic capability declarations
- validation of the supplied endpoint
- native-to-generic discovery translation
- generic-to-native apply translation
- verification
- optional retirement behavior
- application-specific semantic validation
The adapter communicates with the core using generic types such as:
EndpointDiscoveredObjectNormalizedObjectObjectReferencePlanActionApplyResultRetireRequest
Native boundary: client.py
client.py is private implementation machinery for that application.
It owns:
- authentication
- HTTP sessions, sockets, commands, or other transport
- application-native request and response handling
- application-native models
- endpoint-local mutation primitives
- endpoint-specific verification mechanics
A client represents one explicit Endpoint.
The client should not independently rediscover which endpoint it is supposed to contact from another global endpoint setting.
There is deliberately no generic dns-sync Client base class. Different
applications may communicate through completely different mechanisms. The
shared contract is the adapter plugin boundary, not the transport layer.
Application-specific models belong inside the plugin package rather than the dns-sync core unless they represent a genuinely application-independent core concept.
Discovery
Plugin.discover(object_type) reads native endpoint state through the private
client and returns DiscoveredObject instances.
A discovered object carries:
object_type
local_key
payload
references
local_key is endpoint-local identity. It is not assumed to be portable to
another endpoint.
The plugin owns translation from the native application's representation into this generic discovery representation.
Unsupported object types should fail explicitly rather than silently returning an empty or misleading result.
Normalized state
The core converts discoveries into NormalizedObject instances and owns the
reconciliation state built from them.
A normalized object carries:
object_type
logical_key
payload
local_key
references
logical_key represents portable identity.
local_key represents endpoint-local identity.
Plugins do not persist their own reconciliation truth. The core owns:
- observations
- observation status
- desired state
- applied and verified state
- reconciliation
- planning
- dependency resolution
Object references
Dependencies between objects use ObjectReference.
A reference identifies another object by exactly one of:
local_key
or:
logical_key
but never both.
Endpoint discovery may expose local references because those are the identities native applications actually use.
Central reconciliation can convert those into portable logical references.
During execution, dns-sync resolves portable references back to the correct endpoint-local identity before invoking the target adapter.
Plugins should never treat an endpoint-local ID from one endpoint as portable desired state for another endpoint.
Apply
Plugin.apply(action) receives a generic PlanAction.
Before mutation, the adapter should validate that the action belongs to its endpoint.
Shared validation helpers are available in:
dns_sync.adapter_support
including:
require_endpoint_plugin(...)
require_action_endpoint(...)
require_desired(...)
NOOP and SKIP actions must not mutate the endpoint.
For a mutating operation, the adapter should:
- validate the requested operation;
- validate the desired normalized object;
- translate desired state into an application-native object;
- invoke the private client;
- re-fetch or rediscover the actual resulting native object;
- obtain its real endpoint-local identity;
- return an
ApplyResult.
Do not guess or predict endpoint-local IDs when the endpoint assigns them.
Unsupported operations should fail explicitly.
Apply results
A successful mutation returns an ApplyResult whose normalized object contains
the actual endpoint-local key.
Conceptually:
ApplyResult(
object=normalized_applied_object,
detail=...,
)
The returned object's local_key is required.
An apply result may also include deferred RetireRequest objects when a
replacement workflow requires the old object to remain temporarily while
dependencies are rebound.
Verification
Plugin.verify(object) checks that the actual endpoint state matches the
normalized object dns-sync expects to exist.
Verification is intentionally separate from mutation.
A successful API response is not sufficient evidence that endpoint state converged.
The executor requires supported mutations to pass verification before the result is treated as successfully applied.
Retirement
retire() is optional.
A plugin should implement retirement only when it can safely remove or retire endpoint-local objects.
Retirement is used by deletion and replacement workflows.
The executor performs preflight checks before mutation. If execution requires retirement and the adapter does not provide it, the operation fails before the unsafe mutation begins.
Retirement should use the endpoint-local identity supplied by dns-sync and fail closed when the object cannot be identified safely.
Fail-closed behavior
Plugins should reject ambiguous or unsupported behavior.
In particular:
- reject endpoints owned by another plugin;
- reject actions intended for another endpoint;
- reject unsupported object types;
- reject unsupported plan operations;
- reject malformed native state;
- reject ambiguous endpoint-local identities;
- never guess missing IDs;
- never report successful application until the resulting object can be identified;
- never treat mutation success alone as verification.
The generic executor adds additional preflight and dependency safety checks.
Configuration ownership
Configuration should live at the lowest layer that actually understands it.
Use this rule:
Does generic reconciliation need to understand this value?
yes -> core configuration may be appropriate
no -> plugin/client configuration
Examples of generic reconciliation policy include:
DNS_TARGET_IP
DOMAIN_MATCHING
DOMAIN
Examples of plugin/client configuration include:
NPM_ENDPOINTS
PROXY_API_PORT
PROXY_API_SCHEME
NPM_IDENTITY
NPM_SECRET
VERIZON_ENDPOINTS
ROUTER_PASSWORD
ROUTER_TIMEOUT
configured_endpoints(config) owns endpoint discovery for the plugin.
The client receives the resulting explicit Endpoint; it should not implement
a second independent endpoint-selection mechanism.
Secret handling
Secret material should remain below the normalized and persistence boundary unless the core has an explicit safe representation for it.
Plugins must not place credentials, authentication tokens, private keys, or other secret material into generic persisted payloads.
Runtime credentials should normally be provided through environment variables, container secrets, or another external secret-management mechanism.
Plugins
The generic reconciliation engine uses adapter plugins.
Built-in adapters:
npm- Nginx Proxy Manager proxy hosts and certificates
External adapters are loaded from DNS_SYNC_PLUGIN_PATH and must also be
explicitly enabled through ENABLED_PLUGINS.
Example:
ENABLED_PLUGINS=npm,verizon
DNS_SYNC_PLUGIN_PATH=/plugins
The Verizon adapter lives under external_plugins/verizon and is deliberately
not copied into the dns-sync container image. The external plugin directory is
mounted read-only at runtime.
Plugin loading
Only explicitly enabled plugins are loadable.
The registry first checks for a built-in adapter under:
dns_sync/plugins/adapters/<plugin_name>/
If no built-in adapter exists, it looks for an external package at:
$DNS_SYNC_PLUGIN_PATH/<plugin_name>/__init__.py
External plugins are loaded as packages, so they may use relative imports for private modules such as:
from .client import ExampleClient
Imports from dns-sync itself should remain absolute, for example:
from dns_sync.adapters import Endpoint
from dns_sync.adapter_support import require_endpoint_plugin
The external plugin directory does not need to be added globally to
PYTHONPATH.
Adding a new plugin
Start from:
examples/plugin_template/
For an external plugin:
- copy the template into the configured external plugin root;
- rename the directory to the plugin name;
- rename
ExampleClientandExampleRecord; - implement
configured_endpoints(config); - implement native authentication and transport in
client.py; - declare only the generic capabilities actually supported;
- implement native-to-
DiscoveredObjecttranslation; - implement
NormalizedObject-to-native translation; - implement
apply()andverify(); - implement
retire()only when safe retirement is supported; - add focused client and adapter tests;
- enable the plugin through
ENABLED_PLUGINS; - run read-only reconciliation against the real endpoint before permitting mutations.
Do not begin a new integration by adding application-specific models or transport behavior to the core.
Reference implementations
Use both existing plugins when designing a new integration.
NPM
The NPM plugin demonstrates:
- a built-in adapter;
- multiple configured endpoints;
- proxy-host and certificate object types;
- endpoint-local object IDs;
- cross-object certificate references;
- portable logical references;
- replacement workflows;
- deferred retirement;
- certificate material handling.
Its package lives at:
dns_sync/plugins/adapters/npm/
Verizon
The Verizon plugin demonstrates:
- an external adapter;
- plugin-owned endpoint configuration;
- a private native record model;
- a private endpoint client;
- DNS record discovery;
- create/update operations;
- verification;
- a read-only mounted external plugin package.
Its package lives at:
external_plugins/verizon/
Together they demonstrate the preferred boundary:
dns-sync core
|
+-- generic state and orchestration
|
+-- Plugin
|
+-- native / normalized translation
|
+-- <App>Client
|
+-- application-native implementation
Plugin development validation
Before committing a plugin change, run at least:
git diff --check
python3 -m compileall -q dns_sync external_plugins tests examples
python3 -m unittest discover -s tests -v
Changes involving plugin loading, native clients, endpoint configuration, or mutation behavior should also be validated with a real read-only container reconciliation against the intended endpoints.
A clean read-only plan does not by itself authorize mutation. Review unexpected planned changes before enabling apply mode.
Container runtime
The container periodically executes the generic reconciliation engine:
python -m dns_sync reconcile
The default container mode is read-only.
SYNC_INTERVAL=1
DNS_SYNC_APPLY=false
DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3
DNS_SYNC_PLUGIN_PATH=/plugins
ENABLED_PLUGINS=npm,verizon
SYNC_INTERVAL is expressed in minutes. The interval begins after each
reconciliation completes, so runs do not overlap.
To permit planned changes to be applied and verified:
DNS_SYNC_APPLY=true
An optional mutation budget can be specified:
DNS_SYNC_MAX_CHANGES=10
When DNS_SYNC_APPLY=false, reconciliation still performs discovery,
normalization, desired-state reconciliation, planning, and state persistence,
but no endpoint mutations are executed.
The SQLite database should be stored on persistent storage.
External plugins should be mounted read-only at /plugins.
Main environment
NPM_ENDPOINTS=192.168.1.3,192.168.1.4,192.168.1.5
PROXY_API_PORT=81
PROXY_API_SCHEME=http
DNS_TARGET_IP=192.168.1.101
VERIZON_ENDPOINTS=192.168.1.1
DOMAIN_MATCHING=false
DOMAIN=example.com
NPM_IDENTITY=...
NPM_SECRET=...
ROUTER_PASSWORD=...
Domain filtering
Filtering is performed centrally after adapter discovery and before reconciliation, so endpoint-specific adapters get identical behavior.
DOMAIN_MATCHING=true
DOMAIN=example.com
This includes the apex and every subdomain:
example.com
app.example.com
deep.app.example.com
*.example.com
It excludes unrelated names such as:
example.net
notexample.com
example.com.evil.net
When DOMAIN_MATCHING=false, DOMAIN is ignored and all discovered records are
eligible for reconciliation.
When DOMAIN_MATCHING=true, DOMAIN is required.