1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317
|
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
FreedomBox app to configure ez-ipupdate client.
"""
import json
import logging
import subprocess
import time
import urllib
from django.utils.translation import gettext_lazy as _
from plinth import app as app_module
from plinth import cfg, glib, kvstore, menu
from plinth.modules.backups.components import BackupRestore
from plinth.modules.names.components import DomainType
from plinth.modules.privacy import lookup_public_address
from plinth.modules.users.components import UsersAndGroups
from plinth.signals import domain_added, domain_removed
from plinth.utils import format_lazy
from . import gnudip, manifest, privileged
logger = logging.getLogger(__name__)
_description = [
format_lazy(
_('If your Internet provider changes your IP address periodically '
'(i.e. every 24h), it may be hard for others to find you on the '
'Internet. This will prevent others from finding services which are '
'provided by this {box_name}.'), box_name=_(cfg.box_name)),
_('The solution is to assign a DNS name to your IP address and '
'update the DNS name every time your IP is changed by your '
'Internet provider. Dynamic DNS allows you to push your current '
'public IP address to a '
'<a href=\'http://gnudip2.sourceforge.net/\' target=\'_blank\'> '
'GnuDIP</a> server. Afterwards, the server will assign your DNS name '
'to the new IP, and if someone from the Internet asks for your DNS '
'name, they will get a response with your current IP address.'),
_('If you are looking for a free dynamic DNS account, you may find a free '
'GnuDIP service at <a href=\'https://ddns.freedombox.org\' '
'target=\'_blank\'>ddns.freedombox.org</a>. With this service, you also '
'get unlimited subdomains (with wildcards option enabled in account '
'settings). To use a subdomain, add it as a static domain in the Names '
'app.'),
_('Alternatively, you may find a free update URL based service at '
'<a href=\'http://freedns.afraid.org/\' '
'target=\'_blank\'>freedns.afraid.org</a>.'),
_('This service uses an external service to lookup public IP address. '
'This can be configured in the privacy app.'),
]
class DynamicDNSApp(app_module.App):
"""FreedomBox app for Dynamic DNS."""
app_id = 'dynamicdns'
_version = 2
def __init__(self) -> None:
"""Create components for the app."""
super().__init__()
info = app_module.Info(app_id=self.app_id, version=self._version,
is_essential=True, depends=['names'],
name=_('Dynamic DNS Client'), icon='fa-refresh',
description=_description,
manual_page='DynamicDNS', tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-dynamicdns', info.name, info.icon,
info.tags, 'dynamicdns:index',
parent_url_name='system:visibility', order=20)
self.add(menu_item)
enable_state = app_module.EnableState('enable-state-dynamicdns')
self.add(enable_state)
domain_type = DomainType('domain-type-dynamic', _('Dynamic Domain'),
edit_url='dynamicdns:domain-edit',
delete_url='dynamicdns:domain-delete',
add_url='dynamicdns:domain-add',
can_have_certificate=True, priority=70)
self.add(domain_type)
users_and_groups = UsersAndGroups('users-and-groups-dynamicdns',
reserved_usernames=['ez-ipupd'])
self.add(users_and_groups)
backup_restore = BackupRestore('backup-restore-dynamicdns',
**manifest.backup)
self.add(backup_restore)
def post_init(self):
"""Perform post initialization operations."""
config = get_config()
if self.is_enabled():
for domain_name in config['domains']:
notify_domain_added(domain_name)
# Check every 5 minutes to perform dynamic DNS updates.
glib.schedule(300, update_dns)
def enable(self):
"""Send domain signals after enabling the app."""
super().enable()
config = get_config()
for domain_name in config['domains']:
notify_domain_added(domain_name)
def disable(self):
"""Send domain signals before disabling the app."""
config = get_config()
for domain_name in config['domains']:
notify_domain_removed(domain_name)
super().disable()
def setup(self, old_version):
"""Install and configure the app."""
super().setup(old_version)
if not old_version:
self.enable()
if old_version == 1:
config = privileged.export_config()
if config['enabled']:
self.enable()
else:
self.disable()
del config['enabled']
set_config(config)
privileged.clean()
def _lookup_public_address(domain):
"""Return the IP address by querying an external server."""
try:
ip_type = 'ipv6' if domain['use_ipv6'] else 'ipv4'
return lookup_public_address(ip_type)
except Exception:
return None
def _query_dns_address(domain):
"""Return the IP address in the DNS records."""
ip_option = 'AAAA' if domain['use_ipv6'] else 'A'
try:
output = subprocess.check_output(
['host', '-t', ip_option, domain['domain']])
return output.decode().split(' ')[-1].strip().lower()
except subprocess.CalledProcessError as exception:
logger.warning('Unable to lookup DNS for host %s: %s',
domain['domain'], exception)
return None
def _update_using_url(domain, external_address):
"""Update DNS entry using an update URL."""
update_url = domain['update_url']
quote = urllib.parse.quote
if external_address:
update_url = update_url.replace('<Ip>', quote(external_address))
if domain['domain']:
update_url = update_url.replace('<Domain>', quote(domain['domain']))
if domain['username']:
update_url = update_url.replace('<User>', quote(domain['username']))
if domain['password']:
update_url = update_url.replace('<Pass>', quote(domain['password']))
options = ['-o', '/dev/null', '-t', '3', '-T', '3']
if domain['use_http_basic_auth']:
options += [
'--user', domain['username'], '--password', domain['password']
]
if domain['disable_ssl_cert_check']:
options += ['--no-check-certificate']
if domain['use_ipv6']:
options += ['-6']
else:
options += ['-4']
command = ['wget', '-O', '/dev/null'] + options + [update_url]
process = subprocess.run(command, check=False)
return process.returncode == 0, external_address
def _update_dns_for_domain(domain):
"""Update DNS records for a single domain."""
result = False
ip_address = None
error = None
try:
dns_address = _query_dns_address(domain)
external_address = _lookup_public_address(domain)
if dns_address == external_address and dns_address is not None:
logger.info('Dynamic domain %s is up-to-date: %s',
domain['domain'], dns_address)
result = True
ip_address = dns_address
error = ValueError('up-to-date')
else:
logger.info(
'Updating dynamic domain %s, DNS address %s, looked up '
'external address %s', domain['domain'], dns_address,
external_address)
if domain['service_type'] == 'gnudip':
result, ip_address = gnudip.update(domain['server'],
domain['domain'],
domain['username'],
domain['password'])
else:
result, ip_address = _update_using_url(domain,
external_address)
except Exception as exception:
logger.exception('Failed to be update Dynamic DNS - %s', exception)
error = exception
set_status(domain, result, ip_address, error)
def update_dns(_data):
"""For all configured domains, check and up to date DNS records."""
config = get_config()
app = app_module.App.get('dynamicdns')
if not app.is_enabled():
return
# Update for each domain
for domain in config['domains'].values():
_update_dns_for_domain(domain)
def get_status():
"""Return the status of recent update for each domain."""
status = kvstore.get_default('dynamicdns_status', '{}')
status = json.loads(status)
status.setdefault('domains', {})
domains = get_config()['domains']
for domain in domains:
if domain not in status['domains']:
# No status available for newly configured domain
status['domains'][domain] = {
'domain': domain,
'result': False,
'ip_address': None,
'error_code': None,
'error_message': None,
'timestamp': 0,
}
return status
def set_status(domain, result, ip_address, error=None):
"""Set the status of most recent update."""
status = kvstore.get_default('dynamicdns_status', '{}')
status = json.loads(status)
domains = status.setdefault('domains', {})
domains[domain['domain']] = {
'domain': domain['domain'],
'result': result,
'ip_address': ip_address,
'error_code': str(error.__class__.__name__) if error else None,
'error_message': str(error.args[0]) if error and error.args else None,
'timestamp': int(time.time()),
}
kvstore.set('dynamicdns_status', json.dumps(status))
def get_config():
"""Return the current configuration."""
default_config = {'domains': {}}
config = kvstore.get_default('dynamicdns_config', '{}')
config = json.loads(config) or default_config
return _fix_corrupt_config(config)
def _fix_corrupt_config(config):
"""Fix malformed configuration result of bug in older version."""
if 'null' not in config['domains']:
return config
del config['domains']['null']
set_config(config)
return config
def set_config(config):
"""Set a new configuration."""
kvstore.set('dynamicdns_config', json.dumps(config))
def notify_domain_added(domain_name):
"""Send a signal that domain has been added."""
if app_module.App.get('dynamicdns').is_enabled():
domain_added.send_robust(sender='dynamicdns',
domain_type='domain-type-dynamic',
name=domain_name, services='__all__')
def notify_domain_removed(domain_name):
"""Send a signal that domain has been removed."""
if app_module.App.get('dynamicdns').is_enabled():
domain_removed.send_robust(sender='dynamicdns',
domain_type='domain-type-dynamic',
name=domain_name)
|