Completed mddclient

This commit is contained in:
Daniele Verducci (Slimpenguin) 2022-06-01 07:45:19 +02:00
parent c05a6717e4
commit 8317a99826
6 changed files with 205 additions and 43 deletions

View File

@ -17,5 +17,10 @@ Tested on Debian 11, but should run on almost any standard linux box.
Please see [healthcheck documentation](healthcheck/README.md) Please see [healthcheck documentation](healthcheck/README.md)
## MDDCLIENT
A DynDns2 client supporting multiple domains with individual API calls. Developed to allow updating multiple (sub)domains on Infomaniak dynamic DNS, that supports only one domain per request. Works with any provider supporting DynDns2 protocol.
Please see [mddclient documentation](mddclient/README.md)
# License # License
This whole repository is released under GNU General Public License version 3: see http://www.gnu.org/licenses/ This whole repository is released under GNU General Public License version 3: see http://www.gnu.org/licenses/

View File

@ -50,7 +50,7 @@ chmod +x /usr/local/bin/healthcheck.py
``` ```
Edit `/usr/local/etc/healthcheck.cfg` enabling the checks you need and configuring email settings. Edit `/usr/local/etc/healthcheck.cfg` enabling the checks you need and configuring email settings.
Run `/usr/local/bin/healthcheck.py /usr/local/etc/healthcheck.cfg` to check it is working. If needed, change the config to make a check fail and see if the notification mail is delivered. If you need to do some testing without spamming emails, run with the parameter `--dry-run`. Run `/usr/local/bin/healthcheck.py /usr/local/etc/healthcheck.cfg` to check it is working. If needed, change the config to make a check fail and see if the notification mail is delivered. If you need to do some testing without spamming emails, run with the parameter `--dry-run`.
Now copy the cron file: Now copy the cron file (it runs healthcheck every minute):
``` ```
cp healthcheck.cron.example /etc/cron.d/healthcheck cp healthcheck.cron.example /etc/cron.d/healthcheck
``` ```

View File

@ -1,10 +1,13 @@
# MDDCLIENT # MDDCLIENT
It's a client for dynamic DNS services like Infomaniak's. It's a client for `dyndns2` protocol: it is used to interact with dynamic DNS services like Dyn or Infomaniak's.
Like [ddclient](https://github.com/ddclient/ddclient), but supports sending multiple updates for different domains. Updates the IP assigned for a domain, allows the use of dynamic ip internet access (commin in residential contexts like DSL, Fiber, 5G...) for hosting services.
I developed this because [Infomaniak's DynDns APIS](https://www.infomaniak.com/en/support/faq/2376/dyndns-updating-a-dynamic-dns-via-the-api) doesn't support multiple domains updates on the same call. So, even if ddclient was supported for a single domain, it was needed to setup multiple instances to update multiple domains (or subdomains) on the same machine. Like [ddclient](https://github.com/ddclient/ddclient), but updates multiple domaing with multiple REST calls.
I developed this because [Infomaniak's DynDns APIS](https://www.infomaniak.com/en/support/faq/2376/dyndns-updating-a-dynamic-dns-via-the-api) doesn't support multiple domains updates on the same call. So, even if ddclient was supported for a single domain, in case of multiple (sub)domains it was needed to setup multiple instances to update them all on the same machine.
## Compatibility ## Compatibility
I wrote this to solve the multiple domain update problem on Infomainak, but should work for every other provider supporting the original ddclient. Works for any provider supporting `dyndns2` protocol. Was implemented following (this documentation)[https://help.dyn.com/remote-access-api/].
I wrote this to solve the multiple domain update problem on Infomainak.
## Use case ## Use case
Let's say we have our Nextcloud instance on `https://mysite.cloud`. As self hosters, we run this instance on our server at home, behind our fiber or DSL provider with a dynamic IP. We need to use ddclient to keep the dynamic DNS updated, so the requests to `https://mysite.cloud` are sent to our current IP, that may change in any moment. Let's say we have our Nextcloud instance on `https://mysite.cloud`. As self hosters, we run this instance on our server at home, behind our fiber or DSL provider with a dynamic IP. We need to use ddclient to keep the dynamic DNS updated, so the requests to `https://mysite.cloud` are sent to our current IP, that may change in any moment.
@ -22,15 +25,29 @@ Make the script executable:
``` ```
chmod +x /usr/local/bin/mddclient.py chmod +x /usr/local/bin/mddclient.py
``` ```
Edit `/usr/local/etc/mddclient.cfg` setting up the server url, username, password and the main domain to update. If you have other domains or subdomains running on the same fiber/dsl, configure them in the subsections as shown in the config example. Edit `/usr/local/etc/mddclient.cfg` setting up the server url, username, password and the domains to update. If you have multiple domains or subdomains running on the same fiber/dsl, configure them, one for subsection as shown in the config example.
Run `/usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg` to check it is working. Run `/usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg` to check it is working.
Now copy the cron file: Now copy the cron file (it runs mddclient every 5 minutes):
``` ```
cp mddclient.cron.example /etc/cron.d/mddclient cp mddclient.cron.example /etc/cron.d/mddclient
``` ```
For increased safety, edit the cron file placing your email address in MAILTO var to be notified in case of mddclient.py catastrophic failure. For increased safety, edit the cron file placing your email address in MAILTO var to be notified in case of mddclient.py catastrophic failure.
Setup is now complete: the cron runs the script every minute and updates the dns. Setup is now complete: the cron runs the script every five minutes and updates the dns.
## Check status
Some status informations are available with the -s flag:
```
root@myhost# /usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg -s
lastRun: 2022-05-31 10:40:17.006371
lastRunSuccess: True
lastUpdate: 2022-05-31 10:23:38.510386
lastIpAddr: 151.41.52.133
```
## Optimization ## Optimization
If multiple subdomains are served from the same public url (the same fiber/DSL account) it is possible to optimize the domain updates: when the main one results already up-to-date, the others are not updated. This feature is enabled by default, but can be disabled setting OPTIMIZE_API_CALLS to false in the config. As the update requests are subject to rate limit, the script checks the current IP against Dyn's checkip tool and updates only when necessary. To force an update, use the -f flag.
## Thanks
Thanks to `dyndns.org` for the (checkip)[https://help.dyn.com/remote-access-api/checkip-tool/] tool returning current public IP address.

View File

@ -1,4 +1,5 @@
# The DEFAULT section contains the main domain config. # The DEFAULT section contains the config common to all subsections.
# Redefining a value in a subsection overrides the DEFAULT's one.
[DEFAULT] [DEFAULT]
# Dynamic DNS Server # Dynamic DNS Server
@ -6,25 +7,22 @@ SERVER=infomaniak.com
LOGIN=myUserName LOGIN=myUserName
PASSWORD=mySuperSecretPassword PASSWORD=mySuperSecretPassword
[mysite]
# Main domain # Main domain
DOMAIN=mysite.cloud DOMAIN=mysite.cloud
# If multiple subdomains are served from the same public url
# (the same fiber/DSL account) it is possible to optimize the
# domain updates: when the main one results already up-to-date,
# the others are not updated.
OPTIMIZE_API_CALLS=True
[matrix] [matrix]
# The matrix.mysite.cloud subdomain # The matrix.mysite.cloud subdomain
# This will be updated only if the main domain needed update
# (or the OPTIMIZE_API_CALLS setting is disabled)
DISABLED=True
DOMAIN=matrix.mysite.cloud DOMAIN=matrix.mysite.cloud
[mastodon] [mastodon]
# The mastodon.mysite.cloud subdomain # The mastodon.mysite.cloud subdomain
# This will be updated only if the main domain needed update
# (or the OPTIMIZE_API_CALLS setting is disabled)
DISABLED=True
DOMAIN=mastodon.mysite.cloud DOMAIN=mastodon.mysite.cloud
[mysite2]
# Another domain, but this is provided by another dynamicdns service
# so we re-define server, login and password to override the DEFAULT ones
SERVER=anotherdyndns.com
LOGIN=anotherUserName
PASSWORD=anotherSuperSecretPassword
DOMAIN=mysite2.io

View File

@ -3,4 +3,4 @@
# if the mddclient.py script crashes. # if the mddclient.py script crashes.
MAILTO="your-email-address" MAILTO="your-email-address"
* * * * * root /usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg -q */5 * * * * root /usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg -q >> /var/log/mddclient.log 2>&1

View File

@ -15,6 +15,13 @@ Place a cron entry like this one:
* * * * * root python3 /usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg * * * * * root python3 /usr/local/bin/mddclient.py /usr/local/etc/mddclient.cfg
Exit codes:
0: success
1: unmanaged error (crash)
2: managed error (could not update due to bad config/server error)
For more informations on dyndns2 protocol: https://help.dyn.com/remote-access-api/
@author Daniele Verducci <daniele.verducci@ichibi.eu> @author Daniele Verducci <daniele.verducci@ichibi.eu>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
@ -34,11 +41,21 @@ import sys
import logging import logging
import configparser import configparser
import traceback import traceback
import requests
import re
import datetime
import json
NAME = 'mddclient' NAME = 'mddclient'
VERSION = '0.1' VERSION = '0.1'
DESCRIPTION = 'A DynamicDns client like ddclient, but supporting multiple (sub)domains' DESCRIPTION = 'A DynamicDns client like ddclient, but supporting multiple (sub)domains'
STATUS_FILE = '/tmp/mddclient.tmp'
CHECKIP_REQUEST_ADDR = 'http://checkip.dyndns.org'
CHECKIP_RESPONSE_PARSER = '<body>Current IP Address: (\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})</body>'
DDCLIENT2_REQUEST_ADDR = "https://{}/nic/update?system=dyndns&hostname={}&myip={}"
DDCLIENT2_RESPONSE_PARSER = '^(nochg|good) (\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})$'
USER_AGENT = 'Selfhost Utils Mddclient ' + VERSION
class Main: class Main:
@ -52,29 +69,155 @@ class Main:
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read(configPath) self.config.read(configPath)
def run(self): def run(self, force, printStatusAndExit):
''' Makes the update requests ''' ''' Makes the update requests '''
for section in self.config: # Load status
if section == 'DEFAULT': status = Status()
# Main domain
# TODO
continue
if printStatusAndExit:
status.print()
return True
# Check current ip
currentIp = self.getCurrentIp()
if (currentIp == None):
return False
self._log.info('Current ip is {}'.format(currentIp))
if currentIp == status.getIp():
self._log.info('Ip is up-to-date.')
if force:
self._log.info('User requested forced refresh.')
else:
self._log.info('Nothing to do.')
status.save(True, False)
return
success = True
updated = False
for section in self.config:
s = Settings(section, self.config) s = Settings(section, self.config)
if s.disabled: if s.name == 'DEFAULT':
self._log.info('Ignoring disabled domain "{}"'.format(section))
continue continue
self._log.info('Updating "{}"'.format(section)) self._log.info('Updating "{}"'.format(section))
try:
newIpAddr = self.update(s.ddserver, s.dduser, s.ddpass, s.domain, currentIp)
self._log.info('Success update {} to addr {}'.format(s.domain, newIpAddr))
updated = True
except Exception as e:
self._log.error('Error while updating {}: {}'.format(s.domain, e))
success = False
# TODO: subdomain update request # Save current ip
if success:
status.setIp(currentIp)
status.save(success, updated)
return success
def getCurrentIp(self):
'''Obtains current IP from checkip.dyndns.org'''
response = requests.get(CHECKIP_REQUEST_ADDR)
match = re.search(CHECKIP_RESPONSE_PARSER, response.text, re.MULTILINE)
if not match:
self._log.error('Unable to obtain new IP addr: Response format not valid: {}'.format(response.text))
return
groups = match.groups()
if len(groups) != 1:
self._log.error('Unable to obtain new IP addr: Unable to parse response: {}'.format(response.text))
return
return groups[0]
def update(self, server, user, password, domain, ip):
apiUrl = DDCLIENT2_REQUEST_ADDR.format(server, domain, ip)
try:
response = requests.get(apiUrl, auth=(user, password), headers={"User-Agent": USER_AGENT})
except requests.ConnectionError:
raise Exception('Server {} is unreachable'.format(server))
match = re.search(DDCLIENT2_RESPONSE_PARSER, response.text)
if not match:
raise Exception('Response format not valid: {}'.format(response.text))
groups = match.groups()
if len(groups) != 2:
raise Exception('Unable to parse response: {}'.format(response.text))
operationResult = groups[0]
ipAddr = groups[1]
# Check operation result and return appropriate errors
if operationResult == 'good':
# Success!
return ipAddr
elif operationResult == 'nochg':
# Should not happen: IP didn't need update
self._log.warning('Ip addres didn\'t need update: this should happen only at first run')
return ipAddr
elif operationResult == 'badauth':
raise Exception('The username and password pair do not match a real user')
elif operationResult == '!donator':
raise Exception('Option available only to credited users, but the user is not a credited user')
elif operationResult == 'notfqdn':
raise Exception('The hostname specified is not a fully-qualified domain name (not in the form hostname.dyndns.org or domain.com).')
elif operationResult == 'nohost':
raise Exception('The hostname specified does not exist in this user account')
elif operationResult == 'numhost':
raise Exception('Too many hosts specified in an update')
elif operationResult == 'abuse':
raise Exception('The hostname specified is blocked for update abuse')
elif operationResult == 'badagent':
raise Exception('The user agent was not sent or HTTP method is not permitted')
elif operationResult == 'dnserr':
raise Exception('DNS error encountered')
elif operationResult == '911':
raise Exception('There is a problem or scheduled maintenance on server side')
else:
raise Exception('Server returned an unknown result code: {}}'.format(operationResult))
class Status:
''' Represents the current status '''
def __init__(self):
try:
with open(STATUS_FILE, 'r') as openfile:
self.status = json.load(openfile)
except FileNotFoundError:
self.status = {
'lastRun': None,
'lastRunSuccess': None,
'lastUpdate': None,
'lastIpAddr': None,
}
def save(self, success, updated):
self.status['lastRun'] = str(datetime.datetime.now())
self.status['lastRunSuccess'] = success
if updated:
self.status['lastUpdate'] = str(datetime.datetime.now())
jo = json.dumps(self.status)
with open(STATUS_FILE, "w") as outfile:
outfile.write(jo)
def setIp(self, ip):
self.status['lastIpAddr'] = ip
def getIp(self):
return self.status['lastIpAddr']
def print(self):
for k in self.status:
print('{}: {}'.format(k, self.status[k]))
class Settings: class Settings:
''' Represents settings for a check ''' ''' Represents settings for a domain '''
EMAIL_LIST_SEP = ','
def __init__(self, name, config): def __init__(self, name, config):
self.config = config self.config = config
@ -82,10 +225,6 @@ class Settings:
## Section name ## Section name
self.name = name self.name = name
## Settings
self.disabled = self.getBoolean(name, 'DISABLED', False)
self.optimize = self.getBoolean(name, 'OPTIMIZE_API_CALLS', True)
## DynDNS server data ## DynDNS server data
self.ddserver = self.getStr(name, 'SERVER', None) self.ddserver = self.getStr(name, 'SERVER', None)
self.dduser = self.getStr(name, 'LOGIN', None) self.dduser = self.getStr(name, 'LOGIN', None)
@ -117,20 +256,23 @@ if __name__ == '__main__':
) )
parser.add_argument('configFile', help="configuration file path") parser.add_argument('configFile', help="configuration file path")
parser.add_argument('-q', '--quiet', action='store_true', help="suppress non-essential output") parser.add_argument('-q', '--quiet', action='store_true', help="suppress non-essential output")
parser.add_argument('-f', '--force', action='store_true', help="force update")
parser.add_argument('-s', '--status', action='store_true', help="print current status and exit doing nothing")
args = parser.parse_args() args = parser.parse_args()
if args.quiet: if args.quiet:
level = logging.WARNING level = logging.WARNING
else: else:
level = logging.INFO level = logging.INFO
logging.basicConfig(level=level) logging.basicConfig(level=level, format='%(asctime)s %(message)s')
try: try:
main = Main(args.configFile) main = Main(args.configFile)
main.run() if not main.run(args.force, args.status):
sys.exit(2)
except Exception as e: except Exception as e:
logging.critical(traceback.format_exc()) logging.critical(traceback.format_exc())
print('ERROR: {}'.format(e)) print('FATAL ERROR: {}'.format(e))
sys.exit(1) sys.exit(1)
sys.exit(0) sys.exit(0)