dns/ddclient - add backend selection and custom ddclient like implementation using the same input but written in python.

This adds a hybrid approach in dynamic dns support. The default will use ddclient, but when a different backend (opnsense) is selected, services change and daemon control [rc] starts the active one.
Since we're not aiming to support all currently supported vendors in the legacy plugin or ddclient, two example implementations are provided.

o dyndns2 -- simple dyndns2 api compliant accounts, accepting either the "dyndns" protocol or a predefined list of services (as already supported for ddclient)
o azure -- complex example using oauth2 to update a record in Azure DNS.
This commit is contained in:
Ad Schellevis
2023-01-05 17:57:33 +01:00
parent cc42b52878
commit ef91a6b4f9
25 changed files with 1119 additions and 63 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
PLUGIN_NAME= ddclient
PLUGIN_VERSION= 1.10
PLUGIN_VERSION= 1.11
PLUGIN_DEPENDS= ddclient-devel
PLUGIN_COMMENT= Dynamic DNS client
PLUGIN_MAINTAINER= ad@opnsense.org
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
#
# $FreeBSD$
#
# PROVIDE: ddclient_py
# REQUIRE: SERVERS
# KEYWORD: shutdown
#
. /etc/rc.subr
name=ddclient_opn
rcvar=ddclient_opn_enable
command=/usr/local/opnsense/scripts/ddclient/ddclient_opn.py
command_interpreter=/usr/local/bin/python3
pidfile="/var/run/${name}.pid"
load_rc_config $name
# Set defaults
: ${ddclient_opn_enable:=NO}
start_postcmd=ddclient_opn_poststart
stop_cmd=ddclient_opn_stop
ddclient_opn_poststart()
{
# give the daemon some time to initialize its configuration
for i in 1 2 3 4 5; do
sleep 1
if [ -s ${rc_pid} ]; then
break
fi
done
}
ddclient_opn_stop()
{
if [ -z "$rc_pid" ]; then
[ -n "$rc_fast" ] && return 0
_run_rc_notrunning
return 1
fi
echo -n "Stopping ${name}."
kill -15 ${rc_pid}
# wait max 2 seconds for gentle exit
for i in $(seq 1 20);
do
if [ -z "`/bin/ps -ex | /usr/bin/awk '{print $1;}' | /usr/bin/grep "^${rc_pid}"`" ]; then
break
fi
sleep 0.1
done
for ddclient_pid in `/bin/ps -ex | grep 'ddclient_opn.py' | /usr/bin/awk '{print $1;}' `
do
kill -9 $ddclient_pid >/dev/null 2>&1
done
echo "..done"
}
run_rc_command $1
@@ -31,6 +31,8 @@
namespace OPNsense\DynDNS\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Core\Backend;
class AccountsController extends ApiMutableModelControllerBase
{
@@ -25,6 +25,12 @@
<help>DynDNS Server</help>
<style>optional_setting service_custom</style>
</field>
<field>
<id>account.resourceId</id>
<label>resourceId</label>
<type>text</type>
<advanced>true</advanced>
</field>
<field>
<id>account.username</id>
<label>Username</label>
@@ -25,4 +25,10 @@
<type>text</type>
<help>Interval in seconds to check for address changes</help>
</field>
<field>
<id>ddclient.general.backend</id>
<label>Backend</label>
<type>dropdown</type>
<help>Select the backend to use.</help>
</field>
</form>
@@ -1,6 +1,6 @@
<model>
<mount>//OPNsense/DynDNS</mount>
<version>1.5.0</version>
<version>1.5.1</version>
<description>
Dynamic DNS client
</description>
@@ -24,6 +24,15 @@
<MinimumValue>1</MinimumValue>
<MaximumValue>86400</MaximumValue>
</daemon_delay>
<backend type="OptionField">
<Required>Y</Required>
<default>ddclient</default>
<ValidationMessage>A backend is required.</ValidationMessage>
<OptionValues>
<ddclient>ddclient</ddclient>
<opnsense>OPNsense</opnsense>
</OptionValues>
</backend>
</general>
<accounts>
<account type=".\AccountField">
@@ -31,7 +40,7 @@
<default>1</default>
<Required>Y</Required>
</enabled>
<service type="OptionField">
<service type=".\ServiceField">
<Required>Y</Required>
<ValidationMessage>A service type is required.</ValidationMessage>
<OptionValues>
@@ -99,6 +108,11 @@
<Required>Y</Required>
<mask>/^[^\n]*$/</mask>
</password>
<resourceId type="TextField">
<Required>N</Required>
<mask>/^[^\n]*$/</mask>
<ValidationMessage>resourceId contains invalid characters.</ValidationMessage>
</resourceId>
<hostnames type="HostnameField">
<Required>Y</Required>
<IpAllowed>N</IpAllowed>
@@ -116,7 +130,7 @@
<Required>N</Required>
<IpAllowed>N</IpAllowed>
</zone>
<checkip type="OptionField">
<checkip type=".\CheckipField">
<Required>Y</Required>
<default>web_dyndns</default>
<ValidationMessage>An IP service type is required.</ValidationMessage>
@@ -153,7 +167,6 @@
<interface type="InterfaceField">
<Required>N</Required>
<multiple>N</multiple>
<default>wan</default>
</interface>
<description type="TextField">
<Required>N</Required>
@@ -47,8 +47,11 @@ class AccountField extends ArrayField
$current_ip->setInternalIsVirtual();
$current_mtime = new TextField();
$current_mtime->setInternalIsVirtual();
if (!empty((string)$node->hostnames)) {
if (isset(self::$current_stats[$node->getAttribute('uuid')])) {
$stats = self::$current_stats[$node->getAttribute('uuid')];
$current_ip->setValue($stats['ip']);
$current_mtime->setValue(date('c', $stats['mtime']));
} elseif (!empty((string)$node->hostnames)) {
foreach (explode(",", (string)$node->hostnames) as $hostname) {
if (!empty(self::$current_stats[$hostname]) && !empty(self::$current_stats[$hostname]['ip'])) {
$stats = self::$current_stats[$hostname];
@@ -69,6 +72,8 @@ class AccountField extends ArrayField
$stats = json_decode((new Backend())->configdRun('ddclient statistics'), true);
if (!empty($stats) && !empty($stats['hosts'])) {
self::$current_stats = $stats['hosts'];
} elseif (!empty($stats)) {
self::$current_stats = $stats;
}
}
foreach ($this->internalChildnodes as $node) {
@@ -0,0 +1,58 @@
<?php
/*
* Copyright (C) 2023 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\DynDNS\FieldTypes;
use OPNsense\Base\FieldTypes\BaseListField;
use OPNsense\Core\Backend;
class CheckipField extends BaseListField
{
private static $internalCacheOptionList = [];
public function setOptionValues($data)
{
if (!empty(self::$internalCacheOptionList)) {
$this->internalOptionList = self::$internalCacheOptionList;
return;
}
if (is_array($data)) {
$opn_backend = (string)$this->getParentModel()->general->backend == 'opnsense';
foreach ($data as $key => $value) {
self::$internalCacheOptionList[$key] = gettext($value);
}
if ($opn_backend) {
// OPNsense backend, change interface label and add IPv6 option
self::$internalCacheOptionList['if'] = gettext("Interface [IPv4]");
self::$internalCacheOptionList['if6'] = gettext("Interface [IPv6]");
}
$this->internalOptionList = self::$internalCacheOptionList;
}
}
}
@@ -0,0 +1,71 @@
<?php
/*
* Copyright (C) 2023 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\DynDNS\FieldTypes;
use OPNsense\Base\FieldTypes\BaseListField;
use OPNsense\Core\Backend;
class ServiceField extends BaseListField
{
private static $internalCacheOptionList = [];
protected function actionPostLoadingEvent()
{
if (empty(self::$internalCacheOptionList)) {
// request supported services from backend
if ((string)$this->getParentModel()->general->backend == 'opnsense') {
$supported = json_decode((new Backend())->configdRun("ddclient opnbackend supported"), true);
if (!empty($supported)) {
foreach ($supported as $srv) {
self::$internalCacheOptionList[$srv] = $srv;
}
}
}
}
$this->internalOptionList = self::$internalCacheOptionList;
}
/**
* setter for option values
* @param $data
*/
public function setOptionValues($data)
{
if (!empty(self::$internalCacheOptionList) || (string)$this->getParentModel()->general->backend == 'opnsense') {
return;
}
if (is_array($data)) {
foreach ($data as $key => $value) {
self::$internalCacheOptionList[$key] = gettext($value);
}
$this->internalOptionList = self::$internalCacheOptionList;
}
}
}
@@ -52,6 +52,9 @@ POSSIBILITY OF SUCH DAMAGE.
dfObj.resolve();
});
return dfObj;
},
onAction: function(data, status) {
updateServiceControlUI('dyndns');
}
});
$("#account\\.service").change(function(){
@@ -26,61 +26,18 @@
POSSIBILITY OF SUCH DAMAGE.
"""
import argparse
import subprocess
import re
import ipaddress
from lib import checkip_service_list, checkip
service_list = {
'dyndns': '%s://checkip.dyndns.org/',
'freedns': '%s://freedns.afraid.org/dynamic/check.php',
'googledomains': '%s://domains.google.com/checkip',
'he': '%s://checkip.dns.he.net/',
'icanhazip': '%s://icanhazip.com/',
'ip4only.me': '%s://ip4only.me/api/',
'ip6only.me': '%s://ip6only.me/api/',
'ipify-ipv4': '%s://api.ipify.org/',
'ipify-ipv6': '%s://api6.ipify.org/',
'loopia': '%s://dns.loopia.se/checkip/checkip.php',
'myonlineportal': '%s://myonlineportal.net/checkip',
'noip-ipv4': '%s://ip1.dynupdate.no-ip.com/',
'noip-ipv6': '%s://ip1.dynupdate6.no-ip.com/',
'nsupdate.info-ipv4': '%s://ipv4.nsupdate.info/myip',
'nsupdate.info-ipv6': '%s://ipv6.nsupdate.info/myip',
'zoneedit': '%s://dynamic.zoneedit.com/checkip.html'
}
def extract_address(txt):
""" Extract first IPv4 or IPv6 address from provided string
:param txt: text blob
:return: str
"""
for regexp in [r'[^a-fA-F0-9\:]', r'[^F0-9\.]']:
for line in re.sub(regexp, ' ', txt).split():
if line.count('.') == 3 or line.count(':') >= 2:
try:
ipaddress.ip_address(line)
return line
except ValueError:
pass
if __name__ == '__main__':
# handle parameters
parser = argparse.ArgumentParser()
parser.add_argument('-s', '--service', help='service name', choices=service_list.keys(), required=True)
parser.add_argument('-s', '--service', help='service name', choices=checkip_service_list.keys(), required=True)
parser.add_argument('-i', '--interface', help='interface', type=str, default='')
parser.add_argument('-t', '--tls', help='enforce tls', choices=['0', '1'], default='0')
parser.add_argument('-t', '--tls', help='enforce tls', choices=['0', '1'], default='1')
parser.add_argument('--timeout', help='timeout', type=str, default='10')
inputargs = parser.parse_args()
# use curl to fetch data, so we can optionally use "--interface"
params = ['/usr/local/bin/curl', '-m', inputargs.timeout]
if inputargs.interface.strip() != "":
params.append("--interface")
params.append(inputargs.interface)
proto = 'http' if inputargs.tls == "0" else 'https'
params.append(service_list[inputargs.service] % proto)
result = subprocess.run(params, capture_output=True, text=True).stdout
print (extract_address(result))
interface = inputargs.interface if inputargs.interface.strip() != "" else None
print(checkip(inputargs.service, proto, inputargs.timeout, interface))
+50
View File
@@ -0,0 +1,50 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import argparse
import sys
import json
from lib import AccountFactory, Poller
sys.path.insert(0, "/usr/local/opnsense/site-python")
from daemonize import Daemonize
if __name__ == '__main__':
# handle parameters
parser = argparse.ArgumentParser()
parser.add_argument('-c', '--config', help='config file [json]', default='/usr/local/etc/ddclient.json')
parser.add_argument('-s', '--status', help='status output file [json]', default='/var/tmp/ddclient_opn.status')
parser.add_argument('-f', '--foreground', help='run (log) in foreground', default=False, action='store_true')
parser.add_argument('-l', '--list', help='list known services and exit', default=False, action='store_true')
parser.add_argument('-p', '--pid', help='pid file location', default='/var/run/ddclient_opn.pid')
inputargs = parser.parse_args()
if inputargs.list:
print(json.dumps(AccountFactory().known_services()))
else:
cmd = lambda : Poller(inputargs.config, inputargs.status)
daemon = Daemonize(app="ddclient", pid=inputargs.pid, action=cmd, foreground=inputargs.foreground)
daemon.start()
@@ -0,0 +1,61 @@
"""
Copyright (c) 2022-2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------------------------------
OPNsense ddclient alternative version.
The base for the application is a configuration file /usr/local/etc/ddclient.json containing a `general` and
`account` section. Structured dictionaries per account settings determine the services to subscribe to,
general settings contain verbosity settings and poll intervals for example.
Package structure:
* address.py
- contains all logic to figure out which address to use.
* poller.py
- The main poller class, which drives dyndns resolving.
* accounts
- Account/service type definitions, deriving from `BaseAccount`.
The BaseAccount type:
The lifetime of an account starts with the determination if an account object matches a requested configuration
account. Our AccountFactory() class is responsible for figuring our which classes are available and would
fit a provided definition using the `match(account)` method.
Every account has an `atime` property, which determines when was the last time we compared if the stored address
matches the requested one and if needed was set accordingly at the remote service.
Since every account likely has a dependency on an ip address, the base acccount implements an `execute()` method
which detects basic change (address, configuration changes) after which the implementation can do the actual
work and report if the address has really changed. This saves code and keeps the implementation simpler.
The Poller class:
Upon creation will start reading the configuration and merges the last known state (json), after each poll where something
changed (return status of `execute()`) the state is flushed to disk.
"""
from .address import checkip_service_list, checkip
from .poller import AccountFactory, Poller
@@ -0,0 +1,133 @@
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import hashlib
import uuid
import time
from ..address import checkip
class BaseAccount:
_priority = 255
def __init__(self, account: dict):
self._account = account
self._account['id'] = account.get('id', str(uuid.uuid4()))
self._account['description'] = account.get('description', '')
self._state = {}
self._last_accessed = 0
self._current_address = None # last resolved address
# calculate a hash so we can easily detect configuration changes
hash_list = []
for fieldname in sorted(account.keys()):
if fieldname not in ['id', 'description', 'checkip', 'checkip_timeout', 'force_ssl']:
hash_list.append(str(account[fieldname]))
self._account['md5'] = hashlib.md5("|".join(hash_list).encode()).hexdigest()
def update_state(self, address, status='good'):
""" set ip[v4 or v6] address and update in state dict when address is provided.
"""
if address is not None:
self._state['ip'] = address
self._state['status'] = status
self._state['mtime'] = time.time()
self._state['md5'] = self.md5
self._last_accessed = time.time()
@staticmethod
def known_services():
return []
@property
def id(self):
""" account unique id
"""
return self._account['id']
@property
def settings(self):
return self._account
@staticmethod
def match(account):
""" Does this account fit for the provided specification
"""
return False
@property
def description(self):
return ("%(id)s [%(service)s - %(description)s] " % self._account)
@property
def state(self):
return self._state
@state.setter
def state(self, value: dict):
self._state = value
@property
def mtime(self):
return self._state.get('mtime', 0)
@property
def atime(self):
return self._last_accessed
@property
def md5(self):
return self._account.get('md5')
@property
def is_verbose(self):
return self._account.get('verbose') is True
@property
def current_address(self):
return self._current_address
def execute(self):
""" execute account check/update sequence, return true if state changed
"""
self._current_address = checkip(
service = self.settings.get('checkip'),
proto = 'https' if self.settings.get('force_ssl', False) else 'http',
timeout = str(self.settings.get('checkip_timeout', '10')),
interface = self.settings['interface'] if self.settings.get('interface' ,'').strip() != '' else None
)
if self._current_address != '' and (
self._state.get('ip') is None or
self._current_address != self._state.get('ip') or
self.state.get('md5') != self.md5
):
# if current address doesn't equal the current state, propagate the fact
return True
else:
# unmodified, keep track of last access timestamp
self._last_accessed = time.time()
return False
@@ -0,0 +1,196 @@
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
----------------------------------------------------------------------------------------------------
Azure DNS provider, inspired by https://github.com/opnsense/plugins/pull/1547
List DNS zones using Azure cloud shell
#> az network dns zone list
Returns a structure like:
[
{
"etag": "00000000-0000-0000-0000-0000000000000",
"id": "/subscriptions/00000000-0000-0000-0000-00000000000/resourceGroups/example/providers/Microsoft.Network/dnszones/example.com", <---- ResourceId
"location": "global",
"maxNumberOfRecordSets": 10000,
"maxNumberOfRecordsPerRecordSet": null,
"name": "test.deciso.com",
"nameServers": [
"ns1-07.azure-dns.com.",
"ns2-07.azure-dns.net.",
"ns3-07.azure-dns.org.",
"ns4-07.azure-dns.info."
],
"numberOfRecordSets": 3,
"registrationVirtualNetworks": null,
"resolutionVirtualNetworks": null,
"resourceGroup": "xxxxxx",
"tags": {},
"type": "Microsoft.Network/dnszones",
"zoneType": "Public"
}
]
Next create a service principal (https://learn.microsoft.com/en-us/cli/azure/ad/sp?view=azure-cli-latest#az_ad_sp_create_for_rbac)
#> az ad sp create-for-rbac --name "AcmeDnsValidator" --role "DNS Zone Contributor" --scopes /subscriptions/00000000-0000-0000-0000-00000000000/resourceGroups/example/providers/Microsoft.Network/dnszones/example.com
Which returns a structure like:
{
"appId": "00000000-0000-0000-0000-000000000000", <--- username
"displayName": "AcmeDnsValidator",
"password": "000000000000000000000000000000000000", <--- Password
"tenant": "00000000-0000-0000-0000-000000000000"
}
"""
import syslog
import requests
from requests.auth import HTTPBasicAuth
from . import BaseAccount
class Azure(BaseAccount):
_services = {
'azure': 'management.azure.com/subscriptions'
}
def __init__(self, account: dict):
super().__init__(account)
@staticmethod
def known_services():
return Azure._services.keys()
def match(account):
if account.get('service') in Azure._services:
return True
else:
return False
def execute(self):
""" Azure DNS update, uses an oauth2 sequence to login, the following requests are being performed:
- https://management.azure.com/subscriptions/%s --> request target to authenticate against
- https://login.microsoftonline.com/%s/oauth2/token --> login using the tenantId received in the prev req
- https://management.azure.com/%s/XXX/%s --> set hostname address using the bearer token received
"""
if super().execute():
resourceId = self.settings.get('resourceId', '')
if resourceId.find('subscriptions/') == -1:
syslog.syslog(syslog.LOG_ERR, 'No subscription id found for account %s' % self.description)
return
subscriptionId = resourceId.split('subscriptions/')[-1].split('/')[0]
req = requests.get('https://management.azure.com/subscriptions/%s?api-version=2016-09-01' % subscriptionId)
auth_target = req.headers.get('WWW-Authenticate', '').split(maxsplit=1)
if len(auth_target) < 2 or auth_target[0] != 'Bearer':
syslog.syslog(syslog.LOG_ERR, 'No Bearer token found for account %s' % self.description)
return
elif auth_target[1].find('https://login.windows.net/') == -1:
syslog.syslog(
syslog.LOG_ERR,
'Unable to find Tenant ID for account %s (response: %s)' % (self.description, auth_target[1])
)
return
tenantId = auth_target[1].split('https://login.windows.net/')[1].split('"')[0]
req_opts = {
'url': 'https://login.microsoftonline.com/%s/oauth2/token' % tenantId,
'data': {
'resource': 'https://management.core.windows.net/',
'grant_type': 'client_credentials',
'client_id': self.settings.get('username'),
'client_secret': self.settings.get('password')
},
'headers': {
'User-Agent': 'OPNsense-dyndns'
}
}
req = requests.post(**req_opts)
try:
token_payload = req.json()
except requests.exceptions.JSONDecodeError:
token_payload = {}
if req.status_code != 200 or 'access_token' not in token_payload:
syslog.syslog(
syslog.LOG_ERR,
'Unable to authenticate account %s (http_code: %d - %s)' % (
self.description,
req.status_code,
req.text.replace('\n', '')
)
)
return
for hostname in self.settings.get('hostnames', '').split(','):
req_opts = {
'headers': {
'Accept': 'application/json',
'Authorization': 'Bearer %s' % token_payload['access_token'],
'Content-Type': 'application/json'
}
}
if self.current_address.find(':') > 1:
# IPv6
req_opts['url'] = 'https://management.azure.com/%s/AAAA/%s?api-version=2018-05-01' % (
resourceId, hostname
)
req_opts['json'] = {
'properties': {
'AAAARecords': [
{
'ipv6Address': self.current_address
}
]
}
}
else:
#IPv4
req_opts['url'] = 'https://management.azure.com/%s/A/%s?api-version=2018-05-01' % (
resourceId, hostname
)
req_opts['json'] = {
'properties': {
'ARecords': [
{
'ipv4Address': self.current_address
}
]
}
}
req = requests.patch(**req_opts)
if req.status_code == 200:
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s set new ip %s [%s]" % (self.description, self.current_address, req.text.strip())
)
self.update_state(address=self.current_address)
return True
return False
@@ -0,0 +1,104 @@
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import syslog
import requests
from requests.auth import HTTPBasicAuth
from . import BaseAccount
class DynDNS2(BaseAccount):
_priority = 65535
_services = {
'dyndns2': 'members.dyndns.org',
'dns-o-matic': 'updates.dnsomatic.com',
'dynu': 'api.dynu.com',
'he-net': 'dyn.dns.he.net',
'he-net-tunnel': 'ipv4.tunnelbroker.net',
'inwx': 'dyndns.inwx.com',
'loopia': 'dyndns.loopia.se',
'nsupdatev4': 'ipv4.nsupdate.info',
'nsupdatev6': 'ipv6.nsupdate.info',
'ovh': 'www.ovh.com',
'spdyn': 'update.spdyn.de',
'strato': 'dyndns.strato.com',
'noip': 'dynupdate.no-ip.com'
}
def __init__(self, account: dict):
super().__init__(account)
@staticmethod
def known_services():
return DynDNS2._services.keys()
def match(account):
if account.get('service') in DynDNS2._services or (
account.get('server') is not None and account.get('protocol') in ['dyndns2', 'dyndns1']
):
return True
else:
return False
def execute(self):
if super().execute():
proto = 'https' if self.settings.get('force_ssl', False) else 'http'
if self.settings.get('service') in self._services:
url = "%s://%s/nic/update" % (proto, self._services[self.settings.get('service')])
else:
url = "%s://%s/nic/update" % (proto, self.settings.get('server'))
req_opts = {
'url': url,
'params': {
'hostname': self.settings.get('hostnames'),
'myip': self.current_address,
'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG'
},
'auth': HTTPBasicAuth(self.settings.get('username'), self.settings.get('password')),
'headers': {
'User-Agent': 'OPNsense-dyndns'
}
}
req = requests.get(**req_opts)
if req.status_code == 200:
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s set new ip %s [%s]" % (self.description, self.current_address, req.text.strip())
)
self.update_state(address=self.current_address, status=req.text.split()[0])
return True
else:
syslog.syslog(
syslog.LOG_ERR,
"Account %s failed to set new ip %s [%d - %s]" % (
self.description, self.current_address, req.status_code, req.text.replace('\n', '')
)
)
return False
@@ -0,0 +1,98 @@
"""
Copyright (c) 2022-2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import subprocess
import re
import ipaddress
checkip_service_list = {
'dyndns': '%s://checkip.dyndns.org/',
'freedns': '%s://freedns.afraid.org/dynamic/check.php',
'googledomains': '%s://domains.google.com/checkip',
'he': '%s://checkip.dns.he.net/',
'icanhazip': '%s://icanhazip.com/',
'ip4only.me': '%s://ip4only.me/api/',
'ip6only.me': '%s://ip6only.me/api/',
'ipify-ipv4': '%s://api.ipify.org/',
'ipify-ipv6': '%s://api6.ipify.org/',
'loopia': '%s://dns.loopia.se/checkip/checkip.php',
'myonlineportal': '%s://myonlineportal.net/checkip',
'noip-ipv4': '%s://ip1.dynupdate.no-ip.com/',
'noip-ipv6': '%s://ip1.dynupdate6.no-ip.com/',
'nsupdate.info-ipv4': '%s://ipv4.nsupdate.info/myip',
'nsupdate.info-ipv6': '%s://ipv6.nsupdate.info/myip',
'zoneedit': '%s://dynamic.zoneedit.com/checkip.html'
}
def extract_address(txt):
""" Extract first IPv4 or IPv6 address from provided string
:param txt: text blob
:return: str
"""
for regexp in [r'[^a-fA-F0-9\:]', r'[^F0-9\.]']:
for line in re.sub(regexp, ' ', txt).split():
if line.count('.') == 3 or line.count(':') >= 2:
try:
ipaddress.ip_address(line)
return line
except ValueError:
pass
return ""
def checkip(service, proto='https', timeout='10', interface=None):
""" find ip address using external services defined in checkip_service_list
:param proto: protocol
:param timeout: timeout in seconds
:param interface: bind to interface
:return: str
"""
if service.startswith('web_'):
# configuration name, strip web_ part
service = service[4:]
if service in checkip_service_list:
params = ['/usr/local/bin/curl', '-m', timeout]
if interface is not None:
params.append("--interface")
params.append(interface)
params.append(checkip_service_list[service] % proto)
return extract_address(subprocess.run(params, capture_output=True, text=True).stdout)
elif service in ['if', 'if6'] and interface is not None:
# return first non private IPv[4|6] interface address
ifcfg = subprocess.run(['/sbin/ifconfig', interface], capture_output=True, text=True).stdout
for line in ifcfg.split('\n'):
if line.startswith('\tinet'):
parts = line.split()
if (parts[0] == 'inet' and service == 'if') or (parts[0] == 'inet6' and service == 'if6'):
try:
address = ipaddress.ip_address(parts[1])
if address.is_global:
return address
except ValueError:
continue
else:
return ""
@@ -0,0 +1,165 @@
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import fcntl
import syslog
import glob
import importlib
import sys
import os
import time
import ujson
import ipaddress
from .account import BaseAccount
class AccountFactory:
def __init__(self):
self._account_classes = list()
self._register()
def _register(self):
""" Register all account (type) classes.
These usually describe a protocol (like dyndns2)
"""
pkg_name = "%s.account" % __name__[:-len(os.path.splitext(os.path.basename(__file__))[0])-1]
all_account_classes = list()
for filename in glob.glob("%s/account/*.py" % os.path.dirname(__file__)):
importlib.import_module(".%s" % os.path.splitext(os.path.basename(filename))[0], pkg_name)
for module_name in dir(sys.modules[pkg_name]):
for attribute_name in dir(getattr(sys.modules[pkg_name], module_name)):
cls = getattr(getattr(sys.modules[pkg_name], module_name), attribute_name)
if isinstance(cls, type) and issubclass(cls, BaseAccount) and cls != BaseAccount:
all_account_classes.append(cls)
self._account_classes = sorted(all_account_classes, key=lambda k: k._priority)
def get(self, account: dict):
for handler in self._account_classes:
if handler.match(account):
return handler(account)
def known_services(self):
all_services = []
for handler in self._account_classes:
all_services += handler.known_services()
return all_services
class Poller:
def __init__(self, config_filename, status_filename):
self._config_filename = config_filename
self._status_filename = status_filename
self._accounts = {}
self._general_settings = {}
syslog.openlog('ddclient', logoption=syslog.LOG_DAEMON, facility=syslog.LOG_LOCAL4)
self.startup()
self.run()
@property
def is_verbose(self):
return self._general_settings.get('verbose') is True
@property
def is_enabled(self):
return self._general_settings.get('enabled') is True
@property
def poll_interval(self):
return self._general_settings.get('daemon_delay', 60)
def startup(self):
account_factory = AccountFactory()
with open(self._config_filename) as f:
cnf = ujson.load(f)
if type(cnf.get('general')) is dict:
self._general_settings = cnf.get('general')
if type(cnf.get('accounts')) is list:
for account in cnf.get('accounts'):
account['verbose'] = self.is_verbose
acc = account_factory.get(account)
if acc:
self._accounts[acc.id] = acc
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s uses %s for service" % (acc.description, acc.__class__.__name__)
)
elif self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Unable to find a suitable target for account %(id)s [%(description)s]" % account
)
if len(self._accounts) > 0 and os.path.isfile(self._status_filename):
with open(self._status_filename) as f:
try:
state = ujson.load(f)
if type(state) is dict:
for sid in state:
if sid in self._accounts:
self._accounts[sid].state = state[sid]
except ValueError:
syslog.syslog(syslog.LOG_ERR, "Unable to read file %s" % self._status_filename)
def flush_status(self):
fhandle = open(self._status_filename, 'a+')
try:
fcntl.flock(fhandle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
syslog.syslog(syslog.LOG_ERR, "Unable to flush status, %s already locked" % self._status_filename)
return
fhandle.seek(0)
fhandle.truncate()
data = {}
for acc_id in self._accounts:
data[acc_id] = self._accounts[acc_id].state
fhandle.write(ujson.dumps(data))
fhandle.close()
def run(self):
while True:
needs_flush = False
for acc in self._accounts.values():
if time.time() - acc.atime > self.poll_interval:
if self.is_verbose:
syslog.syslog(syslog.LOG_NOTICE, "Account %s execute" % acc.description)
try:
if acc.execute():
if self.is_verbose:
syslog.syslog(syslog.LOG_NOTICE, "Account %s changed" % acc.description)
needs_flush = True
except Exception as e:
# fatal exception, update atime so we're not going to retry too soon
acc.update_state(None)
syslog.syslog(syslog.LOG_ERR, "Account %s raised fatal error (%s)" % (acc.description, e))
if needs_flush:
if self.is_verbose:
syslog.syslog(syslog.LOG_NOTICE, "Flush dyndns status to disk")
self.flush_status()
# XXX: needs better poll interval calculation
time.sleep(5)
@@ -29,10 +29,17 @@
import os
import json
filename = "/var/tmp/ddclient.cache"
filename = '/var/tmp/ddclient.cache'
filename_new = '/var/tmp/ddclient_opn.status'
result = {"hosts": {}}
if os.path.isfile(filename):
# both ddclient and "opnsense" ddclient exist, only use old when currently in use
if os.path.isfile(filename) and os.path.isfile(filename_new):
if os.path.getmtime(filename) < os.path.getmtime(filename_new):
filename = None
if filename is not None and os.path.isfile(filename):
with open(filename, "r") as fhandle:
for idx, row in enumerate(fhandle):
if idx == 0:
@@ -49,5 +56,10 @@ if os.path.isfile(filename):
record[parts[0]] = parts[1]
if 'host' in record:
result['hosts'][record['host']] = record
elif os.path.isfile(filename_new):
# output will look completely different when our implementation is used, the model knows how to parse this
# (see AccountField.php)
with open(filename_new) as f:
result = json.load(f)
print(json.dumps(result))
@@ -1,17 +1,21 @@
[start]
command:
chmod 600 /usr/local/etc/ddclient.conf;
/usr/local/etc/rc.d/ddclient start
/usr/local/etc/rc.d/ddclient start ;
/usr/local/etc/rc.d/ddclient_opn start
type:script
message:starting ddclient
[stop]
command:pkill -F /var/run/ddclient.pid 2> /dev/null; exit 0
command:pkill -F /var/run/ddclient.pid 2> /dev/null; /usr/local/etc/rc.d/ddclient_opn onestop 2> /dev/null; exit 0
type:script
message:stopping ddclient
[status]
command:pgrep -qF /var/run/ddclient.pid && echo "ddclient is running" || echo "ddclient is not running"
command:
pgrep -qF /var/run/ddclient.pid 2> /dev/null && echo "ddclient is running" ||
pgrep -qF /var/run/ddclient_opn.pid 2> /dev/null && echo "ddclient is running" ||
echo "ddclient is not running"
type:script_output
message:get ddclient status
@@ -19,7 +23,8 @@ message:get ddclient status
command:
chmod 600 /usr/local/etc/ddclient.conf;
pkill -F /var/run/ddclient.pid 2> /dev/null;
/usr/local/etc/rc.d/ddclient restart
/usr/local/etc/rc.d/ddclient restart ;
/usr/local/etc/rc.d/ddclient_opn restart
type:script
message:restarting ddclient
description:Restart ddclient service
@@ -27,6 +32,8 @@ description:Restart ddclient service
[force]
command:
chmod 600 /usr/local/etc/ddclient.conf;
rm /var/tmp/ddclient_opn.status 2>/dev/null;
/usr/local/etc/rc.d/ddclient_opn restart 2>/dev/null;
/usr/local/sbin/ddclient -force
type:script
message:forcing ddclient update
@@ -36,3 +43,8 @@ description:Force ddclient update
command:/usr/local/opnsense/scripts/ddclient/stats
type:script_output
message:get ddclient statistics
[opnbackend.supported]
command:/usr/local/opnsense/scripts/ddclient/ddclient_opn.py -l
type:script_output
message:get ddclient statistics

Some files were not shown because too many files have changed in this diff Show More