mirror of
https://github.com/netbirdio/plugins.git
synced 2026-05-22 18:44:07 -07:00
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:
@@ -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
|
||||
|
||||
Executable
+63
@@ -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
|
||||
+2
@@ -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>
|
||||
|
||||
+7
-2
@@ -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))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user