├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── .renovaterc.json ├── AUTHORS ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── .gitignore ├── Makefile ├── conf.py ├── dev │ ├── api.rst │ └── contrib.rst ├── index.rst ├── make.bat ├── man │ └── dyndnsc.rst ├── requirements.txt └── user │ ├── faq.rst │ ├── install.rst │ ├── intro.rst │ ├── license.rst │ ├── quickstart.rst │ └── updates.rst ├── dyndns.plist ├── dyndnsc ├── __init__.py ├── cli.py ├── common │ ├── __init__.py │ ├── constants.py │ ├── detect_ip.py │ ├── dynamiccli.py │ ├── load.py │ ├── six.py │ └── subject.py ├── conf.py ├── core.py ├── detector │ ├── __init__.py │ ├── base.py │ ├── builtin.py │ ├── command.py │ ├── dns.py │ ├── dnswanip.py │ ├── iface.py │ ├── manager.py │ ├── null.py │ ├── rand.py │ ├── socket_ip.py │ ├── teredo.py │ └── webcheck.py ├── plugins │ ├── __init__.py │ ├── base.py │ ├── builtin.py │ ├── manager.py │ └── notify │ │ └── __init__.py ├── resources │ ├── __init__.py │ └── presets.ini ├── tests │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── test_dynamiccli.py │ │ └── test_subject.py │ ├── detector │ │ ├── __init__.py │ │ ├── test_all.py │ │ ├── test_dnswanip.py │ │ └── test_iface.py │ ├── plugins │ │ ├── __init__.py │ │ ├── notify │ │ │ └── __init__.py │ │ ├── test_base.py │ │ └── test_manager.py │ ├── resources │ │ ├── __init__.py │ │ └── test_resources.py │ ├── test_cli.py │ ├── test_conf.py │ ├── test_console_scripts.py │ ├── test_core.py │ └── updater │ │ ├── __init__.py │ │ ├── test_afraid.py │ │ ├── test_all.py │ │ ├── test_dnsimple.py │ │ ├── test_duckdns.py │ │ └── test_dyndns2.py └── updater │ ├── __init__.py │ ├── afraid.py │ ├── base.py │ ├── builtin.py │ ├── dnsimple.py │ ├── duckdns.py │ ├── dummy.py │ ├── dyndns2.py │ └── manager.py ├── example.ini ├── packaging ├── Vagrantfile ├── debian │ ├── README.rst │ ├── changelog │ ├── compat │ ├── control │ ├── copyright │ ├── docs │ ├── dyndnsc.dirs │ ├── dyndnsc.install │ ├── pycompat │ └── rules ├── docker │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── arm32v7-ubuntu │ │ ├── Dockerfile │ │ └── hooks │ │ │ └── build │ ├── prepare_source.sh │ ├── x86-alpine │ │ ├── Dockerfile │ │ └── hooks │ │ │ └── build │ └── x86-ubuntu │ │ ├── Dockerfile │ │ └── hooks │ │ └── build └── provision.sh ├── requirements-style.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = dyndnsc/tests/* 3 | [run] 4 | relative_files = True 5 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: unit tests 3 | 4 | on: 5 | - push 6 | - pull_request 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.7", "3.8", "3.9", "3.10"] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox tox-gh-actions coveralls 25 | - name: Test with tox 26 | run: tox 27 | - name: Coveralls 28 | uses: AndreMiras/coveralls-python-action@develop 29 | with: 30 | parallel: true 31 | flag-name: Unit Test 32 | 33 | coveralls_finish: 34 | needs: test 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Coveralls Finished 38 | uses: AndreMiras/coveralls-python-action@develop 39 | with: 40 | github-token: ${{ secrets.github_token }} 41 | parallel-finished: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | deb_dist/ 12 | develop-eggs/ 13 | dist/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | 26 | # Unit test / coverage reports 27 | .tox/ 28 | .pytest_cache/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .settings/ 38 | .pydevproject 39 | .envrc 40 | .vscode/ 41 | 42 | # virtualenv 43 | env/ 44 | 45 | #python fubar 46 | MANIFEST 47 | 48 | # automation 49 | packaging/.vagrant 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: git@github.com:pre-commit/pre-commit-hooks 4 | rev: v4.0.1 5 | hooks: 6 | - id: check-case-conflict 7 | - id: fix-encoding-pragma 8 | - id: check-docstring-first 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-ast 12 | - id: check-byte-order-marker 13 | - id: check-merge-conflict 14 | - id: detect-private-key 15 | - id: mixed-line-ending 16 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | #disable=missing-docstring 3 | 4 | [FORMAT] 5 | max-line-length=120 6 | 7 | [MASTER] 8 | # ignore=tests 9 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 10 | extension-pkg-whitelist=netifaces 11 | max-branches=12 12 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Paul Kremer 2 | Alex (alexm888): IPDetector_Command 3 | Thomas Waldmann: nsupdate.info support, random hacks 4 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Release history 4 | --------------- 5 | 0.6.x (unreleased) 6 | ++++++++++++++++++ 7 | - changed: moved to github actions instead of travis-ci due to policy changes on travis-ci 8 | - changed: migrated testing from using bottle servers to mocking 9 | - changed: dropped support for python 3.6 10 | 11 | 0.6.1 (April 2nd 2021) 12 | ++++++++++++++++++++++ 13 | - improved: dnswanip error reporting now includes dns information 14 | - improved: fix for bug `#144 `_ 15 | - improved: added tests for console script 16 | 17 | 0.6.0 (February 21st 2021) 18 | ++++++++++++++++++++++++++ 19 | - changed (**INCOMPATIBLE**): dropped support for python 2.7 and python 3.4, 3.5 20 | - added: more presets 21 | - improved: add support for python 3.8, 3.9 22 | - added: docker build automation 23 | - added: `--log-json` command line option, useful when running in docker 24 | 25 | 0.5.1 (July 7th 2019) 26 | ++++++++++++++++++++++ 27 | - improved: pin pytest version to `version smaller than 5.0.0 `_ 28 | 29 | 0.5.0 (June 25th 2019) 30 | ++++++++++++++++++++++ 31 | - improved: simplified notification plugin and externalized them using entry_points 32 | - added: WAN IP detection through DNS (detector 'dnswanip') 33 | - improved: replaced built-in daemon code with `daemonocle `_ 34 | - switched to `pytest `_ for running tests 35 | - changed (**INCOMPATIBLE**): dropped support for python 2.6 and python 3.3 36 | - added: new command line option -v to control verbosity 37 | - improved: infinite loop and daemon stability, diagnostics #57 38 | - improved: updated list of external urls for IP discovery 39 | - improved: install documentation updated 40 | - improved: add many missing docstrings and fixed many code smells 41 | - improved: run `flake8 `_ code quality checks in CI 42 | - improved: run `check-manifest `_ in CI 43 | - improved: run `safety `_ in CI 44 | 45 | 0.4.4 (December 27th 2017) 46 | ++++++++++++++++++++++++++ 47 | - fixed: fixed wheel dependency on python 2.6 and 3.3 48 | - fixed: pep8 related changes, doc fixes 49 | 50 | 0.4.3 (June 26th 2017) 51 | ++++++++++++++++++++++ 52 | - fixed: nsupdate URLs 53 | - fixed: several minor cosmetic issues, mostly testing related 54 | 55 | 0.4.2 (March 8th 2015) 56 | ++++++++++++++++++++++ 57 | - added: support for https://www.duckdns.org 58 | - fixed: user configuration keys now override built-in presets 59 | 60 | 0.4.1 (February 16th 2015) 61 | ++++++++++++++++++++++++++ 62 | - bugfixes 63 | 64 | 0.4.0 (February 15th 2015) 65 | ++++++++++++++++++++++++++ 66 | 67 | - changed (**INCOMPATIBLE**): command line arguments have been drastically adapted 68 | to fit different update protocols and detectors 69 | - added: config file support 70 | - added: running against multiple update services in one go using config file 71 | - improved: for python < 3.2, install more dependencies to get SNI support 72 | - improved: the DNS resolution automatically resolves using the same address 73 | family (ipv4/A or ipv6/AAAA or any) as the detector configured 74 | - improved: it is now possible to specify arbitrary service URLs for the 75 | different updater protocols. 76 | - fixed: naming conventions 77 | - fixed: http connection robustness (i.e. catch more errors and handle them as 78 | being transient) 79 | - changed: dependency on netifaces was removed, but if installed, the 80 | functionality remains in place 81 | - a bunch of pep8, docstring and documntation updates 82 | 83 | 0.3.4 (January 3rd 2014) 84 | ++++++++++++++++++++++++ 85 | - added: initial support for dnsimple.com through 86 | `dnsimple-dyndns `_ 87 | - added: plugin based desktop notification (growl and OS X notification center) 88 | - changed: for python3.3+, use stdlib 'ipaddress' instead of 'IPy' 89 | - improved: dyndns2 update is now allowed to timeout 90 | - improved: freedns.afraid.org robustness 91 | - improved: webcheck now has an http timeout 92 | - improved: naming conventions in code 93 | - added: initial documentation using sphinx 94 | 95 | 0.3.3 (December 2nd 2013) 96 | +++++++++++++++++++++++++ 97 | - added: experimental support for http://freedns.afraid.org 98 | - added: detecting ipv6 addresses using 'webcheck6' or 'webcheck46' 99 | - fixed: long outstanding state bugs in detector base class 100 | - improved: input validation in Iface detection 101 | - improved: support pytest conventions 102 | 103 | 0.3.2 (November 16th 2013) 104 | ++++++++++++++++++++++++++ 105 | - added: command line option --debug to explicitly increase loglevel 106 | - fixed potential race issues in detector base class 107 | - fixed: several typos, test structure, naming conventions, default loglevel 108 | - changed: dynamic importing of detector code 109 | 110 | 0.3.1 (November 2013) 111 | +++++++++++++++++++++ 112 | - added: support for https://nsupdate.info 113 | - fixed: automatic installation of 'requests' with setuptools dependencies 114 | - added: more URL sources for 'webcheck' IP detection 115 | - improved: switched optparse to argparse for future-proofing 116 | - fixed: logging initialization warnings 117 | - improved: ship tests with source tarball 118 | - improved: use reStructuredText rather than markdown 119 | 120 | 0.3 (October 2013) 121 | +++++++++++++++++++ 122 | - moved project to https://github.com/infothrill/python-dyndnsc 123 | - added continuous integration tests using http://travis-ci.org 124 | - added unittests 125 | - dyndnsc is now a package rather than a single file module 126 | - added more generic observer/subject pattern that can be used for 127 | desktop notifications 128 | - removed growl notification 129 | - switched all http related code to the "requests" library 130 | - added http://www.noip.com 131 | - removed dyndns.majimoto.net 132 | - dropped support for python <= 2.5 and added support for python 3.2+ 133 | 134 | 0.2.1 (February 2013) 135 | +++++++++++++++++++++ 136 | - moved code to git 137 | - minimal PEP8 changes and code restructuring 138 | - provide a makefile to get dependencies using buildout 139 | 140 | 0.2.0 (February 2010) 141 | +++++++++++++++++++++ 142 | - updated IANA reserved IP address space 143 | - Added new IP Detector: running an external command 144 | - Minimal syntax changes based on the 2to3 tool, but remaining compatible 145 | with python 2.x 146 | 147 | 0.1.2 (July 2009) 148 | +++++++++++++++++ 149 | - Added a couple of documentation files to the source distribution 150 | 151 | 0.1.1 (September 2008) 152 | ++++++++++++++++++++++ 153 | - Focus: initial public release 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2015 Paul Kremer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include *.rst 3 | include LICENSE 4 | include example.ini 5 | recursive-include dyndnsc *.py 6 | recursive-include dyndnsc/resources * 7 | recursive-include docs * 8 | global-exclude *.py[co] 9 | recursive-exclude packaging * 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: publish clean 2 | 3 | PYTHON=python3 4 | 5 | publish: 6 | @echo "Use 'tox -e release'" 7 | 8 | deb: 9 | # this requires `apt-get install debhelper python3-all` 10 | # Please note that this is not the way "official" debian packages are built 11 | # Please also note that dyndnsc is best supported in python3, so debs for python2 are 12 | # simply left out. 13 | pip install stdeb 14 | $(PYTHON) setup.py --command-packages=stdeb.command bdist_deb 15 | 16 | 17 | clean: 18 | @echo "Cleaning up distutils and tox stuff" 19 | rm -rf build dist deb_dist 20 | rm -rf *.egg .eggs *.egg-info 21 | rm -rf .tox 22 | @echo "Cleaning up byte compiled python stuff" 23 | find . -regex "\(.*__pycache__.*\|*.py[co]\)" -delete 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Dyndnsc - dynamic dns update client 2 | =================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/dyndnsc.svg 5 | :target: https://pypi.python.org/pypi/dyndnsc 6 | 7 | .. image:: https://img.shields.io/pypi/l/dyndnsc.svg 8 | :target: https://pypi.python.org/pypi/dyndnsc 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/dyndnsc.svg 11 | :target: https://pypi.python.org/pypi/dyndnsc 12 | 13 | .. image:: https://github.com/infothrill/python-dyndnsc/actions/workflows/tests.yml/badge.svg?branch=master 14 | :target: https://github.com/infothrill/python-dyndnsc/actions/workflows/tests.yml 15 | 16 | .. image:: https://img.shields.io/coveralls/infothrill/python-dyndnsc/master.svg 17 | :target: https://coveralls.io/r/infothrill/python-dyndnsc?branch=master 18 | :alt: Code coverage 19 | 20 | *Dyndnsc* is a command line client for sending updates to dynamic 21 | dns (ddns, dyndns) services. It supports multiple protocols and services, 22 | and it has native support for ipv6. The configuration file allows 23 | using foreign, but compatible services. *Dyndnsc* ships many different IP 24 | detection mechanisms, support for configuring multiple services in one place 25 | and it has a daemon mode for running unattended. It has a plugin system 26 | to provide external notification services. 27 | 28 | 29 | Quickstart / Documentation 30 | ========================== 31 | See the Quickstart section of the https://dyndnsc.readthedocs.io/ 32 | 33 | 34 | Installation 35 | ============ 36 | 37 | .. code-block:: bash 38 | 39 | # from pypi: 40 | pip install dyndnsc 41 | 42 | # using docker: 43 | docker pull infothrill/dyndnsc-x86-alpine 44 | 45 | # from downloaded source: 46 | python setup.py install 47 | 48 | # directly from github: 49 | pip install https://github.com/infothrill/python-dyndnsc/zipball/develop 50 | 51 | 52 | Requirements 53 | ============ 54 | * Python 3.6+ 55 | 56 | 57 | Status 58 | ====== 59 | *Dyndnsc* is still in alpha stage, which means that any interface can still 60 | change at any time. For this to change, it shall be sufficient to have 61 | documented use of this package which will necessitate stability (i.e. 62 | community process). 63 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | _static 3 | _templates 4 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/dyndnsc.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/dyndnsc.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/dyndnsc" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/dyndnsc" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # dyndnsc documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Dec 5 14:28:10 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath('../dyndnsc')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'dyndnsc' 45 | copyright = u'2013-2018, Paul Kremer' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | 52 | import dyndnsc 53 | # The short X.Y version. 54 | version = dyndnsc.__version__ 55 | # The full version, including alpha/beta/rc tags. 56 | release = version 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'sphinx_rtd_theme' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | #html_theme_path = [] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'dyndnscdoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'dyndnsc.tex', u'dyndnsc Documentation', 190 | u'Paul Kremer', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('man/dyndnsc', 'dyndnsc', u'dyndnsc cli documentation', [u'Paul Kremer'], 1), 220 | #('index', 'dyndnsc', u'dyndnsc Documentation', [u'Paul Kremer'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ('index', 'dyndnsc', u'dyndnsc Documentation', 234 | u'Paul Kremer', 'dyndnsc', 'One line description of project.', 235 | 'Miscellaneous'), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | #texinfo_show_urls = 'footnote' 246 | 247 | 248 | class Mock(object): 249 | def __init__(self, *args, **kwargs): 250 | pass 251 | 252 | def __call__(self, *args, **kwargs): 253 | return Mock() 254 | 255 | @classmethod 256 | def __getattr__(cls, name): 257 | if name in ('__file__', '__path__'): 258 | return '/dev/null' 259 | elif name[0] == name[0].upper(): 260 | mockType = type(name, (), {}) 261 | mockType.__module__ = __name__ 262 | return mockType 263 | else: 264 | return Mock() 265 | 266 | autoclass_content = 'both' 267 | 268 | MOCK_MODULES = ['netifaces', 'netifaces-py3'] 269 | for mod_name in MOCK_MODULES: 270 | sys.modules[mod_name] = Mock() 271 | -------------------------------------------------------------------------------- /docs/dev/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Documentation 4 | ================= 5 | 6 | .. module:: dyndnsc 7 | 8 | This part of the documentation should cover all the relevant interfaces of `dyndnsc`. 9 | 10 | Main Interface 11 | -------------- 12 | 13 | 14 | .. autoclass:: dyndnsc.DynDnsClient 15 | :inherited-members: 16 | 17 | IP Updaters 18 | ----------- 19 | Afraid 20 | ~~~~~~ 21 | .. automodule:: dyndnsc.updater.afraid 22 | 23 | Duckdns 24 | ~~~~~~~ 25 | .. automodule:: dyndnsc.updater.duckdns 26 | 27 | Dyndns2 28 | ~~~~~~~ 29 | .. automodule:: dyndnsc.updater.dyndns2 30 | 31 | 32 | IP Detectors 33 | ------------ 34 | 35 | Command 36 | ~~~~~~~ 37 | .. automodule:: dyndnsc.detector.command 38 | 39 | .. autoclass:: dyndnsc.detector.command.IPDetector_Command 40 | :special-members: __init__ 41 | 42 | 43 | DNS WAN IP 44 | ~~~~~~~~~~ 45 | .. automodule:: dyndnsc.detector.dnswanip 46 | 47 | .. autoclass:: dyndnsc.detector.dnswanip.IPDetector_DnsWanIp 48 | :special-members: __init__ 49 | 50 | Interface 51 | ~~~~~~~~~ 52 | .. automodule:: dyndnsc.detector.iface 53 | 54 | .. autoclass:: dyndnsc.detector.iface.IPDetector_Iface 55 | :special-members: __init__ 56 | 57 | 58 | Socket 59 | ~~~~~~ 60 | .. automodule:: dyndnsc.detector.socket_ip 61 | 62 | .. autoclass:: dyndnsc.detector.socket_ip.IPDetector_Socket 63 | :special-members: __init__ 64 | 65 | 66 | Teredo 67 | ~~~~~~ 68 | .. automodule:: dyndnsc.detector.teredo 69 | 70 | .. autoclass:: dyndnsc.detector.teredo.IPDetector_Teredo 71 | :special-members: __init__ 72 | 73 | Web check 74 | ~~~~~~~~~ 75 | .. automodule:: dyndnsc.detector.webcheck 76 | 77 | .. autoclass:: dyndnsc.detector.webcheck.IPDetectorWebCheck 78 | :special-members: __init__ 79 | -------------------------------------------------------------------------------- /docs/dev/contrib.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Basic method to contribute a change 5 | ----------------------------------- 6 | 7 | Dyndnsc is under active development, and contributions are more than welcome! 8 | 9 | #. Check for open issues or open a fresh issue to start a discussion around a bug 10 | on the `issue tracker `_. 11 | #. Fork `the repository `_ and start making your 12 | changes to a new branch. 13 | #. Write a test which shows that the bug was fixed. 14 | #. Send a pull request and bug the maintainer until it gets merged and published. :) 15 | Make sure to add yourself to `AUTHORS `_. 16 | 17 | 18 | Idioms to keep in mind 19 | ---------------------- 20 | 21 | * keep amount of external dependencies low, i.e. if it can be done using the 22 | standard library, do it using the standard library 23 | * do not prefer specific operating systems, i.e. even if we love Linux, we 24 | shall not make other suffer from our personal choice 25 | * write unittests 26 | 27 | Also, keep these :pep:`20` idioms in mind: 28 | 29 | #. Beautiful is better than ugly. 30 | #. Explicit is better than implicit. 31 | #. Simple is better than complex. 32 | #. Complex is better than complicated. 33 | #. Readability counts. 34 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. dyndnsc documentation master file 2 | 3 | Welcome to Dyndnsc's documentation! 4 | =================================== 5 | 6 | User Guide 7 | ---------- 8 | 9 | This part of the documentation, which is mostly prose, begins with some 10 | background information about Dyndnsc, then focuses on step-by-step 11 | instructions for getting the most out of Dyndnsc. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | user/intro 17 | user/install 18 | user/quickstart 19 | user/faq 20 | user/updates 21 | user/license 22 | 23 | 24 | API Documentation 25 | ----------------- 26 | 27 | If you are looking for information on a specific function, class or method, 28 | this part of the documentation is for you. 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | dev/api 34 | 35 | Contributor Guide 36 | ----------------- 37 | 38 | If you want to contribute to the project, this part of the documentation is for 39 | you. 40 | 41 | .. toctree:: 42 | :maxdepth: 2 43 | 44 | dev/contrib 45 | 46 | Indices and tables 47 | ================== 48 | 49 | * :ref:`genindex` 50 | * :ref:`modindex` 51 | * :ref:`search` 52 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\dyndnsc.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\dyndnsc.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/man/dyndnsc.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | dyndnsc command line interface 4 | ============================== 5 | 6 | Synopsis 7 | -------- 8 | 9 | **dyndnsc** [*options*] [*params* ...] 10 | 11 | 12 | Description 13 | ----------- 14 | 15 | :program:`dyndnsc` is the command line interface to sync dynamic dns entries. 16 | 17 | 18 | :program:`dyndnsc --help` will give a list of available options. 19 | 20 | 21 | See also 22 | -------- 23 | 24 | :manpage:`dyndnsc(1)` 25 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==7.2.6 2 | -------------------------------------------------------------------------------- /docs/user/faq.rst: -------------------------------------------------------------------------------- 1 | .. _faq: 2 | 3 | Frequently Asked Questions 4 | ========================== 5 | 6 | Python 3 Support? 7 | ----------------- 8 | 9 | Yes! In fact, we only support Python3 at this point. 10 | 11 | Here's a list of Python platforms that are officially 12 | supported: 13 | 14 | * Python 3.6 15 | * Python 3.7 16 | * Python 3.8 17 | * Python 3.9 18 | 19 | 20 | Is service xyz supported? 21 | ------------------------- 22 | To find out wether a certain dynamic dns service is supported by Dyndnsc, you 23 | can either try to identify the protocol involved and see if it is supported by 24 | Dyndnsc by looking the output of 'dyndnsc --help'. Or maybe the service in 25 | question is already listed in the presets ('dyndnsc --list-presets'). 26 | 27 | I get a wrong IPv6 address, why? 28 | -------------------------------- 29 | 30 | If you use the "webcheck6" detector and your system has IPv6 privacy extensions, 31 | it'll result in the temporary IPv6 address that you use to connect to the 32 | outside world. 33 | 34 | You likely rather want your less private, but static global IPv6 address in 35 | DNS and you can determine it using the "socket" detector. 36 | 37 | 38 | What about error handling of network issues? 39 | -------------------------------------------- 40 | 41 | "Hard" errors on the transport level (tcp timeouts, socket erors...) are 42 | not handled and will fail the client. In daemon or loop mode, exceptions are 43 | caught to keep the client alive (and retries will be issued at a later time). 44 | -------------------------------------------------------------------------------- /docs/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | This part of the documentation covers the installation of Dyndnsc. 7 | The first step to using any software package is getting it properly installed. 8 | 9 | 10 | Pip / pipsi 11 | ----------- 12 | 13 | Installing Dyndnsc is simple with `pip `_:: 14 | 15 | pip install dyndnsc 16 | 17 | Or, if you prefer a more encapsulated way, use `pipsi `_:: 18 | 19 | pipsi install dyndnsc 20 | 21 | 22 | Docker 23 | ------ 24 | 25 | `Docker `_ images are provided for the following architectures. 26 | 27 | x86:: 28 | 29 | docker pull infothrill/dyndnsc-x86-alpine 30 | 31 | See also https://hub.docker.com/r/infothrill/dyndnsc-x86-alpine/ 32 | 33 | armhf:: 34 | 35 | docker pull infothrill/dyndnsc-armhf-alpine 36 | 37 | See also https://hub.docker.com/r/infothrill/dyndnsc-armhf-alpine/ 38 | 39 | Get the Code 40 | ------------ 41 | 42 | Dyndnsc is developed on GitHub, where the code is 43 | `available `_. 44 | 45 | You can clone the public repository:: 46 | 47 | git clone https://github.com/infothrill/python-dyndnsc.git 48 | 49 | Once you have a copy of the source, you can embed it in your Python package, 50 | or install it into your site-packages easily:: 51 | 52 | python setup.py install 53 | -------------------------------------------------------------------------------- /docs/user/intro.rst: -------------------------------------------------------------------------------- 1 | .. _introduction: 2 | 3 | Introduction 4 | ============ 5 | 6 | What is Dyndnsc? 7 | ---------------- 8 | It's a `dynamic DNS client `_. 9 | It can detect your IP address in a variety of ways and update DNS records 10 | automatically. 11 | 12 | Goals 13 | ----- 14 | Provide: 15 | 16 | * an easy to use command line tool 17 | * an API for developers 18 | * support for a variety of ways to detect IP addresses 19 | * support for a variety of ways to update DNS records 20 | -------------------------------------------------------------------------------- /docs/user/license.rst: -------------------------------------------------------------------------------- 1 | .. _`mit`: 2 | 3 | License 4 | ------- 5 | *Dyndnsc* is released under terms of `MIT License`_. This license was chosen 6 | explicitly to allow inclusion of this software in proprietary and closed systems. 7 | 8 | .. _`MIT License`: http://www.opensource.org/licenses/MIT 9 | 10 | 11 | .. include:: ../../LICENSE 12 | -------------------------------------------------------------------------------- /docs/user/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | 7 | Eager to get started? This page gives a good introduction in how to get started 8 | with Dyndnsc. This assumes you already have Dyndnsc installed. If you do not, 9 | head over to the :ref:`Installation ` section. 10 | 11 | First, make sure that: 12 | 13 | * Dyndnsc is :ref:`installed ` 14 | * Dyndnsc is :ref:`up-to-date ` 15 | 16 | 17 | Let's get started with some simple examples. 18 | 19 | 20 | Command line usage 21 | ------------------ 22 | 23 | Dyndnsc exposes all options through the command line interface, however, 24 | we do recommend using a configuration file. 25 | Here is an example to update an IPv4 record on nsupdate.info with web 26 | based IP autodetection: 27 | 28 | .. code-block:: bash 29 | 30 | $ dyndnsc --updater-dyndns2 \ 31 | --updater-dyndns2-hostname test.nsupdate.info \ 32 | --updater-dyndns2-userid test.nsupdate.info \ 33 | --updater-dyndns2-password XXXXXXXX \ 34 | --updater-dyndns2-url https://nsupdate.info/nic/update \ 35 | --detector-webcheck4 \ 36 | --detector-webcheck4-url https://ipv4.nsupdate.info/myip \ 37 | --detector-webcheck4-parser plain 38 | 39 | 40 | Updating an IPv6 address when using `Miredo `_: 41 | 42 | .. code-block:: bash 43 | 44 | $ dyndnsc --updater-dyndns2 \ 45 | --updater-dyndns2-hostname test.nsupdate.info \ 46 | --updater-dyndns2-userid test.nsupdate.info \ 47 | --updater-dyndns2-password XXXXXXXX \ 48 | --detector-teredo 49 | 50 | Updating an IPv6 record on nsupdate.info with interface based IP detection: 51 | 52 | .. code-block:: bash 53 | 54 | $ dyndnsc --updater-dyndns2 \ 55 | --updater-dyndns2-hostname test.nsupdate.info \ 56 | --updater-dyndns2-userid test.nsupdate.info \ 57 | --updater-dyndns2-password XXXXXXXX \ 58 | --detector-socket \ 59 | --detector-socket-family INET6 60 | 61 | Update protocols 62 | ---------------- 63 | Dyndnsc supports several different methods for updating dynamic DNS services: 64 | 65 | * `dnsimple `_ 66 | Note: requires python package `dnsimple-dyndns `_ to be installed 67 | * `duckdns `_ 68 | * `dyndns2 `_ 69 | * `freedns.afraid.org `_ 70 | 71 | A lot of services on the internet offer some form of compatibility, so check 72 | against this list. Some of these external services are pre-configured for 73 | Dyndnsc as a `preset`, see the section on presets. 74 | 75 | Each supported update protocol can be parametrized on the dyndnsc command line 76 | using long options starting with '--updater-' followed by the name of the 77 | protocol: 78 | 79 | .. code-block:: bash 80 | 81 | $ dyndnsc --updater-afraid 82 | $ dyndnsc --updater-dnsimple 83 | $ dyndnsc --updater-duckdns 84 | $ dyndnsc --updater-dyndns2 85 | 86 | Each of these update protocols supports specific parameters, which might differ 87 | from each other. Each of these additional parameters can specified on the 88 | command line by appending them to the long option described above. 89 | 90 | Example to specify `token` for updater `duckdns`: 91 | 92 | .. code-block:: bash 93 | 94 | $ dyndnsc --updater-duckdns-token 847c0ffb-39bd-326f-b971-bfb3d4e36d7b 95 | 96 | 97 | Detecting the IP 98 | ---------------- 99 | *Dyndnsc* ships a couple of "detectors" which are capable of finding an IP 100 | address through different means. 101 | 102 | Detectors may need additional parameters to work properly. Additional parameters 103 | can be specified on the command line similarly to the update protocols. 104 | 105 | .. code-block:: bash 106 | 107 | $ dyndnsc --detector-iface \ 108 | --detector-iface-iface en0 \ 109 | --detector-iface-family INET 110 | 111 | $ dyndnsc --detector-webcheck4 \ 112 | --detector-webcheck4-url http://ipv4.nsupdate.info/myip \ 113 | --detector-webcheck4-parser plain 114 | 115 | Some detectors require additional python dependencies: 116 | 117 | * *iface*, *teredo* detectors require `netifaces `_ to be installed 118 | 119 | Presets 120 | ------- 121 | *Dyndnsc* comes with a list of pre-configured presets. To see all configured 122 | presets, you can run 123 | 124 | .. code-block:: bash 125 | 126 | $ dyndnsc --list-presets 127 | 128 | Presets are used to shorten the amount of configuration needed by providing 129 | preconfigured parameters. For convenience, Dyndnsc ships some built-in presets 130 | but this list can be extended by yourself by adding them to the configuration 131 | file. Each preset has a section in the ini file called '[preset:NAME]'. 132 | See the section on the configuration file to see how to use presets. 133 | 134 | Note: Presets can currently only be used in a configuration file. There is 135 | currently no support to select a preset from the command line. 136 | 137 | Configuration file 138 | ------------------ 139 | 140 | Create a config file test.cfg with this content (no spaces at the left!): 141 | 142 | .. code-block:: ini 143 | 144 | [dyndnsc] 145 | configs = test_ipv4, test_ipv6 146 | 147 | [test_ipv4] 148 | use_preset = nsupdate.info:ipv4 149 | updater-hostname = test.nsupdate.info 150 | updater-userid = test.nsupdate.info 151 | updater-password = xxxxxxxx 152 | 153 | [test_ipv6] 154 | use_preset = nsupdate.info:ipv6 155 | updater-hostname = test.nsupdate.info 156 | updater-userid = test.nsupdate.info 157 | updater-password = xxxxxxxx 158 | 159 | Now invoke dyndnsc and give this file as configuration: 160 | 161 | .. code-block:: bash 162 | 163 | $ dyndnsc --config test.cfg 164 | 165 | Custom services 166 | --------------- 167 | 168 | If you are using a dyndns2 compatible service and need to specify the update 169 | URL explicitly, you can add the argument --updater-dyndns2-url: 170 | 171 | .. code-block:: bash 172 | 173 | $ dyndnsc --updater-dyndns2 \ 174 | --updater-dyndns2-hostname=test.dyndns.com \ 175 | --updater-dyndns2-userid=bob \ 176 | --updater-dyndns2-password=fub4r \ 177 | --updater-dyndns2-url=https://dyndns.example.com/nic/update 178 | 179 | 180 | Plugins 181 | ------- 182 | *Dyndnsc* supports plugins which can be notified when a dynamic DNS entry was 183 | changed. Currently, only two plugins exist: 184 | 185 | * `dyndnsc-growl `_ 186 | * `dyndnsc-macosnotify `_ 187 | 188 | The list of plugins that are installed and available in your environment will 189 | be listed in the command line help. Each plugin command line option starts with 190 | '--with-'. 191 | -------------------------------------------------------------------------------- /docs/user/updates.rst: -------------------------------------------------------------------------------- 1 | .. _updates: 2 | 3 | Community Updates 4 | ================= 5 | 6 | 7 | Tracking development 8 | -------------------- 9 | 10 | The best way to track the development of Dyndnsc is through 11 | `the GitHub repo `_. 12 | 13 | 14 | .. include:: ../../CHANGELOG.rst 15 | -------------------------------------------------------------------------------- /dyndns.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Disabled 6 | 7 | Label 8 | dyndnsc-python 9 | Nice 10 | -15 11 | OnDemand 12 | 13 | ProgramArguments 14 | 15 | /Users/bob/bin/dyndnsc.py 16 | --loop 17 | --updater-dyndns2 18 | --updater-dyndns2-hostname 19 | myhost.example.com 20 | --updater-dyndns2-userid 21 | YOUR_USERNAME 22 | --updater-dyndns2-password 23 | YOUR_SECRET_PW 24 | --detector 25 | webcheck 26 | 27 | RunAtLoad 28 | 29 | ServiceDescription 30 | python dyndns client 31 | ServiceIPC 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /dyndnsc/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package for dyndnsc.""" 4 | 5 | from .core import getDynDnsClientForConfig, DynDnsClient # noqa: @UnusedImport 6 | 7 | __version__ = "0.6.2dev0" 8 | -------------------------------------------------------------------------------- /dyndnsc/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Main CLI program.""" 4 | 5 | import sys 6 | import os 7 | import logging 8 | import time 9 | import argparse 10 | from functools import partial 11 | 12 | import json_logging 13 | 14 | from .plugins.manager import DefaultPluginManager 15 | from .updater.manager import updater_classes 16 | from .detector.manager import detector_classes 17 | from .core import getDynDnsClientForConfig 18 | from .conf import get_configuration, collect_config 19 | from .common.dynamiccli import parse_cmdline_args 20 | 21 | 22 | def list_presets(cfg, out): 23 | """Write a human readable list of available presets to out. 24 | 25 | :param cfg: ConfigParser instance 26 | :param out: file object to write to 27 | """ 28 | for section in cfg.sections(): 29 | if section.startswith("preset:"): 30 | out.write((section.replace("preset:", "")) + os.linesep) 31 | for k, v in cfg.items(section): 32 | out.write("\t%s = %s" % (k, v) + os.linesep) 33 | 34 | 35 | def create_argparser(): 36 | """Instantiate an `argparse.ArgumentParser`. 37 | 38 | Adds all basic cli options including default values. 39 | """ 40 | parser = argparse.ArgumentParser() 41 | arg_defaults = { 42 | "daemon": False, 43 | "log_json": False, 44 | "loop": False, 45 | "listpresets": False, 46 | "config": None, 47 | "debug": False, 48 | "sleeptime": 300, 49 | "version": False, 50 | "verbose_count": 0 51 | } 52 | 53 | # add generic client options to the CLI: 54 | parser.add_argument("-c", "--config", dest="config", 55 | help="config file", default=arg_defaults["config"]) 56 | parser.add_argument("--list-presets", dest="listpresets", 57 | help="list all available presets", 58 | action="store_true", default=arg_defaults["listpresets"]) 59 | parser.add_argument("-d", "--daemon", dest="daemon", 60 | help="go into daemon mode (implies --loop)", 61 | action="store_true", default=arg_defaults["daemon"]) 62 | parser.add_argument("--debug", dest="debug", 63 | help="increase logging level to DEBUG (DEPRECATED, please use -vvv)", 64 | action="store_true", default=arg_defaults["debug"]) 65 | parser.add_argument("--log-json", dest="log_json", 66 | help="log in json format", 67 | action="store_true", default=arg_defaults["log_json"]) 68 | parser.add_argument("--loop", dest="loop", 69 | help="loop forever (default is to update once)", 70 | action="store_true", default=arg_defaults["loop"]) 71 | parser.add_argument("--sleeptime", dest="sleeptime", 72 | help="how long to sleep between checks in seconds", 73 | default=arg_defaults["sleeptime"]) 74 | parser.add_argument("--version", dest="version", 75 | help="show version and exit", 76 | action="store_true", default=arg_defaults["version"]) 77 | parser.add_argument("-v", "--verbose", dest="verbose_count", 78 | action="count", default=arg_defaults["verbose_count"], 79 | help="increases log verbosity for each occurrence") 80 | 81 | return parser, arg_defaults 82 | 83 | 84 | def run_forever(dyndnsclients): 85 | """ 86 | Run an endless loop accross the given dynamic dns clients. 87 | 88 | :param dyndnsclients: list of DynDnsClients 89 | """ 90 | while True: 91 | try: 92 | # Do small sleeps in the main loop, needs_check() is cheap and does 93 | # the rest. 94 | time.sleep(15) 95 | for dyndnsclient in dyndnsclients: 96 | dyndnsclient.check() 97 | except KeyboardInterrupt: 98 | break 99 | except Exception as exc: 100 | logging.critical("An exception occurred in the dyndns loop", exc_info=exc) 101 | return 0 102 | 103 | 104 | def init_logging(log_level, log_json=False): 105 | """Configure logging framework.""" 106 | if log_json: 107 | LOG = logging.getLogger() 108 | LOG.setLevel(log_level) 109 | LOG.addHandler(logging.StreamHandler(sys.stdout)) 110 | json_logging.init_non_web(enable_json=True) 111 | json_logging.config_root_logger() 112 | else: 113 | logging.basicConfig(level=log_level, format="%(levelname)s %(message)s") 114 | 115 | 116 | def main(): 117 | """ 118 | Run the main CLI program. 119 | 120 | Initializes the stack, parses command line arguments, and fires requested 121 | logic. 122 | """ 123 | plugins = DefaultPluginManager() 124 | plugins.load_plugins() 125 | 126 | parser, _ = create_argparser() 127 | # add the updater protocol options to the CLI: 128 | for kls in updater_classes(): 129 | kls.register_arguments(parser) 130 | 131 | for kls in detector_classes(): 132 | kls.register_arguments(parser) 133 | 134 | # add the plugin options to the CLI: 135 | from os import environ 136 | plugins.options(parser, environ) 137 | 138 | args = parser.parse_args() 139 | 140 | if args.debug: 141 | args.verbose_count = 5 # some high number 142 | 143 | log_level = max(int(logging.WARNING / 10) - args.verbose_count, 0) * 10 144 | init_logging(log_level, log_json=args.log_json) 145 | # logging.debug("args %r", args) 146 | 147 | if args.version: 148 | from . import __version__ 149 | print("dyndnsc %s" % __version__) # noqa 150 | return 0 151 | 152 | # silence 'requests' logging 153 | requests_log = logging.getLogger("requests") 154 | requests_log.setLevel(logging.WARNING) 155 | 156 | logging.debug(parser) 157 | cfg = get_configuration(args.config) 158 | 159 | if args.listpresets: 160 | list_presets(cfg, out=sys.stdout) 161 | return 0 162 | 163 | if args.config: 164 | collected_configs = collect_config(cfg) 165 | else: 166 | parsed_args = parse_cmdline_args(args, updater_classes().union(detector_classes())) 167 | logging.debug("parsed_args %r", parsed_args) 168 | 169 | collected_configs = { 170 | "cmdline": { 171 | "interval": int(args.sleeptime) 172 | } 173 | } 174 | collected_configs["cmdline"].update(parsed_args) 175 | 176 | plugins.configure(args) 177 | plugins.initialize() 178 | 179 | logging.debug("collected_configs: %r", collected_configs) 180 | dyndnsclients = [] 181 | for thisconfig in collected_configs: 182 | logging.debug("Initializing client for '%s'", thisconfig) 183 | # done with options, bring on the dancing girls 184 | dyndnsclient = getDynDnsClientForConfig( 185 | collected_configs[thisconfig], plugins=plugins) 186 | if dyndnsclient is None: 187 | return 1 188 | # do an initial synchronization, before going into endless loop: 189 | dyndnsclient.sync() 190 | dyndnsclients.append(dyndnsclient) 191 | 192 | run_forever_callable = partial(run_forever, dyndnsclients) 193 | 194 | if args.daemon: 195 | import daemonocle 196 | daemon = daemonocle.Daemon(worker=run_forever_callable) 197 | daemon.do_action("start") 198 | args.loop = True 199 | 200 | if args.loop: 201 | run_forever_callable() 202 | 203 | return 0 204 | -------------------------------------------------------------------------------- /dyndnsc/common/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package containing more general shared code across dyndnsc.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/common/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Common constants.""" 4 | 5 | from .. import __version__ 6 | 7 | REQUEST_HEADERS_DEFAULT = { 8 | # dyndns2 standard requires that we set our own user agent: 9 | "User-Agent": "python-dyndnsc/%s" % __version__, 10 | } 11 | -------------------------------------------------------------------------------- /dyndnsc/common/detect_ip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Permission to use, copy, modify, and/or distribute this software for any 5 | # purpose with or without fee is hereby granted, provided that the above 6 | # copyright notice and this permission notice appear in all copies. 7 | # 8 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | """ 17 | Detect IP v4 or v6 addresses the system uses to talk to outside world. 18 | 19 | Original code from 20 | https://github.com/vincentbernat/puppet-workstation/blob/master/modules/system/templates/network/ddns-updater.erb 21 | 22 | Refactored/modified by Thomas Waldmann to just detect the IP. 23 | """ 24 | 25 | from __future__ import print_function 26 | import errno 27 | import socket 28 | 29 | IPV4 = "ipv4" 30 | IPV6_ANY = "ipv6" 31 | IPV6_PUBLIC = "ipv6_public" 32 | IPV6_TMP = "ipv6_tmp" 33 | 34 | # reserved IPs for documentation/example purposes 35 | OUTSIDE_IPV4 = "192.0.2.1" 36 | OUTSIDE_IPV6 = "2001:db8::1" 37 | 38 | # Not everything is available in Python 39 | if not hasattr(socket, "IPV6_ADDR_PREFERENCES"): 40 | socket.IPV6_ADDR_PREFERENCES = 72 41 | if not hasattr(socket, "IPV6_PREFER_SRC_TMP"): 42 | socket.IPV6_PREFER_SRC_TMP = 1 43 | if not hasattr(socket, "IPV6_PREFER_SRC_PUBLIC"): 44 | socket.IPV6_PREFER_SRC_PUBLIC = 2 45 | 46 | 47 | class GetIpException(Exception): 48 | """Generic base class for all exceptions raised here.""" 49 | 50 | 51 | def detect_ip(kind): 52 | """ 53 | Detect IP address. 54 | 55 | kind can be: 56 | IPV4 - returns IPv4 address 57 | IPV6_ANY - returns any IPv6 address (no preference) 58 | IPV6_PUBLIC - returns public IPv6 address 59 | IPV6_TMP - returns temporary IPV6 address (privacy extensions) 60 | 61 | This function either returns an IP address (str) or 62 | raises a GetIpException. 63 | """ 64 | if kind not in (IPV4, IPV6_PUBLIC, IPV6_TMP, IPV6_ANY): 65 | raise ValueError("invalid kind specified") 66 | 67 | # We create an UDP socket and connect it to a public host. 68 | # We query the OS to know what our address is. 69 | # No packet will really be sent since we are using UDP. 70 | af = socket.AF_INET if kind == IPV4 else socket.AF_INET6 71 | s = socket.socket(af, socket.SOCK_DGRAM) 72 | try: 73 | if kind in [IPV6_PUBLIC, IPV6_TMP, ]: 74 | # caller wants some specific kind of IPv6 address (not IPV6_ANY) 75 | try: 76 | if kind == IPV6_PUBLIC: 77 | preference = socket.IPV6_PREFER_SRC_PUBLIC 78 | elif kind == IPV6_TMP: 79 | preference = socket.IPV6_PREFER_SRC_TMP 80 | s.setsockopt(socket.IPPROTO_IPV6, 81 | socket.IPV6_ADDR_PREFERENCES, preference) 82 | except socket.error as e: 83 | if e.errno == errno.ENOPROTOOPT: 84 | raise GetIpException("Kernel doesn't support IPv6 address preference") 85 | else: 86 | raise GetIpException("Unable to set IPv6 address preference: %s" % e) 87 | 88 | try: 89 | outside_ip = OUTSIDE_IPV4 if kind == IPV4 else OUTSIDE_IPV6 90 | s.connect((outside_ip, 9)) 91 | except (socket.error, socket.gaierror) as e: 92 | raise GetIpException(str(e)) 93 | 94 | ip = s.getsockname()[0] 95 | finally: 96 | s.close() 97 | return ip 98 | 99 | 100 | if __name__ == "__main__": 101 | print("IP v4:", detect_ip(IPV4)) # noqa 102 | print("IP v6 public:", detect_ip(IPV6_PUBLIC)) # noqa 103 | print("IP v6 tmp:", detect_ip(IPV6_TMP)) # noqa 104 | print("IP v6 any:", detect_ip(IPV6_ANY)) # noqa 105 | -------------------------------------------------------------------------------- /dyndnsc/common/dynamiccli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """This module deals with dynamic CLI options.""" 4 | 5 | import logging 6 | import textwrap 7 | 8 | from .six import getargspec 9 | 10 | 11 | def parse_cmdline_args(args, classes): 12 | """ 13 | Parse all updater and detector related arguments from args. 14 | 15 | Returns a list of ("name", { "k": "v"}) 16 | 17 | :param args: argparse arguments 18 | """ 19 | if args is None: 20 | raise ValueError("args must not be None") 21 | parsed_args = {} 22 | for kls in classes: 23 | prefix = kls.configuration_key_prefix() 24 | name = kls.configuration_key 25 | if getattr(args, "%s_%s" % (prefix, name), False): 26 | logging.debug( 27 | "Gathering initargs for '%s.%s'", prefix, name) 28 | initargs = {} 29 | for arg_name in kls.init_argnames(): 30 | val = getattr(args, "%s_%s_%s" % 31 | (prefix, name, arg_name)) 32 | if val is not None: 33 | initargs[arg_name] = val 34 | if prefix not in parsed_args: 35 | parsed_args[prefix] = [] 36 | parsed_args[prefix].append((name, initargs)) 37 | return parsed_args 38 | 39 | 40 | class DynamicCliMixin(object): 41 | """Base class providing functionality to register and handle CLI args.""" 42 | 43 | @classmethod 44 | def init_argnames(cls): 45 | """ 46 | Inspect the __init__ arguments of the given cls. 47 | 48 | :param cls: a class with an __init__ method 49 | """ 50 | return getargspec(cls.__init__).args[1:] 51 | 52 | @classmethod 53 | def _init_argdefaults(cls): 54 | defaults = getargspec(cls.__init__).defaults 55 | if defaults is None: 56 | defaults = () 57 | return defaults 58 | 59 | @classmethod 60 | def register_arguments(cls, parser): 61 | """Register command line options. 62 | 63 | Implement this method for normal options behavior with protection from 64 | OptionConflictErrors. If you override this method and want the default 65 | --$name option(s) to be registered, be sure to call super(). 66 | """ 67 | if hasattr(cls, "_dont_register_arguments"): 68 | return 69 | prefix = cls.configuration_key_prefix() 70 | cfgkey = cls.configuration_key 71 | parser.add_argument("--%s-%s" % (prefix, cfgkey), 72 | action="store_true", 73 | dest="%s_%s" % (prefix, cfgkey), 74 | default=False, 75 | help="%s: %s" % 76 | (cls.__name__, cls.help())) 77 | args = cls.init_argnames() 78 | defaults = cls._init_argdefaults() 79 | for arg in args[0:len(args) - len(defaults)]: 80 | parser.add_argument("--%s-%s-%s" % (prefix, cfgkey, arg), 81 | dest="%s_%s_%s" % (prefix, cfgkey, arg), 82 | help="") 83 | for i, arg in enumerate(args[len(args) - len(defaults):]): 84 | parser.add_argument("--%s-%s-%s" % (prefix, cfgkey, arg), 85 | dest="%s_%s_%s" % (prefix, cfgkey, arg), 86 | default=defaults[i], 87 | help="default: %(default)s") 88 | 89 | @classmethod 90 | def help(cls): 91 | """ 92 | Return help for this. 93 | 94 | This will be output as the help section of the --$name option that 95 | enables this plugin. 96 | """ 97 | if cls.__doc__: 98 | # remove doc section indentation 99 | return textwrap.dedent(cls.__doc__) 100 | return "(no help available)" 101 | 102 | @staticmethod 103 | def configuration_key_prefix(): 104 | """ 105 | Return string prefix for configuration key. 106 | 107 | Abstract method, must be implemented in subclass. 108 | """ 109 | raise NotImplementedError("Please implement in subclass") 110 | -------------------------------------------------------------------------------- /dyndnsc/common/load.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Shared code simplifying plugin stuff.""" 4 | 5 | from importlib import import_module 6 | from warnings import warn 7 | 8 | 9 | def load_class(module_name, class_name): 10 | """Return class object specified by module name and class name. 11 | 12 | Return None if module failed to be imported. 13 | 14 | :param module_name: string module name 15 | :param class_name: string class name 16 | """ 17 | try: 18 | plugmod = import_module(module_name) 19 | except Exception as exc: 20 | warn("Importing built-in plugin %s.%s raised an exception: %r" % 21 | (module_name, class_name, repr(exc)), ImportWarning) 22 | return None 23 | else: 24 | return getattr(plugmod, class_name) 25 | 26 | 27 | def find_class(name, classes): 28 | """Return class in ``classes`` identified by configuration key ``name``.""" 29 | name = name.lower() 30 | cls = next((c for c in classes if c.configuration_key == name), None) 31 | if cls is None: 32 | raise ValueError("No class named '%s' could be found" % name) 33 | return cls 34 | -------------------------------------------------------------------------------- /dyndnsc/common/six.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module for providing python compatibility across interpreter versions.""" 4 | 5 | import inspect 6 | import ipaddress as _ipaddress 7 | 8 | getargspec = inspect.getfullargspec # pylint: disable=invalid-name 9 | string_types = (str,) # pylint: disable=invalid-name 10 | from io import StringIO # noqa: @UnresolvedImport @UnusedImport pylint: disable=unused-import,import-error 11 | 12 | 13 | def ipaddress(addr): 14 | """Return an ipaddress.ip_address object from the given string IP.""" 15 | return _ipaddress.ip_address(addr) 16 | 17 | 18 | def ipnetwork(addr): 19 | """Return an ipaddress.ip_network object from the given string IP.""" 20 | return _ipaddress.ip_network(addr) 21 | -------------------------------------------------------------------------------- /dyndnsc/common/subject.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Observer/subject implementation.""" 4 | 5 | import logging 6 | 7 | LOG = logging.getLogger(__name__) 8 | 9 | 10 | class Subject(object): 11 | """Dispatches messages to registered callables.""" 12 | 13 | def __init__(self): 14 | """Initialize.""" 15 | self._observers = {} 16 | 17 | def register_observer(self, observer, events=None): 18 | """Register a listener function. 19 | 20 | :param observer: external listener function 21 | :param events: tuple or list of relevant events (default=None) 22 | """ 23 | if events is not None and not isinstance(events, (tuple, list)): 24 | events = (events,) 25 | 26 | if observer in self._observers: 27 | LOG.warning("Observer '%r' already registered, overwriting for events" 28 | " %r", observer, events) 29 | self._observers[observer] = events 30 | 31 | def notify_observers(self, event=None, msg=None): 32 | """Notify observers.""" 33 | for observer, events in list(self._observers.items()): 34 | # LOG.debug("trying to notify the observer") 35 | if events is None or event is None or event in events: 36 | try: 37 | observer(self, event, msg) 38 | except Exception as ex: # pylint: disable=broad-except 39 | self.unregister_observer(observer) 40 | errmsg = "Exception in message dispatch: Handler '{0}' unregistered for event '{1}' ".format( 41 | observer.__class__.__name__, event) 42 | LOG.error(errmsg, exc_info=ex) 43 | 44 | def unregister_observer(self, observer): 45 | """Unregister observer callable.""" 46 | del self._observers[observer] 47 | -------------------------------------------------------------------------------- /dyndnsc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Problem to be solved: read and parse config file(s).""" 4 | 5 | import logging 6 | import os 7 | import configparser 8 | 9 | 10 | from .resources import get_filename, PRESETS_INI 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | DEFAULT_USER_INI = ".dyndnsc.ini" 15 | 16 | 17 | def get_configuration(config_file=None): 18 | """Return an initialized ConfigParser. 19 | 20 | If no config filename is presented, `DEFAULT_USER_INI` is used if present. 21 | Also reads the built-in presets. 22 | 23 | :param config_file: string path 24 | """ 25 | parser = configparser.ConfigParser() 26 | if config_file is None: 27 | # fallback to default user config file 28 | config_file = os.path.join(os.getenv("HOME"), DEFAULT_USER_INI) 29 | if not os.path.isfile(config_file): 30 | config_file = None 31 | else: 32 | if not os.path.isfile(config_file): 33 | raise ValueError("%s is not a file" % config_file) 34 | 35 | configs = [get_filename(PRESETS_INI)] 36 | if config_file: 37 | configs.append(config_file) 38 | LOG.debug("Attempting to read configuration from %r", configs) 39 | read_configs = parser.read(configs) 40 | LOG.debug("Successfully read configuration from %r", read_configs) 41 | LOG.debug("config file sections: %r", parser.sections()) 42 | return parser 43 | 44 | 45 | def _iraw_client_configs(cfg): 46 | """ 47 | Generate (client_name, client_cfg_dict) tuples from the configuration. 48 | 49 | Conflates the presets and removes traces of the preset configuration 50 | so that the returned dict can be used directly on a dyndnsc factory. 51 | 52 | :param cfg: ConfigParser 53 | """ 54 | client_names = cfg.get("dyndnsc", "configs").split(",") 55 | _preset_prefix = "preset:" 56 | _use_preset = "use_preset" 57 | for client_name in (x.strip() for x in client_names if x.strip()): 58 | client_cfg_dict = dict(cfg.items(client_name)) 59 | if cfg.has_option(client_name, _use_preset): 60 | prf = dict( 61 | cfg.items(_preset_prefix + cfg.get(client_name, _use_preset))) 62 | prf.update(client_cfg_dict) 63 | client_cfg_dict = prf 64 | else: 65 | # raw config with NO preset in use, so no updating of dict 66 | pass 67 | logging.debug("raw config for '%s': %r", client_name, client_cfg_dict) 68 | if _use_preset in client_cfg_dict: 69 | del client_cfg_dict[_use_preset] 70 | yield client_name, client_cfg_dict 71 | 72 | 73 | def collect_config(cfg): 74 | """ 75 | Construct configuration dictionary from configparser. 76 | 77 | Resolves presets and returns a dictionary containing: 78 | 79 | .. code-block:: bash 80 | 81 | { 82 | "client_name": { 83 | "detector": ("detector_name", detector_opts), 84 | "updater": [ 85 | ("updater_name", updater_opts), 86 | ... 87 | ] 88 | }, 89 | ... 90 | } 91 | 92 | :param cfg: ConfigParser 93 | """ 94 | collected_configs = {} 95 | _updater_str = "updater" 96 | _detector_str = "detector" 97 | _dash = "-" 98 | for client_name, client_cfg_dict in _iraw_client_configs(cfg): 99 | detector_name = None 100 | detector_options = {} 101 | updater_name = None 102 | updater_options = {} 103 | collected_config = {} 104 | for k in client_cfg_dict: 105 | if k.startswith(_detector_str + _dash): 106 | detector_options[ 107 | k.replace(_detector_str + _dash, "")] = client_cfg_dict[k] 108 | elif k == _updater_str: 109 | updater_name = client_cfg_dict.get(k) 110 | elif k == _detector_str: 111 | detector_name = client_cfg_dict.get(k) 112 | elif k.startswith(_updater_str + _dash): 113 | updater_options[ 114 | k.replace(_updater_str + _dash, "")] = client_cfg_dict[k] 115 | else: 116 | # options passed "as is" to the dyndnsc client 117 | collected_config[k] = client_cfg_dict[k] 118 | 119 | collected_config[_detector_str] = [(detector_name, detector_options)] 120 | collected_config[_updater_str] = [(updater_name, updater_options)] 121 | 122 | collected_configs[client_name] = collected_config 123 | return collected_configs 124 | -------------------------------------------------------------------------------- /dyndnsc/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing dyndnsc core logic.""" 4 | 5 | import logging 6 | from logging import NullHandler 7 | import time 8 | 9 | 10 | from .plugins.manager import NullPluginManager 11 | from .updater.base import UpdateProtocol 12 | from .updater.manager import get_updater_class 13 | from .detector.dns import IPDetector_DNS 14 | from .detector.null import IPDetector_Null 15 | from .detector.base import IPDetector 16 | from .detector.manager import get_detector_class 17 | 18 | 19 | # Set default logging handler to avoid "No handler found" warnings. 20 | logging.getLogger(__name__).addHandler(NullHandler()) 21 | 22 | LOG = logging.getLogger(__name__) 23 | 24 | 25 | class DynDnsClient(object): 26 | """This class represents a client to the dynamic dns service.""" 27 | 28 | def __init__(self, updater=None, detector=None, plugins=None, detect_interval=300): 29 | """ 30 | Initialize. 31 | 32 | :param detect_interval: amount of time in seconds that can elapse between checks 33 | """ 34 | if updater is None: 35 | raise ValueError("No updater specified") 36 | elif not isinstance(updater, UpdateProtocol): 37 | raise ValueError("updater '%r' is not an instance of '%r'" % (updater, UpdateProtocol)) 38 | else: 39 | self.updater = updater 40 | if detector is None: 41 | LOG.warning("No IP detector specified, falling back to null detector.") 42 | self.detector = IPDetector_Null() 43 | elif not isinstance(detector, IPDetector): 44 | raise ValueError("detector '%r' is not an instance of '%r'" % (detector, IPDetector)) 45 | else: 46 | self.detector = detector 47 | LOG.debug("IP detector uses address family %r", self.detector.af()) 48 | if plugins is None: 49 | self.plugins = NullPluginManager() 50 | else: 51 | self.plugins = plugins 52 | hostname = self.updater.hostname # this is kind of a kludge 53 | self.dns = IPDetector_DNS(hostname=hostname, family=self.detector.af()) 54 | self.ipchangedetection_sleep = int(detect_interval) # check every n seconds if our IP changed 55 | self.forceipchangedetection_sleep = int(detect_interval) * 5 # force check every n seconds if our IP changed 56 | self.lastcheck = None 57 | self.lastforce = None 58 | self.status = 0 59 | LOG.debug("DynDnsClient initializer done") 60 | 61 | def sync(self): 62 | """ 63 | Synchronize the registered IP with the detected IP (if needed). 64 | 65 | This can be expensive, mostly depending on the detector, but also 66 | because updating the dynamic ip in itself is costly. Therefore, this 67 | method should usually only be called on startup or when the state changes. 68 | """ 69 | detected_ip = self.detector.detect() 70 | if detected_ip is None: 71 | LOG.debug("Couldn't detect the current IP using detector %r", self.detector.configuration_key) 72 | # we don't have a value to set it to, so don't update! Still shouldn't happen though 73 | elif self.dns.detect() != detected_ip: 74 | LOG.info("%s: dns IP '%s' does not match detected IP '%s', updating", 75 | self.updater.hostname, self.dns.get_current_value(), detected_ip) 76 | self.status = self.updater.update(detected_ip) 77 | self.plugins.after_remote_ip_update(detected_ip, self.status) 78 | else: 79 | self.status = 0 80 | LOG.debug("%s: nothing to do, dns '%s' equals detection '%s'", 81 | self.updater.hostname, 82 | self.dns.get_current_value(), 83 | self.detector.get_current_value()) 84 | 85 | def has_state_changed(self): 86 | """ 87 | Detect changes in offline detector and real DNS value. 88 | 89 | Detect a change either in the offline detector or a 90 | difference between the real DNS value and what the online 91 | detector last got. 92 | This is efficient, since it only generates minimal dns traffic 93 | for online detectors and no traffic at all for offline detectors. 94 | 95 | :rtype: boolean 96 | """ 97 | self.lastcheck = time.time() 98 | # prefer offline state change detection: 99 | if self.detector.can_detect_offline(): 100 | self.detector.detect() 101 | elif not self.dns.detect() == self.detector.get_current_value(): 102 | # The following produces traffic, but probably less traffic 103 | # overall than the detector 104 | self.detector.detect() 105 | 106 | if self.detector.has_changed(): 107 | LOG.debug("detector changed") 108 | return True 109 | elif self.dns.has_changed(): 110 | LOG.debug("dns changed") 111 | return True 112 | 113 | return False 114 | 115 | def needs_check(self): 116 | """ 117 | Check if enough time has elapsed to perform a check(). 118 | 119 | If this time has elapsed, a state change check through 120 | has_state_changed() should be performed and eventually a sync(). 121 | 122 | :rtype: boolean 123 | """ 124 | if self.lastcheck is None: 125 | return True 126 | return time.time() - self.lastcheck >= self.ipchangedetection_sleep 127 | 128 | def needs_sync(self): 129 | """ 130 | Check if enough time has elapsed to perform a sync(). 131 | 132 | A call to sync() should be performed every now and then, no matter what 133 | has_state_changed() says. This is really just a safety thing to enforce 134 | consistency in case the state gets messed up. 135 | 136 | :rtype: boolean 137 | """ 138 | if self.lastforce is None: 139 | self.lastforce = time.time() 140 | return time.time() - self.lastforce >= self.forceipchangedetection_sleep 141 | 142 | def check(self): 143 | """ 144 | Check if the detector changed and call sync() accordingly. 145 | 146 | If the sleep time has elapsed, this method will see if the attached 147 | detector has had a state change and call sync() accordingly. 148 | """ 149 | if self.needs_check(): 150 | if self.has_state_changed(): 151 | LOG.debug("state changed, syncing...") 152 | self.sync() 153 | elif self.needs_sync(): 154 | LOG.debug("forcing sync after %s seconds", 155 | self.forceipchangedetection_sleep) 156 | self.lastforce = time.time() 157 | self.sync() 158 | else: 159 | # nothing to be done 160 | pass 161 | 162 | 163 | def getDynDnsClientForConfig(config, plugins=None): 164 | """Instantiate and return a complete and working dyndns client. 165 | 166 | :param config: a dictionary with configuration keys 167 | :param plugins: an object that implements PluginManager 168 | """ 169 | initparams = {} 170 | if "interval" in config: 171 | initparams["detect_interval"] = config["interval"] 172 | 173 | if plugins is not None: 174 | initparams["plugins"] = plugins 175 | 176 | if "updater" in config: 177 | for updater_name, updater_options in config["updater"]: 178 | initparams["updater"] = get_updater_class(updater_name)(**updater_options) 179 | 180 | # find class and instantiate the detector: 181 | if "detector" in config: 182 | detector_name, detector_opts = config["detector"][-1] 183 | try: 184 | klass = get_detector_class(detector_name) 185 | except KeyError as exc: 186 | LOG.warning("Invalid change detector configuration: '%s'", 187 | detector_name, exc_info=exc) 188 | return None 189 | thedetector = klass(**detector_opts) 190 | initparams["detector"] = thedetector 191 | 192 | return DynDnsClient(**initparams) 193 | -------------------------------------------------------------------------------- /dyndnsc/detector/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package for dyndns detectors.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/detector/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing shared code for all detectors.""" 4 | 5 | import logging 6 | from socket import AF_INET, AF_INET6, AF_UNSPEC 7 | 8 | from ..common.subject import Subject 9 | from ..common.dynamiccli import DynamicCliMixin 10 | 11 | LOG = logging.getLogger(__name__) 12 | 13 | 14 | class IPDetector(Subject, DynamicCliMixin): 15 | """ 16 | Base class for IP detectors. 17 | 18 | When implementing a new detector, it is usually best to just inherit 19 | from this class first. 20 | """ 21 | 22 | def __init__(self, *args, **kwargs): 23 | """ 24 | Initialize detector. 25 | 26 | Since we want to support ipv4 and ipv6 in a concise manner, we make it 27 | a feature of the base class to handle these options. 28 | """ 29 | super(IPDetector, self).__init__() 30 | 31 | self.opts_family = kwargs.get("family") 32 | # ensure address family is understood: 33 | af_ok = {None: AF_UNSPEC, "INET": AF_INET, "INET6": AF_INET6, 34 | AF_UNSPEC: AF_UNSPEC, AF_INET: AF_INET, AF_INET6: AF_INET6} 35 | if self.opts_family not in af_ok: 36 | raise ValueError("IPDetector(): Unsupported address family '%s' specified, please use one of %r" % 37 | (self.opts_family, af_ok.keys())) 38 | else: 39 | self.opts_family = af_ok[self.opts_family] 40 | 41 | def can_detect_offline(self): 42 | """ 43 | Must be overwritten in subclass. 44 | 45 | Return True if the IP detection does not generate any network traffic. 46 | """ 47 | raise NotImplementedError("Abstract method, must be overridden") 48 | 49 | def af(self): 50 | """ 51 | Return the address family detected by this detector. 52 | 53 | Might be overwritten in subclass. 54 | """ 55 | return self.opts_family 56 | 57 | def get_old_value(self): 58 | """Return the detected IP in the previous run (if any).""" 59 | try: 60 | return self._oldvalue 61 | except AttributeError: 62 | return self.get_current_value() 63 | 64 | def set_old_value(self, value): 65 | """Set the previously detected IP.""" 66 | self._oldvalue = value 67 | 68 | def get_current_value(self, default=None): 69 | """Return the detected IP in the current run (if any).""" 70 | try: 71 | return self._currentvalue 72 | except AttributeError: 73 | return default 74 | 75 | def set_current_value(self, value): 76 | """Set the detected IP in the current run (if any).""" 77 | self._oldvalue = self.get_current_value() 78 | self._currentvalue = value 79 | if self._oldvalue != value: 80 | # self.notify_observers("new_ip_detected", {"ip": value}) 81 | LOG.debug("%s.set_current_value(%s)", self.__class__.__name__, value) 82 | return value 83 | 84 | def has_changed(self): 85 | """Detect difference between old and current value.""" 86 | return self.get_old_value() != self.get_current_value() 87 | 88 | @staticmethod 89 | def configuration_key_prefix(): 90 | """Return "detector".""" 91 | return "detector" 92 | -------------------------------------------------------------------------------- /dyndnsc/detector/builtin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | All built-in detector plugins are listed here and will be dynamically imported. 5 | 6 | If importing a plugin fails, it will be ignored. 7 | """ 8 | 9 | from ..common.load import load_class as _load_plugin 10 | 11 | _BUILTINS = ( 12 | ("dyndnsc.detector.command", "IPDetector_Command"), 13 | ("dyndnsc.detector.dns", "IPDetector_DNS"), 14 | ("dyndnsc.detector.dnswanip", "IPDetector_DnsWanIp"), 15 | ("dyndnsc.detector.iface", "IPDetector_Iface"), 16 | ("dyndnsc.detector.socket_ip", "IPDetector_Socket"), 17 | ("dyndnsc.detector.rand", "IPDetector_Random"), 18 | ("dyndnsc.detector.teredo", "IPDetector_Teredo"), 19 | ("dyndnsc.detector.webcheck", "IPDetectorWebCheck"), 20 | ("dyndnsc.detector.webcheck", "IPDetectorWebCheck6"), 21 | ("dyndnsc.detector.webcheck", "IPDetectorWebCheck46"), 22 | ("dyndnsc.detector.null", "IPDetector_Null") 23 | ) 24 | 25 | PLUGINS = {plug for plug in (_load_plugin(m, c) for m, c in _BUILTINS) if plug is not None} 26 | -------------------------------------------------------------------------------- /dyndnsc/detector/command.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for command based detectors.""" 4 | 5 | from .base import IPDetector 6 | 7 | 8 | class IPDetector_Command(IPDetector): 9 | """IPDetector to detect IP address executing shell command/script.""" 10 | 11 | configuration_key = "command" 12 | 13 | def __init__(self, command="", *args, **kwargs): 14 | """ 15 | Initialize. 16 | 17 | :param command: string shell command that writes IP address to STDOUT 18 | """ 19 | super(IPDetector_Command, self).__init__(*args, **kwargs) 20 | 21 | self.opts_command = command 22 | 23 | def can_detect_offline(self): 24 | """Return false, as this detector possibly generates network traffic. 25 | 26 | :return: False 27 | """ 28 | return False 29 | 30 | def setHostname(self, hostname): 31 | """Set the hostname.""" 32 | self.hostname = hostname 33 | 34 | def detect(self): 35 | """Detect and return the IP address.""" 36 | import subprocess # noqa: S404 @UnresolvedImport pylint: disable=import-error 37 | try: 38 | theip = subprocess.getoutput(self.opts_command) # noqa: S605 39 | except Exception: 40 | theip = None 41 | self.set_current_value(theip) 42 | return theip 43 | -------------------------------------------------------------------------------- /dyndnsc/detector/dns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for dns based detectors.""" 4 | 5 | import socket 6 | import logging 7 | 8 | from .base import IPDetector, AF_INET, AF_INET6, AF_UNSPEC 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | def resolve(hostname, family=AF_UNSPEC): 14 | """ 15 | Resolve hostname to one or more IP addresses through the operating system. 16 | 17 | Resolution is carried out for the given address family. If no 18 | address family is specified, only IPv4 and IPv6 addresses are returned. If 19 | multiple IP addresses are found, all are returned. 20 | 21 | :param family: AF_INET or AF_INET6 or AF_UNSPEC (default) 22 | :return: tuple of unique IP addresses 23 | """ 24 | af_ok = (AF_INET, AF_INET6) 25 | if family != AF_UNSPEC and family not in af_ok: 26 | raise ValueError("Invalid family '%s'" % family) 27 | ips = () 28 | try: 29 | addrinfo = socket.getaddrinfo(hostname, None, family) 30 | except socket.gaierror as exc: 31 | # EAI_NODATA and EAI_NONAME are expected if this name is not (yet) 32 | # present in DNS 33 | if exc.errno not in (socket.EAI_NODATA, socket.EAI_NONAME): 34 | LOG.debug("socket.getaddrinfo() raised an exception", exc_info=exc) 35 | else: 36 | if family == AF_UNSPEC: 37 | ips = tuple({item[4][0] for item in addrinfo if item[0] in af_ok}) 38 | else: 39 | ips = tuple({item[4][0] for item in addrinfo}) 40 | return ips 41 | 42 | 43 | class IPDetector_DNS(IPDetector): 44 | """Class to resolve a hostname using socket.getaddrinfo().""" 45 | 46 | configuration_key = "dns" 47 | 48 | def __init__(self, hostname=None, family=None, *args, **kwargs): 49 | """ 50 | Initialize. 51 | 52 | :param hostname: host name to query from DNS 53 | :param family: IP address family (default: '' (ANY), also possible: 'INET', 'INET6') 54 | """ 55 | super(IPDetector_DNS, self).__init__(*args, family=family, **kwargs) 56 | 57 | self.opts_hostname = hostname 58 | 59 | if self.opts_hostname is None: 60 | raise ValueError( 61 | "IPDetector_DNS(): a hostname to be queried in DNS must be specified!") 62 | 63 | def can_detect_offline(self): 64 | """Return false, as this detector generates dns traffic. 65 | 66 | :return: False 67 | """ 68 | return False 69 | 70 | def detect(self): 71 | """ 72 | Resolve the hostname to an IP address through the operating system. 73 | 74 | Depending on the 'family' option, either ipv4 or ipv6 resolution is 75 | carried out. 76 | 77 | If multiple IP addresses are found, the first one is returned. 78 | 79 | :return: ip address 80 | """ 81 | theip = next(iter(resolve(self.opts_hostname, self.opts_family)), None) 82 | self.set_current_value(theip) 83 | return theip 84 | -------------------------------------------------------------------------------- /dyndnsc/detector/dnswanip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for DNS WAN IP detection. 4 | 5 | See also https://www.cyberciti.biz/faq/how-to-find-my-public-ip-address-from-command-line-on-a-linux/ 6 | """ 7 | from __future__ import absolute_import 8 | 9 | import logging 10 | 11 | import dns.resolver 12 | 13 | from .base import IPDetector, AF_INET, AF_INET6 14 | 15 | LOG = logging.getLogger(__name__) 16 | 17 | 18 | def find_ip(family=AF_INET, provider="opendns"): 19 | """Find the publicly visible IP address of the current system. 20 | 21 | This uses public DNS infrastructure that implement a special DNS "hack" to 22 | return the IP address of the requester rather than some other address. 23 | 24 | :param family: address family, optional, default AF_INET (ipv4) 25 | :param flavour: selector for public infrastructure provider, optional 26 | """ 27 | dnswanipproviders = { 28 | "opendns": { 29 | AF_INET: { 30 | "@": ("resolver1.opendns.com", "resolver2.opendns.com"), 31 | "qname": "myip.opendns.com", 32 | "rdtype": "A", 33 | }, 34 | AF_INET6: { 35 | "@": ("resolver1.ipv6-sandbox.opendns.com", "resolver2.ipv6-sandbox.opendns.com"), 36 | "qname": "myip.opendns.com", 37 | "rdtype": "AAAA", 38 | }, 39 | }, 40 | } 41 | 42 | dnswanipprovider = dnswanipproviders[provider] # only option as of now 43 | 44 | resolver = dns.resolver.Resolver() 45 | # first, get the IPs of the DNS servers: 46 | nameservers = [] 47 | for dnsservername in dnswanipprovider[family]["@"]: 48 | _answers = resolver.query(qname=dnsservername, rdtype=dnswanipprovider[family]["rdtype"]) 49 | nameservers.extend([rdata.address for rdata in _answers]) 50 | # specify the nameservers to be used: 51 | resolver.nameservers = nameservers 52 | # finally, attempt to discover our WAN IP by querying the DNS: 53 | answers = resolver.query(qname=dnswanipprovider[family]["qname"], rdtype=dnswanipprovider[family]["rdtype"]) 54 | for rdata in answers: 55 | return rdata.address 56 | return None 57 | 58 | 59 | class IPDetector_DnsWanIp(IPDetector): 60 | """Detect the internet visible IP address using publicly available DNS infrastructure.""" 61 | 62 | configuration_key = "dnswanip" 63 | 64 | def __init__(self, family=None, *args, **kwargs): 65 | """ 66 | Initialize. 67 | 68 | :param family: IP address family (default: '' (ANY), also possible: 'INET', 'INET6') 69 | """ 70 | if family is None: 71 | family = AF_INET 72 | super(IPDetector_DnsWanIp, self).__init__(*args, family=family, **kwargs) 73 | 74 | def can_detect_offline(self): 75 | """Return false, as this detector generates dns traffic. 76 | 77 | :return: False 78 | """ 79 | return False 80 | 81 | def detect(self): 82 | """ 83 | Detect the WAN IP of the current process through DNS. 84 | 85 | Depending on the 'family' option, either ipv4 or ipv6 resolution is 86 | carried out. 87 | 88 | :return: ip address 89 | """ 90 | theip = find_ip(family=self.opts_family) 91 | self.set_current_value(theip) 92 | return theip 93 | -------------------------------------------------------------------------------- /dyndnsc/detector/iface.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module providing IP detection functionality based on netifaces.""" 4 | 5 | import logging 6 | 7 | import netifaces 8 | 9 | from .base import IPDetector, AF_INET6 10 | from ..common.six import ipaddress, ipnetwork 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | 15 | def _default_interface(): 16 | """Return the default interface name for common operating systems.""" 17 | import platform 18 | system = platform.system() 19 | if system == "Linux": 20 | return "eth0" 21 | elif system == "Darwin": 22 | return "en0" 23 | return None 24 | 25 | 26 | class IPDetector_Iface(IPDetector): 27 | """ 28 | IPDetector to detect an IP address assigned to a local interface. 29 | 30 | This is roughly equivalent to using `ifconfig` or `ipconfig`. 31 | """ 32 | 33 | configuration_key = "iface" 34 | 35 | def __init__(self, iface=None, netmask=None, family=None, *args, **kwargs): 36 | """ 37 | Initialize. 38 | 39 | :param iface: name of interface 40 | :param family: IP address family (default: INET, possible: INET6) 41 | :param netmask: netmask to be matched if multiple IPs on interface 42 | (default: none (match all)", example for teredo: 43 | "2001:0000::/32") 44 | """ 45 | super(IPDetector_Iface, self).__init__(*args, family=family, **kwargs) 46 | 47 | self.opts_iface = iface if iface else _default_interface() 48 | self.opts_netmask = netmask 49 | 50 | # ensure an interface name was specified: 51 | if self.opts_iface is None: 52 | raise ValueError("No network interface specified!") 53 | # parse/validate given netmask: 54 | if self.opts_netmask is not None: # if a netmask was given 55 | # This might fail here, but that's OK since we must avoid sending 56 | # an IP to the outside world that should be hidden (because in a 57 | # "private" netmask) 58 | self.netmask = ipnetwork(self.opts_netmask) 59 | else: 60 | self.netmask = None 61 | 62 | def can_detect_offline(self): 63 | """Return true, as this detector only queries local data.""" 64 | return True 65 | 66 | def _detect(self): 67 | """Use the netifaces module to detect ifconfig information.""" 68 | theip = None 69 | try: 70 | if self.opts_family == AF_INET6: 71 | addrlist = netifaces.ifaddresses(self.opts_iface)[netifaces.AF_INET6] 72 | else: 73 | addrlist = netifaces.ifaddresses(self.opts_iface)[netifaces.AF_INET] 74 | except ValueError as exc: 75 | LOG.error("netifaces choked while trying to get network interface" 76 | " information for interface '%s'", self.opts_iface, 77 | exc_info=exc) 78 | else: # now we have a list of addresses as returned by netifaces 79 | for pair in addrlist: 80 | try: 81 | detip = ipaddress(pair["addr"]) 82 | except (TypeError, ValueError) as exc: 83 | LOG.debug("Found invalid IP '%s' on interface '%s'!?", 84 | pair["addr"], self.opts_iface, exc_info=exc) 85 | continue 86 | if self.netmask is not None: 87 | if detip in self.netmask: 88 | theip = pair["addr"] 89 | else: 90 | continue 91 | else: 92 | theip = pair["addr"] 93 | break # we use the first IP found 94 | # theip can still be None at this point! 95 | self.set_current_value(theip) 96 | return theip 97 | 98 | def detect(self): 99 | """Detect the IP address and return it.""" 100 | return self._detect() 101 | -------------------------------------------------------------------------------- /dyndnsc/detector/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Management of detectors.""" 4 | 5 | from ..common.load import find_class 6 | 7 | 8 | def detector_classes(): 9 | """Return all built-in detector classes.""" 10 | from .builtin import PLUGINS 11 | return PLUGINS 12 | 13 | 14 | def get_detector_class(name="webcheck4"): 15 | """Return detector class identified by configuration key ``name``.""" 16 | return find_class(name, detector_classes()) 17 | -------------------------------------------------------------------------------- /dyndnsc/detector/null.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for null detector.""" 4 | 5 | import logging 6 | 7 | from .base import IPDetector 8 | 9 | LOG = logging.getLogger(__name__) 10 | 11 | 12 | class IPDetector_Null(IPDetector): 13 | """Dummy IP detector.""" 14 | 15 | configuration_key = "null" 16 | 17 | def __init__(self, family=None, *args, **kwargs): 18 | """ 19 | Initialize. 20 | 21 | :param family: IP address family (default: '' (ANY), also possible: 'INET', 'INET6') 22 | """ 23 | super(IPDetector_Null, self).__init__(*args, family=family, **kwargs) 24 | 25 | def can_detect_offline(self): 26 | """Return true, as this detector generates no network traffic. 27 | 28 | :return: True 29 | """ 30 | return True 31 | 32 | def detect(self): 33 | """ 34 | Return None. 35 | 36 | :rtype: None 37 | """ 38 | return None 39 | -------------------------------------------------------------------------------- /dyndnsc/detector/rand.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for random IP based detectors.""" 4 | 5 | import logging 6 | from random import randint 7 | 8 | from .base import IPDetector, AF_INET 9 | from ..common.six import ipaddress, ipnetwork 10 | 11 | LOG = logging.getLogger(__name__) 12 | 13 | 14 | def random_ip(): 15 | """Return a randomly generated IPv4 address. 16 | 17 | :return: ip address 18 | """ 19 | return ipaddress( 20 | "%i.%i.%i.%i" % ( 21 | randint(1, 254), randint(1, 254), randint(1, 254), randint(1, 254) # noqa: S311 22 | ) 23 | ) 24 | 25 | 26 | class RandomIPGenerator(object): 27 | """The random IP generator.""" 28 | 29 | def __init__(self, num=None): 30 | """Initialize.""" 31 | self._max = num 32 | 33 | # Reserved list from http://www.iana.org/assignments/ipv4-address-space 34 | # (dated 2010-02-22) 35 | self._reserved_netmasks = frozenset([ 36 | "0.0.0.0/8", 37 | "5.0.0.0/8", 38 | "10.0.0.0/8", 39 | "23.0.0.0/8", 40 | "31.0.0.0/8", 41 | "36.0.0.0/8", 42 | "39.0.0.0/8", 43 | "42.0.0.0/8", 44 | "127.0.0.0/8", 45 | "169.254.0.0/16", 46 | "172.16.0.0/12", 47 | "192.168.0.0/16", 48 | "224.0.0.0/3", 49 | "240.0.0.0/8" 50 | ]) 51 | 52 | def is_reserved_ip(self, ip): 53 | """Check if the given ip address is in a reserved ipv4 address space. 54 | 55 | :param ip: ip address 56 | :return: boolean 57 | """ 58 | theip = ipaddress(ip) 59 | for res in self._reserved_netmasks: 60 | if theip in ipnetwork(res): 61 | return True 62 | return False 63 | 64 | def random_public_ip(self): 65 | """Return a randomly generated, public IPv4 address. 66 | 67 | :return: ip address 68 | """ 69 | randomip = random_ip() 70 | while self.is_reserved_ip(randomip): 71 | randomip = random_ip() 72 | return randomip 73 | 74 | def __iter__(self): 75 | """Iterate over this instance..""" 76 | count = 0 77 | while self._max is None or count < self._max: 78 | yield self.random_public_ip() 79 | count += 1 80 | 81 | 82 | class IPDetector_Random(IPDetector): 83 | """Detect randomly generated IP addresses.""" 84 | 85 | configuration_key = "random" 86 | 87 | def __init__(self, *args, **kwargs): 88 | """Initialize.""" 89 | super(IPDetector_Random, self).__init__(*args, **kwargs) 90 | 91 | self.opts_family = AF_INET 92 | self.rips = RandomIPGenerator() 93 | 94 | def can_detect_offline(self): 95 | """ 96 | Detect the IP address. 97 | 98 | :return: True 99 | """ 100 | return True 101 | 102 | def detect(self): 103 | """Detect IP and return it.""" 104 | for theip in self.rips: 105 | LOG.debug("detected %s", str(theip)) 106 | self.set_current_value(str(theip)) 107 | return str(theip) 108 | -------------------------------------------------------------------------------- /dyndnsc/detector/socket_ip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for socket based detectors.""" 4 | 5 | import logging 6 | 7 | from .base import IPDetector, AF_INET6 8 | from ..common.detect_ip import detect_ip, IPV4, IPV6_PUBLIC, GetIpException 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class IPDetector_Socket(IPDetector): 14 | """Detect IPs used by the system to communicate with outside world.""" 15 | 16 | configuration_key = "socket" 17 | 18 | def __init__(self, family=None, *args, **kwargs): 19 | """ 20 | Initialize. 21 | 22 | :param family: IP address family (default: INET, possible: INET6) 23 | """ 24 | super(IPDetector_Socket, self).__init__(*args, family=family, **kwargs) 25 | 26 | def can_detect_offline(self): 27 | """Return False, this detector works offline.""" 28 | # unsure about this. detector does not really transmit data to outside, 29 | # but unsure if it gives the wanted IPs if system is offline 30 | return False 31 | 32 | def detect(self): 33 | """Detect the IP address.""" 34 | if self.opts_family == AF_INET6: 35 | kind = IPV6_PUBLIC 36 | else: # 'INET': 37 | kind = IPV4 38 | theip = None 39 | try: 40 | theip = detect_ip(kind) 41 | except GetIpException: 42 | LOG.exception("socket detector raised an exception:") 43 | self.set_current_value(theip) 44 | return theip 45 | -------------------------------------------------------------------------------- /dyndnsc/detector/teredo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for teredo based detectors.""" 4 | 5 | import logging 6 | 7 | from .iface import IPDetector_Iface, AF_INET6 8 | 9 | LOG = logging.getLogger(__name__) 10 | 11 | 12 | class IPDetector_Teredo(IPDetector_Iface): 13 | """IPDetector to detect a Teredo ipv6 address of a local interface. 14 | 15 | Bits 0 to 31 of the ipv6 address are set to the Teredo prefix (normally 16 | 2001:0000::/32). 17 | This detector only checks the first 16 bits! 18 | See http://en.wikipedia.org/wiki/Teredo_tunneling for more information on 19 | Teredo. 20 | 21 | Inherits IPDetector_Iface and sets default options only. 22 | """ 23 | 24 | configuration_key = "teredo" 25 | 26 | def __init__(self, iface="tun0", netmask="2001:0000::/32", *args, **kwargs): 27 | """Initialize.""" 28 | super(IPDetector_Teredo, self).__init__(*args, **kwargs) 29 | 30 | self.opts_iface = iface 31 | self.opts_netmask = netmask 32 | self.opts_family = AF_INET6 33 | -------------------------------------------------------------------------------- /dyndnsc/detector/webcheck.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing logic for webcheck based detectors.""" 4 | 5 | import logging 6 | from random import choice 7 | import re 8 | 9 | import requests 10 | 11 | from .base import IPDetector, AF_INET, AF_INET6, AF_UNSPEC 12 | from ..common.six import ipaddress 13 | from ..common import constants 14 | 15 | LOG = logging.getLogger(__name__) 16 | 17 | 18 | def _get_ip_from_url(url, parser, timeout=10): 19 | LOG.debug("Querying IP address from '%s'", url) 20 | try: 21 | req = requests.get(url, headers=constants.REQUEST_HEADERS_DEFAULT, timeout=timeout) 22 | except requests.exceptions.RequestException as exc: 23 | LOG.debug("webcheck failed for url '%s'", url, exc_info=exc) 24 | return None 25 | else: 26 | if req.status_code == 200: 27 | return parser(req.text) 28 | else: 29 | LOG.debug("Wrong http status code for '%s': %i", url, req.status_code) 30 | return None 31 | 32 | 33 | def _parser_plain(text): 34 | try: 35 | return str(ipaddress(text.strip())) 36 | except ValueError as exc: 37 | LOG.warning("Error parsing IP address '%s':", text, exc_info=exc) 38 | return None 39 | 40 | 41 | def _parser_line_regex(text, pattern="Current IP Address: (.*?)(<.*){0,1}$"): 42 | regex = re.compile(pattern) 43 | for line in text.splitlines(): 44 | match_obj = regex.search(line) 45 | if match_obj is not None: 46 | return str(ipaddress(match_obj.group(1))) 47 | LOG.debug("Output '%s' could not be parsed", text) 48 | return None 49 | 50 | 51 | def _parser_checkip_dns_he_net(text): 52 | return _parser_line_regex(text, pattern="Your IP address is : (.*?)(<.*){0,1}$") 53 | 54 | 55 | def _parser_checkip(text): 56 | return _parser_line_regex(text, pattern="Current IP Address: (.*?)(<.*){0,1}$") 57 | 58 | 59 | def _parser_freedns_afraid(text): 60 | return _parser_line_regex(text, pattern="Detected IP : (.*?)(<.*){0,1}$") 61 | 62 | 63 | def _parser_jsonip(text): 64 | """Parse response text like the one returned by http://jsonip.com/.""" 65 | import json 66 | try: 67 | return str(json.loads(text).get("ip")) 68 | except ValueError as exc: 69 | LOG.debug("Text '%s' could not be parsed", exc_info=exc) 70 | return None 71 | 72 | 73 | class IPDetectorWebCheckBase(IPDetector): 74 | """Base Class for misc. web service based IP detection classes.""" 75 | 76 | urls = None # override in child class 77 | configuration_key = None 78 | 79 | def __init__(self, url=None, parser=None, *args, **kwargs): 80 | """ 81 | Initialize. 82 | 83 | :param url: URL to fetch and parse for IP detection 84 | :param parser: parser to use for above URL 85 | """ 86 | super(IPDetectorWebCheckBase, self).__init__(*args, **kwargs) 87 | 88 | self.opts_url = url 89 | self.opts_parser = parser 90 | 91 | def can_detect_offline(self): 92 | """Return false, as this detector generates http traffic.""" 93 | return False 94 | 95 | def detect(self): 96 | """ 97 | Try to contact a remote webservice and parse the returned output. 98 | 99 | Determine the IP address from the parsed output and return. 100 | """ 101 | if self.opts_url and self.opts_parser: 102 | url = self.opts_url 103 | parser = self.opts_parser 104 | else: 105 | url, parser = choice(self.urls) # noqa: S311 106 | parser = globals().get("_parser_" + parser) 107 | theip = _get_ip_from_url(url, parser) 108 | if theip is None: 109 | LOG.info("Could not detect IP using webcheck! Offline?") 110 | self.set_current_value(theip) 111 | return theip 112 | 113 | 114 | class IPDetectorWebCheck(IPDetectorWebCheckBase): 115 | """ 116 | Class to detect an IPv4 address as seen by an online web site. 117 | 118 | Return parsable output containing the IP address. 119 | 120 | .. note:: 121 | This detection mechanism requires ipv4 connectivity, otherwise it 122 | will simply not detect the IP address. 123 | """ 124 | 125 | configuration_key = "webcheck4" 126 | 127 | # TODO: consider throwing out all URLs with no TLS support 128 | urls = ( 129 | ("http://checkip.eurodyndns.org/", "checkip"), 130 | ("http://ip.dnsexit.com/", "plain"), 131 | ("http://checkip.dns.he.net/", "checkip_dns_he_net"), 132 | ("http://ip1.dynupdate.no-ip.com/", "plain"), 133 | ("http://ip2.dynupdate.no-ip.com/", "plain"), 134 | ("https://api.ipify.org/", "plain"), 135 | ("https://dynamic.zoneedit.com/checkip.html", "plain"), 136 | ("https://freedns.afraid.org/dynamic/check.php", "freedns_afraid"), 137 | ("https://ifconfig.co/ip", "plain"), 138 | ("https://ipinfo.io/ip", "plain"), 139 | ("https://ipv4.icanhazip.com/", "plain"), 140 | ("https://ipv4.nsupdate.info/myip", "plain"), 141 | ("https://jsonip.com/", "jsonip"), 142 | ) 143 | 144 | def __init__(self, *args, **kwargs): 145 | """Initialize.""" 146 | super(IPDetectorWebCheck, self).__init__(*args, **kwargs) 147 | 148 | self.opts_family = AF_INET 149 | 150 | 151 | class IPDetectorWebCheck6(IPDetectorWebCheckBase): 152 | """ 153 | Class to detect an IPv6 address as seen by an online web site. 154 | 155 | Return parsable output containing the IP address. 156 | 157 | Note: this detection mechanism requires ipv6 connectivity, otherwise it 158 | will simply not detect the IP address. 159 | """ 160 | 161 | configuration_key = "webcheck6" 162 | 163 | urls = ( 164 | ("https://ipv6.icanhazip.com/", "plain"), 165 | ("https://ipv6.nsupdate.info/myip", "plain"), 166 | ("https://v6.ident.me", "plain"), 167 | ) 168 | 169 | def __init__(self, *args, **kwargs): 170 | """Initialize.""" 171 | super(IPDetectorWebCheck6, self).__init__(*args, **kwargs) 172 | 173 | self.opts_family = AF_INET6 174 | 175 | 176 | class IPDetectorWebCheck46(IPDetectorWebCheckBase): 177 | """ 178 | Class to variably detect either an IPv4 xor IPv6 address. 179 | 180 | (as seen by an online web site). 181 | 182 | Returns parsable output containing the IP address. 183 | 184 | Note: this detection mechanism works with both ipv4 as well as ipv6 185 | connectivity, however it should be noted that most dns resolvers 186 | implement negative caching: 187 | 188 | Alternating a DNS hostname between A and AAAA records is less efficient 189 | than staying within the same RR-Type. This is due to the fact that most 190 | libc-implementations do both lookups when getaddrinf() is called and 191 | therefore negative caching occurs (e.g. caching that a record does not 192 | exist). 193 | 194 | This also means that alternating only works well if the zone's SOA 195 | record has a minimum TTL close to the record TTL, which in turn means 196 | that using alternation should only be done in a dedicated (sub)domain 197 | with its own SOA record and a low TTL. 198 | """ 199 | 200 | configuration_key = "webcheck46" 201 | 202 | urls = ( 203 | ("https://icanhazip.com/", "plain"), 204 | ("https://www.nsupdate.info/myip", "plain"), 205 | ("https://ident.me", "plain"), 206 | ) 207 | 208 | def __init__(self, *args, **kwargs): 209 | """Initialize.""" 210 | super(IPDetectorWebCheck46, self).__init__(*args, **kwargs) 211 | 212 | self.opts_family = AF_UNSPEC 213 | -------------------------------------------------------------------------------- /dyndnsc/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package for dyndns plugins.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/plugins/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module with basic plugin code.""" 4 | 5 | import logging 6 | 7 | 8 | LOG = logging.getLogger(__name__) 9 | 10 | 11 | class IPluginInterface(object): 12 | """IPluginInterface describes the plugin API. 13 | 14 | Do not subclass or use this class directly. 15 | """ 16 | 17 | def __new__(cls, *arg, **kw): 18 | """Private constructor.""" 19 | raise TypeError("IPluginInterface class cannot be instantiated, it " 20 | "is for documentation and API verification only") 21 | 22 | def options(self, parser, env): 23 | """Register command line options with the argparse parser. 24 | 25 | DO NOT return a value from this method unless you want to stop 26 | all other plugins from setting their options. 27 | 28 | :param parser: options parser instance 29 | :type parser: `argparse.ArgumentParser` 30 | :param env: environment, default is os.environ 31 | """ 32 | pass 33 | 34 | def configure(self, options): 35 | """Call after any user input has been parsed, with the options. 36 | 37 | DO NOT return a value from this method unless you want to 38 | stop all other plugins from being configured. 39 | """ 40 | pass 41 | 42 | def initialize(self): 43 | """Call before any core activities are run. 44 | 45 | Use this to perform any plugin specific setup. 46 | """ 47 | pass 48 | 49 | def after_remote_ip_update(self, ip, status): 50 | """Call after a remote IP update was performed.""" 51 | -------------------------------------------------------------------------------- /dyndnsc/plugins/builtin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Dynamic loading of built-in plugins. 5 | 6 | All built-in plugins are listed here and will be dynamically imported 7 | on importing this module. If importing a plugin fails, it will be ignored. 8 | """ 9 | 10 | from ..common.load import load_class as _load_plugin 11 | 12 | _BUILTINS = () 13 | 14 | PLUGINS = {plug for plug in (_load_plugin(m, c) for m, c in _BUILTINS) if plug is not None} 15 | -------------------------------------------------------------------------------- /dyndnsc/plugins/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module for plugin manager.""" 4 | 5 | import logging 6 | from warnings import warn 7 | 8 | from .base import IPluginInterface 9 | 10 | ENV_PREFIX = "DYNDNSC_WITH_" 11 | LOG = logging.getLogger(__name__) 12 | 13 | 14 | class PluginProxy(object): 15 | """Proxy for plugin calls. 16 | 17 | To verify presence of methods, this proxy is bound to an interface class 18 | providing no implementation. 19 | """ 20 | 21 | interface = IPluginInterface 22 | 23 | def __init__(self, call, plugins): 24 | """Initialize the given plugins.""" 25 | try: 26 | self.method = getattr(self.interface, call) 27 | except AttributeError: 28 | raise AttributeError("%s is not a valid %s method" 29 | % (call, self.interface.__name__)) 30 | self.plugins = [] 31 | for plugin in plugins: 32 | self.add_plugin(plugin, call) 33 | 34 | def __call__(self, *arg, **kw): 35 | """Implement callable interface by calling every plugin sequentally.""" 36 | return self.listcall(*arg, **kw) 37 | 38 | def add_plugin(self, plugin, call): 39 | """Add plugin to list of plugins. 40 | 41 | Will be added if it has the attribute I'm bound to. 42 | """ 43 | meth = getattr(plugin, call, None) 44 | if meth is not None: 45 | self.plugins.append((plugin, meth)) 46 | 47 | def listcall(self, *arg, **kw): 48 | """Call each plugin sequentially. 49 | 50 | Return the first result that is not None. 51 | """ 52 | final_result = None 53 | for _, meth in self.plugins: 54 | result = meth(*arg, **kw) 55 | if final_result is None and result is not None: 56 | final_result = result 57 | return final_result 58 | 59 | 60 | class NullPluginManager(object): 61 | """Plugin manager that has no plugins. 62 | 63 | Used as a NOP when no plugins are used 64 | """ 65 | 66 | interface = IPluginInterface 67 | 68 | def __iter__(self): 69 | """Return an empty iterator.""" 70 | return iter(()) 71 | 72 | def __getattr__(self, call): 73 | """Return a dummy function that does nothing regardless of specified args.""" 74 | return self._nop 75 | 76 | def _nop(self, *args, **kwds): 77 | pass 78 | 79 | def add_plugin(self, plug): 80 | """Fake add plugin to list of plugins.""" 81 | raise NotImplementedError() 82 | 83 | def add_plugins(self, plugins): 84 | """Fake add plugins to list of plugins.""" 85 | raise NotImplementedError() 86 | 87 | def configure(self, options): 88 | """Fake configure plugins.""" 89 | pass 90 | 91 | def load_plugins(self): 92 | """Fake load plugins.""" 93 | pass 94 | 95 | 96 | class PluginManager(object): 97 | """Base class PluginManager is not intended to be used directly. 98 | 99 | The basic functionality of a plugin manager is to proxy all unknown 100 | attributes through a ``PluginProxy`` to a list of plugins. 101 | 102 | The list of plugins *must not* be changed after the first call to a plugin. 103 | """ 104 | 105 | proxyClass = PluginProxy 106 | 107 | def __init__(self, plugins=(), proxyClass=None): 108 | """Initialize.""" 109 | self._plugins = [] 110 | self._proxies = {} 111 | if plugins: 112 | self.add_plugins(plugins) 113 | if proxyClass is not None: 114 | self.proxyClass = proxyClass 115 | 116 | def __getattr__(self, call): 117 | """Return proxy method for all plugins for call.""" 118 | try: 119 | return self._proxies[call] 120 | except KeyError: 121 | proxy = self.proxyClass(call, self._plugins) 122 | self._proxies[call] = proxy 123 | return proxy 124 | 125 | def __iter__(self): 126 | """Return an iterator over all registered plugins.""" 127 | return iter(self.plugins) 128 | 129 | def add_plugin(self, plugin): 130 | """Add the given plugin.""" 131 | # allow plugins loaded via entry points to override builtin plugins 132 | new_name = self.plugin_name(plugin) 133 | self._plugins[:] = [p for p in self._plugins 134 | if self.plugin_name(p) != new_name] 135 | self._plugins.append(plugin) 136 | 137 | @staticmethod 138 | def plugin_name(plugin): 139 | """Discover the plugin name and return it.""" 140 | return plugin.__class__.__name__.lower() 141 | 142 | def add_plugins(self, plugins=()): 143 | """Add the given plugins.""" 144 | for plugin in plugins: 145 | self.add_plugin(plugin) 146 | 147 | def configure(self, args): 148 | """Configure the set of plugins with the given args. 149 | 150 | After configuration, disabled plugins are removed from the plugins list. 151 | """ 152 | for plug in self._plugins: 153 | plug_name = self.plugin_name(plug) 154 | plug.enabled = getattr(args, "plugin_%s" % plug_name, False) 155 | if plug.enabled and getattr(plug, "configure", None): 156 | if callable(getattr(plug, "configure", None)): 157 | plug.configure(args) 158 | LOG.debug("Available plugins: %s", self._plugins) 159 | self.plugins = [plugin for plugin in self._plugins if getattr(plugin, "enabled", False)] 160 | LOG.debug("Enabled plugins: %s", self.plugins) 161 | 162 | def options(self, parser, env): 163 | """Register commandline options with the given parser. 164 | 165 | Implement this method for normal options behavior with protection from 166 | OptionConflictErrors. If you override this method and want the default 167 | --with-$name option to be registered, be sure to call super(). 168 | 169 | :param parser: argparse parser object 170 | :param env: 171 | """ 172 | def get_help(plug): 173 | """Extract the help docstring from the given plugin.""" 174 | import textwrap 175 | if plug.__class__.__doc__: 176 | # doc sections are often indented; compress the spaces 177 | return textwrap.dedent(plug.__class__.__doc__) 178 | return "(no help available)" 179 | for plug in self._plugins: 180 | env_opt = ENV_PREFIX + self.plugin_name(plug).upper() 181 | env_opt = env_opt.replace("-", "_") 182 | parser.add_argument("--with-%s" % self.plugin_name(plug), 183 | action="store_true", 184 | dest="plugin_%s" % self.plugin_name(plug), 185 | default=env.get(env_opt), 186 | help="Enable plugin %s: %s [%s]" % 187 | (plug.__class__.__name__, get_help(plug), env_opt)) 188 | 189 | def load_plugins(self): 190 | """Abstract method.""" 191 | pass 192 | 193 | def _get_plugins(self): 194 | return self._plugins 195 | 196 | def _set_plugins(self, plugins): 197 | self._plugins = [] 198 | self.add_plugins(plugins) 199 | 200 | plugins = property( 201 | _get_plugins, _set_plugins, None, """Access the list of plugins""") 202 | 203 | 204 | class EntryPointPluginManager(PluginManager): 205 | """Plugin manager. 206 | 207 | Load plugins from the setuptools entry_point ``dyndnsc.plugins``. 208 | """ 209 | 210 | entry_points = ("dyndnsc.notifier_beta",) 211 | 212 | def load_plugins(self): 213 | """Load plugins from entry point(s).""" 214 | from pkg_resources import iter_entry_points 215 | seen = set() 216 | for entry_point in self.entry_points: 217 | for ep in iter_entry_points(entry_point): 218 | if ep.name in seen: 219 | continue 220 | seen.add(ep.name) 221 | try: 222 | plugincls = ep.load() 223 | except Exception as exc: 224 | # never let a plugin load kill us 225 | warn("Unable to load plugin %s: %s" % (ep, exc), 226 | RuntimeWarning) 227 | continue 228 | plugin = plugincls() 229 | self.add_plugin(plugin) 230 | super(EntryPointPluginManager, self).load_plugins() 231 | 232 | 233 | class BuiltinPluginManager(PluginManager): 234 | """Plugin manager. 235 | 236 | Load plugins from the list in `dyndnsc.plugins.builtin`. 237 | """ 238 | 239 | def load_plugins(self): 240 | """Load plugins from `dyndnsc.plugins.builtin`.""" 241 | from dyndnsc.plugins.builtin import PLUGINS 242 | for plugin in PLUGINS: 243 | self.add_plugin(plugin()) 244 | super(BuiltinPluginManager, self).load_plugins() 245 | 246 | 247 | try: 248 | import pkg_resources # noqa: @UnusedImport pylint: disable=unused-import 249 | 250 | class DefaultPluginManager(EntryPointPluginManager, BuiltinPluginManager): 251 | """The plugin manager serving both built-in and external plugins.""" 252 | 253 | pass 254 | 255 | except ImportError: 256 | 257 | class DefaultPluginManager(BuiltinPluginManager): 258 | """The plugin manager serving only built-in plugins.""" 259 | 260 | pass 261 | -------------------------------------------------------------------------------- /dyndnsc/plugins/notify/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package for notify plugins.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This package contains resources, non-python files, that we ship. 4 | 5 | For ease of use, we provide this module to access the resources using 6 | symbolic references, rather than by string conventions. 7 | """ 8 | 9 | from pkg_resources import resource_stream as _resstream # @UnresolvedImport 10 | from pkg_resources import resource_string as _resstring # @UnresolvedImport 11 | from pkg_resources import resource_exists as _resexists # @UnresolvedImport 12 | from pkg_resources import resource_filename as _resfname # @UnresolvedImport 13 | 14 | 15 | PRESETS_INI = "presets.ini" 16 | 17 | 18 | def exists(resource_name): 19 | """ 20 | Test if the specified resource exists. 21 | 22 | :param resource_name: string 23 | """ 24 | return _resexists(__name__, resource_name) 25 | 26 | 27 | def get_stream(resource_name): 28 | """ 29 | Return a stream of the specified resource. 30 | 31 | :param resource_name: string 32 | """ 33 | return _resstream(__name__, resource_name) 34 | 35 | 36 | def get_string(resource_name): 37 | """ 38 | Return content string of the specified resource. 39 | 40 | :param resource_name: string 41 | """ 42 | return _resstring(__name__, resource_name) 43 | 44 | 45 | def get_filename(resource_name): 46 | """ 47 | Return filename of the specified resource. 48 | 49 | :param resource_name: string 50 | """ 51 | return _resfname(__name__, resource_name) 52 | -------------------------------------------------------------------------------- /dyndnsc/resources/presets.ini: -------------------------------------------------------------------------------- 1 | [preset:no-ip.com] 2 | updater = dyndns2 3 | updater-url = https://dynupdate.no-ip.com/nic/update 4 | detector = webcheck4 5 | detector-family = INET 6 | detector-url = http://ip1.dynupdate.no-ip.com/ 7 | detector-parser = plain 8 | 9 | 10 | [preset:freedns.afraid.com] 11 | updater = afraid 12 | updater-url = http://freedns.afraid.org/api/ 13 | detector = webcheck4 14 | detector-family = INET 15 | detector-url = https://freedns.afraid.org/dynamic/check.php 16 | detector-parser = freedns_afraid 17 | 18 | 19 | [preset:nsupdate.info:ipv4] 20 | updater = dyndns2 21 | updater-url = https://ipv4.nsupdate.info/nic/update 22 | detector = webcheck4 23 | detector-family = INET 24 | detector-url = https://ipv4.nsupdate.info/myip 25 | detector-parser = plain 26 | 27 | 28 | [preset:nsupdate.info:ipv6] 29 | updater = dyndns2 30 | updater-url = https://ipv6.nsupdate.info/nic/update 31 | detector = socket 32 | detector-family = INET6 33 | 34 | [preset:dns.he.net] 35 | updater = dyndns2 36 | updater-url = https://dyn.dns.he.net/nic/update 37 | detector = webcheck 38 | detector-url = http://checkip.dns.he.net/ 39 | detector-parser = checkip_dns_he_net 40 | 41 | 42 | [preset:dnsimple.com] 43 | updater = dnsimple 44 | detector = webcheck 45 | 46 | 47 | [preset:dnsdynamic.org] 48 | updater = dyndns2 49 | updater-url = https://www.dnsdynamic.org/api/ 50 | detector = webcheck 51 | detector-url = http://myip.dnsdynamic.org/ 52 | detector-parser = plain 53 | 54 | 55 | [preset:hopper.pw:ipv4] 56 | updater = dyndns2 57 | updater-url = https://ipv4.www.hopper.pw/nic/update 58 | detector = webcheck4 59 | detector-family = INET 60 | detector-url = https://ipv4.www.hopper.pw/myip 61 | detector-parser = plain 62 | 63 | 64 | [preset:hopper.pw:ipv6] 65 | updater = dyndns2 66 | updater-url = https://ipv6.www.hopper.pw/nic/update 67 | detector = webcheck6 68 | detector-family = INET6 69 | detector-url = https://ipv6.www.hopper.pw/myip 70 | detector-parser = plain 71 | 72 | 73 | [preset:dyn.com] 74 | updater = dyndns2 75 | updater-url = https://members.dyndns.org/nic/update 76 | detector = webcheck 77 | 78 | [preset:duckdns.org] 79 | updater = duckdns 80 | updater-url = https://www.duckdns.org/update 81 | detector = webcheck4 82 | 83 | 84 | [preset:strato.com:ipv4] 85 | updater = dyndns2 86 | updater-url = https://dyndns.strato.com/nic/update 87 | detector = webcheck4 88 | 89 | [preset:strato.com:ipv6] 90 | updater = dyndns2 91 | updater-url = https://dyndns.strato.com/nic/update 92 | detector = webcheck6 93 | -------------------------------------------------------------------------------- /dyndnsc/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for dyndnsc.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/common/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for common package.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/common/test_dynamiccli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the dynamiccli module.""" 4 | 5 | import unittest 6 | 7 | from dyndnsc.common.dynamiccli import parse_cmdline_args, DynamicCliMixin 8 | 9 | 10 | class Dummy(DynamicCliMixin): 11 | """A dummy class used to verify parse_cmdline_args() behaviour.""" 12 | 13 | configuration_key = "dummy" 14 | 15 | def __init__(self, userid, password): 16 | """Initialize. Do nothing.""" 17 | 18 | @staticmethod 19 | def configuration_key_prefix(): 20 | """Return 'foo', identifying the protocol prefix.""" 21 | return "foo" 22 | 23 | 24 | class TestDynamicCli(unittest.TestCase): 25 | """Test cases for dynamiccli.""" 26 | 27 | def test_parse_cmdline_updater_args(self): 28 | """Run tests for parse_cmdline_args().""" 29 | self.assertRaises(TypeError, parse_cmdline_args, None) 30 | self.assertRaises(ValueError, parse_cmdline_args, None, None) 31 | self.assertRaises(NotImplementedError, parse_cmdline_args, object(), [DynamicCliMixin]) 32 | self.assertEqual({}, parse_cmdline_args(object(), [])) 33 | 34 | # test that a sample valid config parses: 35 | from argparse import Namespace 36 | args = Namespace() 37 | args.foo_dummy = True 38 | args.foo_dummy_userid = "bob" 39 | args.foo_dummy_password = "******" # noqa: S105 40 | 41 | parsed = parse_cmdline_args(args, [Dummy]) 42 | self.assertEqual(1, len(parsed)) 43 | self.assertTrue(isinstance(parsed, dict)) 44 | self.assertTrue("foo" in parsed) 45 | self.assertTrue(isinstance(parsed["foo"], list)) 46 | self.assertEqual(1, len(parsed["foo"])) 47 | self.assertTrue(isinstance(parsed["foo"][0], tuple)) 48 | self.assertEqual(parsed["foo"][0][0], "dummy") 49 | self.assertTrue(isinstance(parsed["foo"][0][1], dict)) 50 | self.assertTrue("userid" in parsed["foo"][0][1].keys()) 51 | self.assertTrue("password" in parsed["foo"][0][1].keys()) 52 | -------------------------------------------------------------------------------- /dyndnsc/tests/common/test_subject.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the subject module.""" 4 | 5 | import unittest 6 | import logging 7 | 8 | from dyndnsc.common.subject import Subject 9 | 10 | 11 | class SampleListener(object): 12 | """An example listener that records all notifications.""" 13 | 14 | def __init__(self): 15 | """Initialize with an empty list of messages.""" 16 | self.messages = [] 17 | 18 | def notify(self, sender, event, msg): 19 | """Do nothing but remember the notification.""" 20 | self.messages.append((sender, event, msg)) 21 | 22 | 23 | class InvalidListener(object): 24 | """An invalid listener.""" 25 | 26 | def notify(self, dummy): 27 | """Do nothing.""" 28 | pass 29 | 30 | 31 | class TestSubjectObserver(unittest.TestCase): 32 | """Test cases for Subject.""" 33 | 34 | def setUp(self): 35 | """Disable logging to not confuse the person watching the unit test output.""" 36 | logging.disable(logging.CRITICAL) 37 | unittest.TestCase.setUp(self) 38 | 39 | def test_observer(self): 40 | """Run observer tests.""" 41 | subject = Subject() 42 | subject.notify_observers("INVALID_EVENT", "msg") 43 | listener = SampleListener() 44 | subject.register_observer(listener.notify, ["SAMPLE_EVENT"]) 45 | self.assertEqual(0, len(listener.messages)) 46 | subject.notify_observers("INVALID_EVENT", "msg") 47 | self.assertEqual(0, len(listener.messages)) 48 | subject.notify_observers("SAMPLE_EVENT", "msg") 49 | self.assertEqual(1, len(listener.messages)) 50 | subject.notify_observers("SAMPLE_EVENT", "msg") 51 | self.assertEqual(2, len(listener.messages)) 52 | self.assertEqual(1, len(subject._observers)) 53 | subject.unregister_observer(listener.notify) 54 | self.assertEqual(0, len(subject._observers)) 55 | subject.notify_observers("SAMPLE_EVENT", "msg") 56 | self.assertEqual(2, len(listener.messages)) 57 | invalid_listener = InvalidListener() 58 | subject.register_observer(invalid_listener.notify, "FUNWITHFLAGS") 59 | self.assertEqual(1, len(subject._observers)) 60 | subject.notify_observers("FUNWITHFLAGS", "msg") 61 | self.assertEqual(0, len(subject._observers)) 62 | -------------------------------------------------------------------------------- /dyndnsc/tests/detector/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for detector package.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/detector/test_all.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for detectors.""" 4 | 5 | 6 | import unittest 7 | 8 | from dyndnsc.detector.base import AF_INET, AF_INET6, AF_UNSPEC 9 | 10 | HAVE_IPV6 = True 11 | try: 12 | import socket 13 | socket.socket(socket.AF_INET6, socket.SOCK_DGRAM).connect(("ipv6.google.com", 0)) 14 | except (OSError, socket.gaierror): 15 | HAVE_IPV6 = False 16 | 17 | 18 | class TestPluginDetectors(unittest.TestCase): 19 | """Test cases for detector discovery and management.""" 20 | 21 | def test_detector_builtin(self): 22 | """Test that we have at least one builtin detector class.""" 23 | import dyndnsc.detector.builtin 24 | self.assertTrue(len(dyndnsc.detector.builtin.PLUGINS) > 0) 25 | 26 | def test_detector_interfaces(self): 27 | """Test that each builtin detector class has certain apis.""" 28 | import dyndnsc.detector.manager 29 | self.assertTrue(len(dyndnsc.detector.manager.detector_classes()) > 0) 30 | for cls in dyndnsc.detector.manager.detector_classes(): 31 | self.assertTrue(hasattr(cls, "configuration_key")) 32 | self.assertTrue(hasattr(cls, "af")) 33 | self.assertRaises(ValueError, dyndnsc.detector.manager.get_detector_class, "nonexistent") 34 | 35 | 36 | class TestIndividualDetectors(unittest.TestCase): 37 | """Test cases for detectors.""" 38 | 39 | def test_dns_resolve(self): 40 | """Run tests for DNS resolution.""" 41 | import dyndnsc.detector.dns as ns 42 | self.assertTrue(len(ns.resolve("localhost")) > 0) 43 | self.assertTrue(len(ns.resolve("localhost", family=ns.AF_INET)) > 0) 44 | 45 | def test_detector_state_changes(self): 46 | """Run tests for IPDetector state changes.""" 47 | import dyndnsc.detector.base 48 | ip1 = "127.0.0.1" 49 | ip2 = "127.0.0.2" 50 | detector = dyndnsc.detector.base.IPDetector() 51 | 52 | self.assertEqual(None, detector.get_current_value()) 53 | self.assertEqual(None, detector.get_old_value()) 54 | self.assertFalse(detector.has_changed()) 55 | 56 | # set to ip1 57 | self.assertEqual(ip1, detector.set_current_value(ip1)) 58 | self.assertTrue(detector.has_changed()) 59 | self.assertEqual(ip1, detector.get_current_value()) 60 | self.assertEqual(None, detector.get_old_value()) 61 | 62 | # set to ip2 63 | self.assertEqual(ip2, detector.set_current_value(ip2)) 64 | self.assertEqual(ip2, detector.get_current_value()) 65 | self.assertEqual(ip1, detector.get_old_value()) 66 | self.assertTrue(detector.has_changed()) 67 | 68 | # set again to ip2 69 | self.assertEqual(ip2, detector.set_current_value(ip2)) 70 | self.assertFalse(detector.has_changed()) 71 | self.assertEqual(ip2, detector.get_current_value()) 72 | self.assertEqual(ip2, detector.get_old_value()) 73 | 74 | def test_dns_detector(self): 75 | """Run tests for IPDetector_DNS.""" 76 | import dyndnsc.detector.dns as ns 77 | self.assertEqual("dns", ns.IPDetector_DNS.configuration_key) 78 | detector = ns.IPDetector_DNS(hostname="localhost") 79 | self.assertFalse(detector.can_detect_offline()) 80 | self.assertEqual(AF_UNSPEC, detector.af()) 81 | self.assertEqual(None, detector.get_current_value()) 82 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 83 | self.assertTrue(detector.detect() in ("::1", "127.0.0.1", "fe80::1%lo0")) 84 | self.assertTrue(detector.get_current_value() in ("::1", "127.0.0.1", "fe80::1%lo0")) 85 | # test address family restriction to ipv4: 86 | detector = ns.IPDetector_DNS(hostname="localhost", family="INET") 87 | self.assertEqual(AF_INET, detector.af()) 88 | self.assertTrue(detector.detect() in ("127.0.0.1", )) 89 | # test address family restriction to ipv6: 90 | if HAVE_IPV6: 91 | detector = ns.IPDetector_DNS(hostname="localhost", family="INET6") 92 | self.assertEqual(AF_INET6, detector.af()) 93 | val = detector.detect() 94 | self.assertTrue(val in ("::1", "fe80::1%lo0"), "%r not known" % val) 95 | 96 | def test_command_detector(self): 97 | """Run tests for IPDetector_Command.""" 98 | import dyndnsc.detector.command 99 | cmd = "echo 127.0.0.1" 100 | self.assertEqual("command", dyndnsc.detector.command.IPDetector_Command.configuration_key) 101 | detector = dyndnsc.detector.command.IPDetector_Command(command=cmd) 102 | self.assertFalse(detector.can_detect_offline()) 103 | self.assertEqual(AF_UNSPEC, detector.af()) 104 | self.assertEqual(None, detector.get_current_value()) 105 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 106 | self.assertTrue(detector.detect() in ("::1", "127.0.0.1")) 107 | self.assertTrue(detector.get_current_value() in ("::1", "127.0.0.1")) 108 | 109 | # test address family restriction to ipv4: 110 | detector = dyndnsc.detector.command.IPDetector_Command(command=cmd, family="INET") 111 | self.assertEqual(AF_INET, detector.af()) 112 | 113 | # test address family restriction to ipv6: 114 | detector = dyndnsc.detector.command.IPDetector_Command(command=cmd, family="INET6") 115 | self.assertEqual(AF_INET6, detector.af()) 116 | 117 | def test_rand_ip_generator(self): 118 | """Run tests for RandomIPGenerator.""" 119 | import dyndnsc.detector.rand 120 | generator = dyndnsc.detector.rand.RandomIPGenerator() 121 | self.assertTrue(generator.is_reserved_ip("127.0.0.1")) 122 | self.assertFalse(generator.is_reserved_ip("83.169.1.157")) 123 | self.assertFalse(generator.is_reserved_ip(generator.random_public_ip())) 124 | # for the sake of randomness, detect a bunch of IPs: 125 | _count = 0 126 | generator = dyndnsc.detector.rand.RandomIPGenerator(100) 127 | for _count, theip in enumerate(generator): 128 | self.assertFalse(generator.is_reserved_ip(theip)) 129 | self.assertEqual(_count, 99) 130 | 131 | def test_rand_detector(self): 132 | """Run tests for IPDetector_Random.""" 133 | import dyndnsc.detector.rand 134 | self.assertEqual("random", dyndnsc.detector.rand.IPDetector_Random.configuration_key) 135 | detector = dyndnsc.detector.rand.IPDetector_Random() 136 | self.assertTrue(detector.can_detect_offline()) 137 | self.assertEqual(AF_INET, detector.af()) 138 | self.assertEqual(None, detector.get_current_value()) 139 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 140 | 141 | def test_socket_detector(self): 142 | """Run tests for IPDetector_Socket.""" 143 | from dyndnsc.detector import socket_ip 144 | self.assertEqual("socket", socket_ip.IPDetector_Socket.configuration_key) 145 | detector = socket_ip.IPDetector_Socket(family="INET") 146 | self.assertFalse(detector.can_detect_offline()) 147 | self.assertEqual(AF_INET, detector.af()) 148 | self.assertEqual(None, detector.get_current_value()) 149 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 150 | # unknown address family must fail construction 151 | self.assertRaises(ValueError, socket_ip.IPDetector_Socket, family="bla") 152 | 153 | def test_webcheck_parsers(self): 154 | """Run tests for different webcheck parsers.""" 155 | test_data_checkip_dns_he_net = """ 156 | 157 | 158 | 159 | What is my IP address? 160 | 161 | 162 | Your IP address is : 127.0.0.1 163 | 164 | """ 165 | from dyndnsc.detector import webcheck 166 | self.assertEqual(None, webcheck._parser_checkip("")) 167 | self.assertEqual("127.0.0.1", webcheck._parser_checkip("Current IP Address: 127.0.0.1")) 168 | 169 | self.assertEqual("127.0.0.1", webcheck._parser_checkip_dns_he_net(test_data_checkip_dns_he_net)) 170 | 171 | self.assertEqual(None, webcheck._parser_plain("")) 172 | self.assertEqual("127.0.0.1", webcheck._parser_plain("127.0.0.1")) 173 | 174 | self.assertEqual(None, webcheck._parser_freedns_afraid("")) 175 | self.assertEqual("127.0.0.1", webcheck._parser_freedns_afraid("Detected IP : 127.0.0.1")) 176 | 177 | self.assertEqual(None, webcheck._parser_jsonip("")) 178 | self.assertEqual("127.0.0.1", webcheck._parser_jsonip( 179 | r'{"ip":"127.0.0.1","about":"/about","Pro!":"http://getjsonip.com"}')) 180 | 181 | def test_webcheck(self): 182 | """Run tests for IPDetectorWebCheck.""" 183 | from dyndnsc.detector import webcheck 184 | self.assertEqual("webcheck4", webcheck.IPDetectorWebCheck.configuration_key) 185 | detector = webcheck.IPDetectorWebCheck() 186 | self.assertFalse(detector.can_detect_offline()) 187 | self.assertEqual(AF_INET, detector.af()) 188 | self.assertEqual(None, detector.get_current_value()) 189 | value = detector.detect() 190 | self.assertTrue(isinstance(value, (type(None), str))) 191 | 192 | def test_webcheck6(self): 193 | """Run tests for IPDetectorWebCheck6.""" 194 | from dyndnsc.detector import webcheck 195 | self.assertEqual("webcheck6", webcheck.IPDetectorWebCheck6.configuration_key) 196 | detector = webcheck.IPDetectorWebCheck6() 197 | self.assertFalse(detector.can_detect_offline()) 198 | self.assertEqual(AF_INET6, detector.af()) 199 | self.assertEqual(None, detector.get_current_value()) 200 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 201 | 202 | def test_webcheck46(self): 203 | """Run tests for IPDetectorWebCheck46.""" 204 | from dyndnsc.detector import webcheck 205 | self.assertEqual("webcheck46", webcheck.IPDetectorWebCheck46.configuration_key) 206 | detector = webcheck.IPDetectorWebCheck46() 207 | self.assertFalse(detector.can_detect_offline()) 208 | self.assertEqual(AF_UNSPEC, detector.af()) 209 | self.assertEqual(None, detector.get_current_value()) 210 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 211 | 212 | def test_null(self): 213 | """Run tests for IPDetector_Null.""" 214 | from dyndnsc.detector import null 215 | self.assertEqual("null", null.IPDetector_Null.configuration_key) 216 | detector = null.IPDetector_Null() 217 | self.assertTrue(detector.can_detect_offline()) 218 | self.assertEqual(AF_UNSPEC, detector.af()) 219 | self.assertEqual(None, detector.get_current_value()) 220 | self.assertTrue(isinstance(detector.detect(), (type(None), str))) 221 | -------------------------------------------------------------------------------- /dyndnsc/tests/detector/test_dnswanip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for detectors.""" 4 | 5 | 6 | import unittest 7 | 8 | import pytest 9 | 10 | from dyndnsc.common.six import string_types 11 | from dyndnsc.common.six import ipaddress 12 | from dyndnsc.detector.base import AF_INET, AF_INET6 13 | from dyndnsc.detector.dnswanip import IPDetector_DnsWanIp 14 | 15 | HAVE_IPV6 = True 16 | try: 17 | import socket 18 | socket.socket(socket.AF_INET6, socket.SOCK_DGRAM).connect(("ipv6.google.com", 0)) 19 | except (OSError, socket.gaierror): 20 | HAVE_IPV6 = False 21 | 22 | 23 | class TestIndividualDetectors(unittest.TestCase): 24 | """Test cases for detectors.""" 25 | 26 | def test_dnswanip_detector_class(self): 27 | """Run basic tests for IPDetector_DnsWanIp.""" 28 | self.assertEqual("dnswanip", IPDetector_DnsWanIp.configuration_key) 29 | detector = IPDetector_DnsWanIp() 30 | self.assertFalse(detector.can_detect_offline()) 31 | self.assertEqual(None, detector.get_current_value()) 32 | # default family should be ipv4: 33 | detector = IPDetector_DnsWanIp(family=None) 34 | self.assertEqual(AF_INET, detector.af()) 35 | detector = IPDetector_DnsWanIp(family=AF_INET) 36 | self.assertEqual(AF_INET, detector.af()) 37 | detector = IPDetector_DnsWanIp(family=AF_INET6) 38 | self.assertEqual(AF_INET6, detector.af()) 39 | 40 | def test_dnswanip_detector_ipv4(self): 41 | """Run ipv4 tests for IPDetector_DnsWanIp.""" 42 | detector = IPDetector_DnsWanIp(family=AF_INET) 43 | result = detector.detect() 44 | self.assertTrue(isinstance(result, (type(None),) + string_types), type(result)) 45 | # ensure the result is in fact an IP address: 46 | self.assertNotEqual(ipaddress(result), None) 47 | self.assertEqual(detector.get_current_value(), result) 48 | 49 | @pytest.mark.skipif(not HAVE_IPV6, reason="requires ipv6 connectivity") 50 | def test_dnswanip_detector_ipv6(self): 51 | """Run ipv6 tests for IPDetector_DnsWanIp.""" 52 | if HAVE_IPV6: # allow running test in IDE without pytest support 53 | detector = IPDetector_DnsWanIp(family=AF_INET6) 54 | result = detector.detect() 55 | self.assertTrue(isinstance(result, (type(None),) + string_types), type(result)) 56 | # ensure the result is in fact an IP address: 57 | self.assertNotEqual(ipaddress(result), None) 58 | self.assertEqual(detector.get_current_value(), result) 59 | -------------------------------------------------------------------------------- /dyndnsc/tests/detector/test_iface.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the iface detector.""" 4 | 5 | 6 | import unittest 7 | 8 | from dyndnsc.detector.base import AF_INET6 9 | from dyndnsc.common.six import string_types 10 | 11 | 12 | def give_me_an_interface_ipv6(): 13 | """Return a local ipv6 interface or None.""" 14 | import netifaces 15 | for interface in netifaces.interfaces(): 16 | if netifaces.AF_INET6 in netifaces.ifaddresses(interface): 17 | return interface 18 | return None 19 | 20 | 21 | def give_me_an_interface_ipv4(): 22 | """Return a local ipv4 interface or None.""" 23 | import netifaces 24 | for interface in netifaces.interfaces(): 25 | if netifaces.AF_INET in netifaces.ifaddresses(interface): 26 | return interface 27 | return None 28 | 29 | 30 | class IfaceDetectorTest(unittest.TestCase): 31 | """Test cases for iface detector.""" 32 | 33 | def test_iface_detector(self): 34 | """Run iface tests.""" 35 | from dyndnsc.detector import iface 36 | self.assertEqual("iface", iface.IPDetector_Iface.configuration_key) 37 | # auto-detect an interface: 38 | interface = give_me_an_interface_ipv4() 39 | self.assertNotEqual(None, interface) 40 | detector = iface.IPDetector_Iface(iface=interface) 41 | self.assertTrue(detector.can_detect_offline()) 42 | self.assertEqual(None, detector.get_current_value()) 43 | self.assertTrue(isinstance(detector.detect(), string_types + (type(None),))) 44 | # empty interface name must not fail construction 45 | self.assertIsInstance(iface.IPDetector_Iface(iface=None), iface.IPDetector_Iface) 46 | # invalid netmask must fail construction 47 | self.assertRaises(ValueError, iface.IPDetector_Iface, netmask="fubar") 48 | # unknown address family must fail construction 49 | self.assertRaises(ValueError, iface.IPDetector_Iface, family="bla") 50 | 51 | def test_teredo_detector(self): 52 | """Run teredo tests.""" 53 | from dyndnsc.detector import teredo 54 | self.assertEqual("teredo", teredo.IPDetector_Teredo.configuration_key) 55 | # auto-detect an interface: 56 | interface = give_me_an_interface_ipv6() 57 | if interface is not None: # we have ip6 support 58 | detector = teredo.IPDetector_Teredo(iface=interface) 59 | self.assertTrue(detector.can_detect_offline()) 60 | self.assertEqual(AF_INET6, detector.af()) 61 | self.assertEqual(None, detector.get_current_value()) 62 | self.assertTrue(isinstance(detector.detect(), string_types + (type(None),))) 63 | # self.assertNotEqual(None, detector.netmask) 64 | 65 | detector = teredo.IPDetector_Teredo(iface="foo0") 66 | self.assertEqual(None, detector.detect()) 67 | -------------------------------------------------------------------------------- /dyndnsc/tests/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for plugins package.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/plugins/notify/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for notify plugins package.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/plugins/test_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for base plugin stuff.""" 4 | 5 | import unittest 6 | 7 | from dyndnsc.plugins import base 8 | 9 | 10 | class TestPluginBase(unittest.TestCase): 11 | """Test cases for plugin base code.""" 12 | 13 | def testIPluginInterface(self): 14 | """Run test.""" 15 | self.assertRaises(TypeError, base.IPluginInterface) 16 | -------------------------------------------------------------------------------- /dyndnsc/tests/plugins/test_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for plugin manager.""" 4 | 5 | import unittest 6 | 7 | try: 8 | from unittest import mock 9 | except ImportError: 10 | import mock 11 | 12 | from dyndnsc.plugins import manager 13 | 14 | 15 | class Dummy(object): 16 | """Empty Dummy class used to test plugin system.""" 17 | 18 | pass 19 | 20 | 21 | class TestPluginProxy(unittest.TestCase): 22 | """Test cases for the PluginProxy.""" 23 | 24 | def test_proxy_constructor(self): 25 | """Test the plugin proxy constructor.""" 26 | self.assertRaises(TypeError, manager.PluginProxy) 27 | self.assertRaises(AttributeError, manager.PluginProxy, "_invalid_interface_method_", []) 28 | # now a valid method: 29 | self.assertEqual(type(manager.PluginProxy("options", [])), manager.PluginProxy) 30 | 31 | def test_proxy_mock_plugin(self): 32 | """Test the plugin proxy.""" 33 | # mock a plugin and assert it will be called by the proxy: 34 | plugin1 = Dummy() 35 | plugin1.initialize = mock.MagicMock() 36 | proxy = manager.PluginProxy("initialize", [plugin1]) 37 | proxy() 38 | plugin1.initialize.assert_called_once_with() 39 | 40 | 41 | class TestNullPluginManager(unittest.TestCase): 42 | """Test cases for NullPluginManager.""" 43 | 44 | def test_nullplugin_manager(self): 45 | """Run basic test for the NullPluginManager.""" 46 | mgr = manager.NullPluginManager() 47 | mgr.initialize() 48 | 49 | 50 | class TestPluginManager(unittest.TestCase): 51 | """Test cases for plugin managers.""" 52 | 53 | def test_plugin_manager(self): 54 | """Test plugin manager.""" 55 | plugin1 = Dummy() 56 | plugins = [plugin1] 57 | mgr = manager.PluginManager(plugins, manager.PluginProxy) 58 | self.assertTrue(hasattr(mgr, "load_plugins")) # does nothing but must exist 59 | self.assertEqual(mgr.plugins, plugins) 60 | self.assertTrue(hasattr(mgr, "__iter__")) 61 | self.assertEqual(list(mgr), plugins) 62 | mgr.plugins = [] 63 | self.assertEqual(mgr.plugins, []) 64 | mgr.plugins = plugins 65 | self.assertEqual(mgr.plugins, plugins) 66 | # configure plugins with no input: 67 | mgr.configure(None) 68 | self.assertEqual(mgr.plugins, []) # no plugins remain 69 | # start over: 70 | mgr.plugins = plugins 71 | 72 | parser = mock.Mock() 73 | parser.DYNDNSC_WITH_DUMMY = 1 74 | mgr.configure(parser) 75 | self.assertEqual(mgr.plugins, plugins) # plugin remains! 76 | 77 | mgr.initialize() 78 | 79 | def test_builtin_plugin_manager(self): 80 | """Run tests for BuiltinPluginManager.""" 81 | mgr = manager.BuiltinPluginManager() 82 | self.assertEqual(mgr.plugins, []) 83 | mgr.load_plugins() 84 | # depending on test environment, some plugins might have been loaded: 85 | self.assertTrue(len(mgr.plugins) >= 0) 86 | 87 | def test_entrypoint_plugin_manager(self): 88 | """Run tests for EntryPointPluginManager.""" 89 | mgr = manager.EntryPointPluginManager() 90 | self.assertEqual(mgr.plugins, []) 91 | mgr.load_plugins() 92 | self.assertEqual(mgr.plugins, []) 93 | -------------------------------------------------------------------------------- /dyndnsc/tests/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Test package for static resources.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/resources/test_resources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for resources.""" 4 | 5 | import unittest 6 | import os 7 | 8 | from dyndnsc.resources import get_filename, get_stream, get_string, exists, PRESETS_INI 9 | 10 | 11 | class TestResources(unittest.TestCase): 12 | """Test cases for resources.""" 13 | 14 | def setUp(self): 15 | """Run setup hooks.""" 16 | unittest.TestCase.setUp(self) 17 | 18 | def tearDown(self): 19 | """Run teardown hooks.""" 20 | unittest.TestCase.tearDown(self) 21 | 22 | def test_get_filename(self): 23 | """Run test.""" 24 | self.assertTrue(os.path.isfile(get_filename(PRESETS_INI))) 25 | 26 | def test_exists(self): 27 | """Run test.""" 28 | self.assertTrue(exists(PRESETS_INI)) 29 | self.assertFalse(exists(get_filename(".fubar-non-existent"))) 30 | 31 | def test_get_string(self): 32 | """Run test.""" 33 | self.assertTrue(type(get_string(PRESETS_INI)) in (str, bytes)) 34 | 35 | def test_get_stream(self): 36 | """Run test.""" 37 | obj = get_stream(PRESETS_INI) 38 | if hasattr(obj, "close"): 39 | obj.close() 40 | else: 41 | self.fail("get_stream() didn't return an object with a close() method") 42 | -------------------------------------------------------------------------------- /dyndnsc/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the cli.""" 4 | 5 | import unittest 6 | import argparse 7 | import os 8 | import configparser 9 | 10 | from dyndnsc.common.six import StringIO 11 | from dyndnsc import cli 12 | 13 | 14 | class TestCli(unittest.TestCase): 15 | """Test cases for Cli.""" 16 | 17 | def test_create_argparser(self): 18 | """Run tests for create_argparser().""" 19 | parser, arg_defaults = cli.create_argparser() 20 | self.assertTrue(isinstance(parser, argparse.ArgumentParser)) 21 | self.assertTrue(isinstance(arg_defaults, dict)) 22 | 23 | def test_list_presets(self): 24 | """Run tests for list_presets().""" 25 | sample_config = """[preset:testpreset] 26 | updater = fubarUpdater 27 | updater-url = https://update.example.com/nic/update 28 | updater-moreparam = some_stuff 29 | detector = webcheck4 30 | detector-family = INET 31 | detector-url = http://ip.example.com/ 32 | detector-parser = plain""" 33 | parser = configparser.ConfigParser() 34 | parser.read_file(StringIO(sample_config)) 35 | output = StringIO() 36 | cli.list_presets(parser, out=output) 37 | buf = output.getvalue() 38 | 39 | self.assertEqual(len(sample_config.splitlines()), len(buf.splitlines())) 40 | self.assertTrue(buf.startswith("testpreset")) 41 | self.assertTrue("fubarUpdater" in buf) 42 | self.assertTrue(buf.endswith(os.linesep)) 43 | -------------------------------------------------------------------------------- /dyndnsc/tests/test_conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the conf module.""" 4 | 5 | import unittest 6 | import configparser 7 | 8 | from dyndnsc.common.six import StringIO 9 | 10 | from dyndnsc.conf import get_configuration, collect_config 11 | from dyndnsc.resources import get_filename, PRESETS_INI 12 | 13 | 14 | class TestConfig(unittest.TestCase): 15 | """Test cases for config.""" 16 | 17 | def test_get_configuration(self): 18 | """Run basic test for configuration parser.""" 19 | parser = get_configuration(get_filename(PRESETS_INI)) 20 | # TODO: this is not a good test. Improve! 21 | self.assertFalse(parser.has_section("dyndnsc")) 22 | 23 | def test_config_builtin_presets(self): 24 | """Run tests for builtin presets.""" 25 | parser = get_configuration(get_filename(PRESETS_INI)) 26 | # in the built-in presets.ini, we don't want anything but presets: 27 | self.assertTrue(len(parser.sections()) > 0) 28 | for section in parser.sections(): 29 | self.assertTrue( 30 | section.startswith("preset:"), "section starts with preset:") 31 | 32 | def test_collect_configuration(self): 33 | """Test minimal example of a working config.""" 34 | sample_config = """[dyndnsc] 35 | configs = testconfig 36 | 37 | [testconfig] 38 | use_preset = testpreset 39 | updater-userid = bob 40 | updater-password = XYZ 41 | # test overwriting a preset value: 42 | detector-url = http://myip.example.com/ 43 | 44 | [preset:testpreset] 45 | updater = fubarUpdater 46 | updater-url = https://update.example.com/nic/update 47 | updater-moreparam = some_stuff 48 | detector = webcheck4 49 | detector-family = INET 50 | detector-url = http://ip.example.com/ 51 | detector-parser = plain 52 | """ 53 | parser = configparser.ConfigParser() 54 | parser.read_file(StringIO(sample_config)) 55 | config = collect_config(parser) 56 | self.assertEqual(dict, type(config)) 57 | self.assertTrue("testconfig" in config) 58 | self.assertTrue("detector" in config["testconfig"]) 59 | self.assertTrue(isinstance(config["testconfig"]["detector"], list)) 60 | self.assertEqual(1, len(config["testconfig"]["detector"])) 61 | detector, detector_opts = config["testconfig"]["detector"][-1] 62 | self.assertEqual(detector, "webcheck4") # from the preset 63 | self.assertEqual(detector_opts["url"], "http://myip.example.com/") # from the user conf 64 | self.assertTrue("updater" in config["testconfig"]) 65 | self.assertTrue(isinstance(config["testconfig"]["updater"], list)) 66 | self.assertEqual(1, len(config["testconfig"]["updater"])) 67 | updater = config["testconfig"]["updater"][0] 68 | self.assertEqual("fubarUpdater", updater[0]) 69 | self.assertTrue("url" in updater[1]) 70 | self.assertTrue("moreparam" in updater[1]) 71 | self.assertEqual("some_stuff", updater[1]["moreparam"]) 72 | -------------------------------------------------------------------------------- /dyndnsc/tests/test_console_scripts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the console script using the pytest-console-scripts fixture.""" 4 | 5 | import pytest 6 | 7 | # flake8: noqa 8 | 9 | 10 | @pytest.mark.script_launch_mode('inprocess') 11 | def test_version(script_runner): 12 | from dyndnsc import __version__ 13 | ret = script_runner.run("dyndnsc", "--version") 14 | assert ret.success 15 | assert ret.stdout == "dyndnsc %s\n" % __version__ 16 | assert ret.stderr == "" 17 | 18 | 19 | @pytest.mark.script_launch_mode('inprocess') 20 | def test_presets(script_runner): 21 | ret = script_runner.run("dyndnsc", "--list-presets") 22 | assert ret.success 23 | assert "updater-url" in ret.stdout 24 | assert ret.stderr == "" 25 | 26 | 27 | @pytest.mark.script_launch_mode('inprocess') 28 | def test_help(script_runner): 29 | ret = script_runner.run("dyndnsc", "--help") 30 | assert ret.success 31 | assert "usage: dyndnsc" in ret.stdout 32 | assert ret.stderr == "" 33 | 34 | 35 | @pytest.mark.script_launch_mode('inprocess') 36 | def test_null_dummy(script_runner): 37 | ret = script_runner.run( 38 | "dyndnsc", 39 | "--detector-null", 40 | "--updater-dummy", 41 | "--updater-dummy-hostname", "example.com" 42 | ) 43 | assert ret.success 44 | assert ret.stdout == "" 45 | assert ret.stderr == "" 46 | 47 | 48 | @pytest.mark.script_launch_mode('inprocess') 49 | def test_null_dummy_debug(script_runner): 50 | ret = script_runner.run( 51 | "dyndnsc", 52 | "--detector-null", 53 | "--updater-dummy", 54 | "--updater-dummy-hostname", "example.com", 55 | "--debug" 56 | ) 57 | assert ret.success 58 | assert ret.stdout == "" 59 | assert "DEBUG" in ret.stderr 60 | 61 | 62 | @pytest.mark.script_launch_mode('inprocess') 63 | def test_null_dummy_logjson(script_runner): 64 | ret = script_runner.run( 65 | "dyndnsc", 66 | "--detector-null", 67 | "--updater-dummy", 68 | "--updater-dummy-hostname", "example.com", 69 | "--log-json", "--debug" 70 | ) 71 | assert ret.success 72 | assert "{\"written_at\":" in ret.stdout 73 | assert ret.stderr == "" 74 | -------------------------------------------------------------------------------- /dyndnsc/tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for the core module.""" 4 | 5 | import unittest 6 | 7 | import dyndnsc 8 | 9 | 10 | def get_valid_updater(): 11 | """Return an updater instance for testing.""" 12 | from dyndnsc.updater.dummy import UpdateProtocolDummy 13 | return UpdateProtocolDummy(hostname="example.com") 14 | 15 | 16 | def get_valid_detector(): 17 | """Return a detector instance for testing.""" 18 | from dyndnsc.detector.null import IPDetector_Null 19 | return IPDetector_Null() 20 | 21 | 22 | class TestDynDnsClient(unittest.TestCase): 23 | """Test cases for DynDnsClient.""" 24 | 25 | def test_dyndnsclient_init_args(self): 26 | """Run tests for DynDnsClient initializer.""" 27 | # at least a valid updater must be provided: 28 | self.assertRaises(ValueError, dyndnsc.DynDnsClient) 29 | self.assertRaises(ValueError, dyndnsc.DynDnsClient, updater=1) 30 | self.assertTrue(isinstance(dyndnsc.DynDnsClient(updater=get_valid_updater()), dyndnsc.DynDnsClient)) 31 | 32 | # a detector is optional but must be of correct type: 33 | self.assertRaises(ValueError, dyndnsc.DynDnsClient, updater=get_valid_updater(), detector=1) 34 | client = dyndnsc.DynDnsClient(updater=get_valid_updater(), detector=get_valid_detector()) 35 | self.assertTrue(isinstance(client, dyndnsc.DynDnsClient)) 36 | 37 | def test_dyndnsclient_factory(self): 38 | """Run tests for getDynDnsClientForConfig().""" 39 | # the type of these exceptions is not formally required, 40 | # but we want to have some basic form of argument validity checking 41 | # Note: we prefer ValueError for semantically wrong options 42 | self.assertRaises(TypeError, dyndnsc.getDynDnsClientForConfig, None) 43 | self.assertRaises(ValueError, dyndnsc.getDynDnsClientForConfig, {}) 44 | self.assertRaises(ValueError, dyndnsc.getDynDnsClientForConfig, 45 | {"updater": ()}) 46 | 47 | # create a dummy config: 48 | config = {} 49 | config["interval"] = 10 50 | config["detector"] = (("random", {}),) 51 | config["updater"] = (("dummy", {"hostname": "example.com"}),) 52 | dyndnsclient = dyndnsc.getDynDnsClientForConfig(config) 53 | self.assertEqual(dyndnsclient.detector.af(), dyndnsclient.dns.af()) 54 | self.assertTrue(dyndnsclient.needs_check()) 55 | dyndnsclient.needs_sync() 56 | dyndnsclient.check() 57 | dyndnsclient.sync() 58 | dyndnsclient.has_state_changed() 59 | 60 | def test_dyndnsclient_null(self): 61 | """Run tests for dyndnsc when we cannot detect the IP.""" 62 | # create config: 63 | config = {} 64 | config["interval"] = 1 65 | config["detector"] = (("null", {}),) 66 | config["updater"] = (("dummy", {"hostname": "example.com"}),) 67 | dyndnsclient = dyndnsc.getDynDnsClientForConfig(config) 68 | self.assertEqual(dyndnsclient.detector.af(), dyndnsclient.dns.af()) 69 | self.assertTrue(dyndnsclient.needs_check()) 70 | dyndnsclient.needs_sync() 71 | dyndnsclient.check() 72 | dyndnsclient.sync() 73 | dyndnsclient.has_state_changed() 74 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for updater package.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/test_afraid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for afraid.""" 4 | 5 | import unittest 6 | 7 | import responses 8 | 9 | 10 | class TestAfraid(unittest.TestCase): 11 | """Test cases for Afraid.""" 12 | 13 | def setUp(self): 14 | """Run setup.""" 15 | responses.add( 16 | responses.GET, 17 | "https://freedns.afraid.org/dynamic/update.php?sdvnkdnvv", 18 | body="Updated 1 host(s) foo.example.com to 127.0.0.1 in 0.178 seconds", 19 | headers={"Content-Type": "text/plain; charset=utf-8"} 20 | ) 21 | from responses import matchers 22 | responses.add( 23 | responses.GET, 24 | "https://freedns.afraid.org/api/", 25 | match=[matchers.query_string_matcher("action=getdyndns&sha=8637d0e37ad5ec987709e5bd868131d6cf972f69")], 26 | body="dummyhostname.example.com|127.0.0.2|https://freedns.afraid.org/dynamic/update.php?sdvnkdnvv\r\n", 27 | headers={"Content-Type": "text/plain; charset=utf-8"} 28 | ) 29 | unittest.TestCase.setUp(self) 30 | 31 | def tearDown(self): 32 | """Teardown.""" 33 | unittest.TestCase.tearDown(self) 34 | 35 | @responses.activate 36 | def test_afraid(self): 37 | """Run tests.""" 38 | from dyndnsc.updater import afraid 39 | NAME = "afraid" 40 | options = { 41 | "hostname": "dummyhostname.example.com", 42 | "userid": "dummy", 43 | "password": "1234" 44 | } 45 | self.assertEqual(NAME, afraid.UpdateProtocolAfraid.configuration_key) 46 | updater = afraid.UpdateProtocolAfraid(**options) 47 | res = updater.update() 48 | self.assertEqual("127.0.0.1", res) 49 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/test_all.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for updaters.""" 4 | 5 | import unittest 6 | 7 | try: 8 | from unittest import mock 9 | except ImportError: 10 | import mock 11 | 12 | 13 | class TestUpdaterCommon(unittest.TestCase): 14 | """Updater test cases.""" 15 | 16 | def test_updater_builtin_plugins(self): 17 | """Run test.""" 18 | import dyndnsc.updater.builtin 19 | self.assertTrue(len(dyndnsc.updater.builtin.PLUGINS) > 0) 20 | 21 | def test_updater_base_class(self): 22 | """Run test.""" 23 | from dyndnsc.updater.base import UpdateProtocol 24 | cls = UpdateProtocol 25 | self.assertTrue(hasattr(cls, "configuration_key")) 26 | self.assertTrue(hasattr(cls, "init_argnames")) 27 | self.assertEqual(type(cls.init_argnames()), type([])) 28 | self.assertTrue(hasattr(cls, "register_arguments")) 29 | self.assertTrue(hasattr(cls, "help")) 30 | self.assertEqual(type(cls.help()), type("")) 31 | instance = cls() 32 | self.assertRaises(NotImplementedError, instance.update, "foo") 33 | 34 | # For the purpose of this test, we fake an implementation of 35 | # configuration_key: 36 | cls._configuration_key = "none" 37 | 38 | # ensure the argparser method 'add_argument' is called: 39 | argparser = mock.Mock() 40 | argparser.add_argument = mock.MagicMock(name="add_argument") 41 | self.assertFalse(argparser.add_argument.called) 42 | cls.register_arguments(argparser) 43 | self.assertTrue(argparser.add_argument.called) 44 | 45 | def test_updater_interfaces(self): 46 | """Run test.""" 47 | from dyndnsc.updater.manager import updater_classes, get_updater_class 48 | for cls in updater_classes(): 49 | self.assertTrue(hasattr(cls, "configuration_key")) 50 | self.assertEqual(cls, get_updater_class(cls.configuration_key)) 51 | self.assertTrue(hasattr(cls, "update")) 52 | self.assertTrue(hasattr(cls, "register_arguments")) 53 | self.assertTrue(hasattr(cls, "help")) 54 | self.assertEqual(str, type(cls.configuration_key)) 55 | self.assertTrue(str, type(cls.help())) 56 | self.assertTrue(len(updater_classes()) > 0) 57 | self.assertRaises(ValueError, get_updater_class, "nonexistent") 58 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/test_dnsimple.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tests for dnsimple updater. 4 | 5 | Since we rely on an external library implementing the actual interfacing with 6 | the remote service, the tests in here merely check the behavior of the Dyndnsc 7 | wrapper class. 8 | """ 9 | 10 | import unittest 11 | 12 | try: 13 | from unittest import mock 14 | except ImportError: 15 | import mock 16 | 17 | import sys 18 | 19 | sys.modules["dnsimple_dyndns"] = mock.Mock() 20 | 21 | 22 | class TestDnsimpleUpdater(unittest.TestCase): 23 | """Test cases for Dnsimple.""" 24 | 25 | def test_mocked_dnsimple(self): 26 | """Run tests.""" 27 | from dyndnsc.updater.dnsimple import UpdateProtocolDnsimple 28 | theip = "127.0.0.1" 29 | self.assertEqual("dnsimple", UpdateProtocolDnsimple.configuration_key) 30 | upd = UpdateProtocolDnsimple(hostname="dnsimple_record.example.com", key="1234") 31 | upd.handler.update_record.return_value = theip 32 | self.assertEqual(theip, upd.update(theip)) 33 | upd.handler.update_record.assert_called_once_with(name="dnsimple_record", address=theip) 34 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/test_duckdns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for duckdns.""" 4 | 5 | import unittest 6 | 7 | import responses 8 | 9 | 10 | class TestDuckdns(unittest.TestCase): 11 | """Test cases for Duckdns.""" 12 | 13 | def setUp(self): 14 | """Run setup.""" 15 | from responses import matchers 16 | responses.add( 17 | responses.GET, 18 | "https://www.duckdns.org/update", 19 | match=[matchers.query_string_matcher("domains=duckdns&token=dummy&ip=127.0.0.1")], 20 | body="OK 127.0.0.1", 21 | status=200, 22 | headers={"Content-Type": "text/plain; charset=utf-8"} 23 | ) 24 | responses.add( 25 | responses.GET, 26 | "https://www.duckdns.org/update", 27 | match=[matchers.query_string_matcher("domains=duckdns&token=dummy&ip=")], 28 | body="OK", 29 | status=200, 30 | headers={"Content-Type": "text/plain; charset=utf-8"} 31 | ) 32 | unittest.TestCase.setUp(self) 33 | 34 | def tearDown(self): 35 | """Teardown.""" 36 | unittest.TestCase.tearDown(self) 37 | 38 | @responses.activate 39 | def test_duckdns(self): 40 | """Run tests for duckdns.""" 41 | from dyndnsc.updater import duckdns 42 | NAME = "duckdns" 43 | self.assertEqual( 44 | NAME, duckdns.UpdateProtocolDuckdns.configuration_key) 45 | 46 | options = { 47 | "hostname": "duckdns.example.com", 48 | "token": "dummy", 49 | "url": "https://www.duckdns.org/update" 50 | } 51 | updater = duckdns.UpdateProtocolDuckdns(**options) 52 | # normal IP test: 53 | theip = "127.0.0.1" 54 | self.assertEqual(theip, updater.update(theip)) 55 | 56 | # empty/no IP test: 57 | self.assertEqual(None, updater.update(None)) 58 | -------------------------------------------------------------------------------- /dyndnsc/tests/updater/test_dyndns2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Tests for dyndns2.""" 4 | 5 | import unittest 6 | 7 | import responses 8 | 9 | 10 | class TestDyndns2(unittest.TestCase): 11 | """Test cases for Dyndns2.""" 12 | 13 | def setUp(self): 14 | """Run setup.""" 15 | self.url = "https://dyndns.example.com/nic/update" 16 | from responses import matchers 17 | responses.add( 18 | responses.GET, 19 | self.url, 20 | match=[matchers.query_string_matcher("myip=127.0.0.1&hostname=dyndns.example.com")], 21 | body="good 127.0.0.1", 22 | status=200, 23 | headers={"Content-Type": "text/plain; charset=utf-8"} 24 | ) 25 | unittest.TestCase.setUp(self) 26 | 27 | def tearDown(self): 28 | """Teardown.""" 29 | unittest.TestCase.tearDown(self) 30 | 31 | @responses.activate 32 | def test_dyndns2(self): 33 | """Run tests.""" 34 | from dyndnsc.updater import dyndns2 35 | NAME = "dyndns2" 36 | theip = "127.0.0.1" 37 | options = { 38 | "hostname": "dyndns.example.com", 39 | "userid": "dummy", "password": "1234", 40 | "url": self.url 41 | } 42 | self.assertTrue( 43 | NAME == dyndns2.UpdateProtocolDyndns2.configuration_key) 44 | self.assertEqual( 45 | NAME, dyndns2.UpdateProtocolDyndns2.configuration_key) 46 | updater = dyndns2.UpdateProtocolDyndns2(**options) 47 | res = updater.update(theip) 48 | self.assertEqual(theip, res) 49 | -------------------------------------------------------------------------------- /dyndnsc/updater/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Package for dyndns updaters.""" 4 | -------------------------------------------------------------------------------- /dyndnsc/updater/afraid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Functionality for interacting with a service compatible with https://freedns.afraid.org/.""" 4 | 5 | import logging 6 | import hashlib 7 | import re 8 | from collections import namedtuple 9 | 10 | import requests 11 | 12 | from .base import UpdateProtocol 13 | from ..common.six import ipaddress 14 | from ..common import constants 15 | 16 | LOG = logging.getLogger(__name__) 17 | 18 | # define a namedtuple for the records returned by the service 19 | AfraidDynDNSRecord = namedtuple( 20 | "AfraidDynDNSRecord", "hostname, ip, update_url") 21 | 22 | 23 | class AfraidCredentials(object): 24 | """ 25 | Minimal container for credentials (userid, password). 26 | 27 | Computes sha checksum lazily if not provided at initialization. 28 | """ 29 | 30 | def __init__(self, userid, password, sha=None): 31 | """ 32 | Initialize. 33 | 34 | :param userid: string user id 35 | :param password: string password 36 | :param sha: optional sha checksum 37 | """ 38 | self._userid = userid 39 | self._password = password 40 | self._sha = sha 41 | 42 | @property 43 | def userid(self): 44 | """Return userid.""" 45 | return self._userid 46 | 47 | @property 48 | def password(self): 49 | """Return password.""" 50 | return self._password 51 | 52 | @property 53 | def sha(self): 54 | """Return sha, lazily compute if not done yet.""" 55 | if self._sha is None: 56 | self._sha = compute_auth_key(self.userid, self.password) 57 | return self._sha 58 | 59 | 60 | def compute_auth_key(userid, password): 61 | """ 62 | Compute the authentication key for freedns.afraid.org. 63 | 64 | This is the SHA1 hash of the string b'userid|password'. 65 | 66 | :param userid: ascii username 67 | :param password: ascii password 68 | :return: ascii authentication key (SHA1 at this point) 69 | """ 70 | import sys 71 | if sys.version_info >= (3, 0): 72 | return hashlib.sha1(b"|".join((userid.encode("ascii"), # noqa: S303, S324 73 | password.encode("ascii")))).hexdigest() 74 | return hashlib.sha1("|".join((userid, password))).hexdigest() # noqa: S303, S324 75 | 76 | 77 | def records(credentials, url="https://freedns.afraid.org/api/"): 78 | """ 79 | Yield the dynamic DNS records associated with this account. 80 | 81 | :param credentials: an AfraidCredentials instance 82 | :param url: the service URL 83 | """ 84 | params = {"action": "getdyndns", "sha": credentials.sha} 85 | req = requests.get( 86 | url, params=params, headers=constants.REQUEST_HEADERS_DEFAULT, timeout=60) 87 | for record_line in (line.strip() for line in req.text.splitlines() 88 | if len(line.strip()) > 0): 89 | yield AfraidDynDNSRecord(*record_line.split("|")) 90 | 91 | 92 | def update(url): 93 | """ 94 | Update remote DNS record by requesting its special endpoint URL. 95 | 96 | This automatically picks the IP address using the HTTP connection: it is not 97 | possible to specify the IP address explicitly. 98 | 99 | :param url: URL to retrieve for triggering the update 100 | :return: IP address 101 | """ 102 | req = requests.get( 103 | url, headers=constants.REQUEST_HEADERS_DEFAULT, timeout=60) 104 | req.close() 105 | # Response must contain an IP address, or else we can't parse it. 106 | # Also, the IP address in the response is the newly assigned IP address. 107 | ipregex = re.compile(r"\b(?P(?:[0-9]{1,3}\.){3}[0-9]{1,3})\b") 108 | ipmatch = ipregex.search(req.text) 109 | if ipmatch: 110 | return str(ipaddress(ipmatch.group("ip"))) 111 | LOG.error("couldn't parse the server's response '%s'", req.text) 112 | return None 113 | 114 | 115 | class UpdateProtocolAfraid(UpdateProtocol): 116 | """Protocol handler for http://freedns.afraid.org .""" 117 | 118 | configuration_key = "afraid" 119 | 120 | def __init__(self, hostname, userid, password, url="https://freedns.afraid.org/api/", **kwargs): 121 | """ 122 | Initialize. 123 | 124 | :param hostname: string hostname 125 | :param userid: string user id 126 | :param password: string password 127 | :param url: option API endpoint URL 128 | """ 129 | self.hostname = hostname 130 | self._credentials = AfraidCredentials(userid, password) 131 | self._url = url 132 | 133 | super(UpdateProtocolAfraid, self).__init__() 134 | 135 | def update(self, *args, **kwargs): 136 | """Update the IP on the remote service.""" 137 | # first find the update_url for the provided account + hostname: 138 | update_url = next((r.update_url for r in 139 | records(self._credentials, self._url) 140 | if r.hostname == self.hostname), None) 141 | if update_url is None: 142 | LOG.warning("Could not find hostname '%s' at '%s'", 143 | self.hostname, self._url) 144 | return None 145 | return update(update_url) 146 | -------------------------------------------------------------------------------- /dyndnsc/updater/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module providing base class and functionality for all update protocols.""" 4 | 5 | import logging 6 | 7 | from ..common.subject import Subject 8 | from ..common.dynamiccli import DynamicCliMixin 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class UpdateProtocol(Subject, DynamicCliMixin): 14 | """Base class for all update protocols that use a simple http GET protocol.""" 15 | 16 | theip = None 17 | __hostname = None # private place to store the property 'hostname' 18 | configuration_key = None 19 | 20 | @property 21 | def hostname(self): 22 | """ 23 | Return the hostname managed by this updater. 24 | 25 | May be implemented or overwritten in updater subclasses. 26 | """ 27 | return self.__hostname 28 | 29 | @hostname.setter 30 | def hostname(self, value): 31 | """ 32 | Set the hostname managed by this updater. 33 | 34 | May be implemented or overwritten in updater subclasses. 35 | """ 36 | self.__hostname = value 37 | 38 | @staticmethod 39 | def configuration_key_prefix(): 40 | """ 41 | Return a human readable string classifying this class as an updater. 42 | 43 | Should not be be implemented or overwritten in updater subclasses. 44 | """ 45 | return "updater" 46 | 47 | def update(self, ip): 48 | """ 49 | Update the hostname on the remote service. 50 | 51 | Abstract method, must be implemented in subclass. 52 | """ 53 | raise NotImplementedError("Please implement in subclass") 54 | -------------------------------------------------------------------------------- /dyndnsc/updater/builtin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | All built-in updater plugins are listed here and will be dynamically imported. 5 | 6 | If importing a plugin fails, it will be silently ignored. 7 | """ 8 | 9 | from ..common.load import load_class as _load_plugin 10 | 11 | _BUILTINS = ( 12 | ("dyndnsc.updater.afraid", "UpdateProtocolAfraid"), 13 | ("dyndnsc.updater.dummy", "UpdateProtocolDummy"), 14 | ("dyndnsc.updater.duckdns", "UpdateProtocolDuckdns"), 15 | ("dyndnsc.updater.dyndns2", "UpdateProtocolDyndns2"), 16 | ("dyndnsc.updater.dnsimple", "UpdateProtocolDnsimple"), 17 | ) 18 | 19 | PLUGINS = {plug for plug in (_load_plugin(m, c) for m, c in _BUILTINS) if plug is not None} 20 | -------------------------------------------------------------------------------- /dyndnsc/updater/dnsimple.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Updater for https://dnsimple.com/ compatible dyndns services. 4 | 5 | This module depends on the python package dnsimple-dyndns from 6 | https://pypi.python.org/pypi/dnsimple-dyndns 7 | If installed, dyndnsc will be able to utilize it. 8 | 9 | Since dnsimple.com is a paid service, I have not had a chance to test this yet. 10 | """ 11 | 12 | import logging 13 | 14 | from dnsimple_dyndns import DNSimple # @UnresolvedImport pylint: disable=import-error 15 | 16 | from .base import UpdateProtocol 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | class UpdateProtocolDnsimple(UpdateProtocol): 22 | """Protocol handler for https://dnsimple.com/ .""" 23 | 24 | configuration_key = "dnsimple" 25 | 26 | def __init__(self, hostname, key, url=None, **kwargs): 27 | """Initialize.""" 28 | self._recordname, _, self._domain = hostname.partition(".") 29 | self.hostname = hostname 30 | self.handler = DNSimple(domain=self._domain, 31 | domain_token=key) 32 | if url is not None: 33 | # The url must be a format string like this: 34 | # 'https://dnsimple.com/domains/%s/records' 35 | self.handler._baseurl = url % self._domain 36 | 37 | super(UpdateProtocolDnsimple, self).__init__() 38 | 39 | def update(self, ip): 40 | """Update the IP on the remote service.""" 41 | return self.handler.update_record(name=self._recordname, 42 | address=ip) 43 | -------------------------------------------------------------------------------- /dyndnsc/updater/duckdns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module containing the logic for updating DNS records using the duckdns protocol. 4 | 5 | From the duckdns.org website: 6 | 7 | https://{DOMAIN}/update?domains={DOMAINLIST}&token={TOKEN}&ip={IP} 8 | 9 | where: 10 | DOMAIN the service domain 11 | DOMAINLIST is either a single domain or a comma separated list of domains 12 | TOKEN is the API token for authentication/authorization 13 | IP is either the IP or blank for auto-detection 14 | 15 | """ 16 | 17 | from logging import getLogger 18 | 19 | import requests 20 | 21 | from .base import UpdateProtocol 22 | from ..common import constants 23 | 24 | LOG = getLogger(__name__) 25 | 26 | 27 | class UpdateProtocolDuckdns(UpdateProtocol): 28 | """Updater for services compatible with the duckdns protocol.""" 29 | 30 | configuration_key = "duckdns" 31 | 32 | def __init__(self, hostname, token, url, *args, **kwargs): 33 | """ 34 | Initialize. 35 | 36 | :param hostname: the fully qualified hostname to be managed 37 | :param token: the token for authentication 38 | :param url: the API URL for updating the DNS entry 39 | """ 40 | self.hostname = hostname 41 | self.__token = token 42 | self._updateurl = url 43 | 44 | super(UpdateProtocolDuckdns, self).__init__() 45 | 46 | def update(self, ip): 47 | """Update the IP on the remote service.""" 48 | timeout = 60 49 | LOG.debug("Updating '%s' to '%s' at service '%s'", self.hostname, ip, self._updateurl) 50 | params = {"domains": self.hostname.partition(".")[0], "token": self.__token} 51 | if ip is None: 52 | params["ip"] = "" 53 | else: 54 | params["ip"] = ip 55 | # LOG.debug("Update params: %r", params) 56 | req = requests.get(self._updateurl, params=params, headers=constants.REQUEST_HEADERS_DEFAULT, 57 | timeout=timeout) 58 | LOG.debug("status %i, %s", req.status_code, req.text) 59 | # duckdns response codes seem undocumented... 60 | if req.status_code == 200: 61 | if req.text.startswith("OK"): 62 | return ip 63 | return req.text 64 | return "invalid http status code: %s" % req.status_code 65 | -------------------------------------------------------------------------------- /dyndnsc/updater/dummy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module providing a dummy updater.""" 4 | 5 | from .base import UpdateProtocol 6 | 7 | 8 | class UpdateProtocolDummy(UpdateProtocol): 9 | """The dummy update protocol.""" 10 | 11 | _updateurl = "http://localhost.nonexistant/nic/update" 12 | configuration_key = "dummy" 13 | 14 | def __init__(self, hostname, **kwargs): 15 | """ 16 | Initialize. 17 | 18 | :param hostname: string hostname 19 | """ 20 | self.hostname = hostname 21 | super(UpdateProtocolDummy, self).__init__() 22 | 23 | def update(self, ip): 24 | """Pretend to update the IP on the remote service.""" 25 | return ip 26 | -------------------------------------------------------------------------------- /dyndnsc/updater/dyndns2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Module providing functionality to interact with dyndns2 compatible services.""" 4 | 5 | from logging import getLogger 6 | 7 | import requests 8 | 9 | from .base import UpdateProtocol 10 | from ..common import constants 11 | 12 | LOG = getLogger(__name__) 13 | 14 | 15 | class UpdateProtocolDyndns2(UpdateProtocol): 16 | """Updater for services compatible with the dyndns2 protocol.""" 17 | 18 | configuration_key = "dyndns2" 19 | 20 | def __init__(self, hostname, userid, password, url, *args, **kwargs): 21 | """ 22 | Initialize. 23 | 24 | :param hostname: the fully qualified hostname to be managed 25 | :param userid: the userid for identification 26 | :param password: the password for authentication 27 | :param url: the API URL for updating the DNS entry 28 | """ 29 | self.hostname = hostname 30 | self.__userid = userid 31 | self.__password = password 32 | self._updateurl = url 33 | 34 | super(UpdateProtocolDyndns2, self).__init__() 35 | 36 | def update(self, ip): 37 | """Update the IP on the remote service.""" 38 | timeout = 60 39 | LOG.debug("Updating '%s' to '%s' at service '%s'", self.hostname, ip, self._updateurl) 40 | params = {"myip": ip, "hostname": self.hostname} 41 | req = requests.get(self._updateurl, params=params, headers=constants.REQUEST_HEADERS_DEFAULT, 42 | auth=(self.__userid, self.__password), timeout=timeout) 43 | LOG.debug("status %i, %s", req.status_code, req.text) 44 | if req.status_code == 200: 45 | # responses can also be "nohost", "abuse", "911", "notfqdn" 46 | if req.text.startswith("good ") or req.text.startswith("nochg"): 47 | return ip 48 | return req.text 49 | return "invalid http status code: %s" % req.status_code 50 | -------------------------------------------------------------------------------- /dyndnsc/updater/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Management of updaters.""" 4 | 5 | from ..common.load import find_class 6 | 7 | 8 | def updater_classes(): 9 | """Return all built-in updater classes.""" 10 | from .builtin import PLUGINS 11 | return PLUGINS 12 | 13 | 14 | def get_updater_class(name="noip"): 15 | """Return updater class identified by configuration key ``name``.""" 16 | return find_class(name, updater_classes()) 17 | -------------------------------------------------------------------------------- /example.ini: -------------------------------------------------------------------------------- 1 | [dyndnsc] 2 | configs = testnoip, testnsupdate 3 | 4 | [testnoip] 5 | use_preset = no-ip.com 6 | detector = dnswanip 7 | updater-userid = dyndnsc 8 | updater-password = *********** 9 | updater-hostname = dyndnsc.no-ip.biz 10 | 11 | [testnsupdate] 12 | use_preset = nsupdate.info:ipv4 13 | updater-userid = python-dyndnsc.nsupdate.info 14 | updater-password = ********** 15 | updater-hostname = python-dyndnsc.nsupdate.info 16 | -------------------------------------------------------------------------------- /packaging/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # Vagrantfile API/syntax version. Don't touch unless you know what you're doing! 5 | VAGRANTFILE_API_VERSION = "2" 6 | 7 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 8 | config.vm.box = "puphpet/debian75-x64" 9 | 10 | # Share an additional folder to the guest VM. The first argument is 11 | # the path on the host to the actual folder. The second argument is 12 | # the path on the guest to mount the folder. And the optional third 13 | # argument is a set of non-required options. 14 | config.vm.synced_folder "../", "/vagrant" 15 | 16 | config.vm.provision "shell", path: "provision.sh" 17 | 18 | end 19 | -------------------------------------------------------------------------------- /packaging/debian/README.rst: -------------------------------------------------------------------------------- 1 | Dyndnsc Debian Package 2 | ====================== 3 | 4 | To create a DEB package: 5 | 6 | sudo apt-get install python-setuptools 7 | sudo apt-get install build-essential devscripts debhelper 8 | git clone git://github.com/infothrill/python-dyndnsc.git 9 | cd python-dyndnsc 10 | # TODO: 11 | make deb 12 | 13 | The debian package file will be placed in the `../` directory. This can then be added to an APT repository or installed with `dpkg -i `. 14 | 15 | Note that `dpkg -i` does not resolve dependencies. 16 | 17 | To install the DEB package and resolve dependencies: 18 | 19 | sudo dpkg -i 20 | sudo apt-get -fy install 21 | -------------------------------------------------------------------------------- /packaging/debian/changelog: -------------------------------------------------------------------------------- 1 | dyndnsc (0.4) unstable; urgency=low 2 | 3 | * 0.4 release (PENDING) 4 | 5 | -- Paul Kremer Wed, 11 Feb 2015 04:29:00 -0500 6 | -------------------------------------------------------------------------------- /packaging/debian/compat: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /packaging/debian/control: -------------------------------------------------------------------------------- 1 | Source: dyndnsc 2 | Section: admin 3 | Priority: optional 4 | Standards-Version: 3.9.3 5 | Maintainer: Paul Kremer 6 | Build-Depends: cdbs, debhelper (>= 5.0.0), python, python-support, python-setuptools 7 | Homepage: https://github.com/infothrill/python-dyndnsc 8 | 9 | Package: dyndnsc 10 | Architecture: all 11 | Depends: python, python-support (>= 0.90), ${misc:Depends} 12 | Description: dynamic dns update client 13 | dyndnsc is both a script to be used directly as well as a re-usable and 14 | hopefully extensible python package for doing updates over http to dynamic 15 | dns services. This package currently focuses on supporting different http 16 | based update protocols. 17 | -------------------------------------------------------------------------------- /packaging/debian/copyright: -------------------------------------------------------------------------------- 1 | This package was debianized by Paul Kremer . 2 | 3 | It was downloaded from https://github.com/infothrill/python-dyndnsc.git 4 | 5 | Copyright: Paul Kremer 6 | 7 | License: 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /packaging/debian/docs: -------------------------------------------------------------------------------- 1 | README.rst 2 | -------------------------------------------------------------------------------- /packaging/debian/dyndnsc.dirs: -------------------------------------------------------------------------------- 1 | etc/dyndnsc 2 | usr/lib/python2.7/site-packages 3 | usr/share/dyndnsc 4 | -------------------------------------------------------------------------------- /packaging/debian/dyndnsc.install: -------------------------------------------------------------------------------- 1 | docs/man/man1/*.1 usr/share/man/man1 2 | bin/* usr/bin 3 | example.ini etc/dyndnsc 4 | -------------------------------------------------------------------------------- /packaging/debian/pycompat: -------------------------------------------------------------------------------- 1 | 2 2 | -------------------------------------------------------------------------------- /packaging/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -- makefile -- 3 | 4 | include /usr/share/cdbs/1/rules/debhelper.mk 5 | DEB_PYTHON_SYSTEM = pysupport 6 | include /usr/share/cdbs/1/class/python-distutils.mk 7 | -------------------------------------------------------------------------------- /packaging/docker/.gitignore: -------------------------------------------------------------------------------- 1 | src 2 | qemu* 3 | -------------------------------------------------------------------------------- /packaging/docker/Makefile: -------------------------------------------------------------------------------- 1 | # Get the tag from an external script 2 | TAG := $(shell ./prepare_source.sh) 3 | # DIR points to the directory with the Dockerfile to be built: 4 | BUILD ?= x86-alpine 5 | DIR ?= $(addprefix $(PWD), /$(BUILD)) 6 | DOCKER_REPO ?= dyndnsc-$(shell basename $(DIR)) 7 | IMAGE_NAME ?= $(DOCKER_REPO):$(TAG) 8 | 9 | default: build 10 | 11 | build: 12 | docker build -t $(IMAGE_NAME) -f $(DIR)/Dockerfile . 13 | # docker tag $(IMAGE_NAME) $(DOCKER_REPO):latest 14 | 15 | push: 16 | docker push $(IMAGE_NAME) 17 | docker push $(DOCKER_REPO) 18 | 19 | test: 20 | docker run --rm $(IMAGE_NAME) /usr/bin/dyndnsc --help 21 | 22 | rmi: 23 | docker rmi -f $(IMAGE_NAME) 24 | 25 | post_checkout: 26 | # arm builds on docker hub, from https://github.com/docker/hub-feedback/issues/1261 27 | curl -L https://github.com/balena-io/qemu/releases/download/v5.2.0%2Bbalena4/qemu-5.2.0.balena4-arm.tar.gz | tar zxvf - -C . && mv qemu-5.2.0+balena4-arm/qemu-arm-static . 28 | docker run --rm --privileged multiarch/qemu-user-static:register --reset 29 | 30 | post_push: 31 | docker tag $(IMAGE_NAME) $(DOCKER_REPO):$(TAG) 32 | docker push $(DOCKER_REPO):$(TAG) 33 | 34 | rebuild: rmi build 35 | 36 | clean: rmi 37 | rm -rf qemu* src 38 | -------------------------------------------------------------------------------- /packaging/docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker images for `dyndnsc` 2 | 3 | ## Usage 4 | 5 | ### x86 6 | 7 | docker pull infothrill/dyndnsc-x86-alpine 8 | 9 | ### arm 10 | 11 | docker pull infothrill/dyndnsc-arm32v7-ubuntu 12 | 13 | ### Running 14 | 15 | docker run -v /etc/dyndnsc.ini:/etc/dyndnsc.ini:ro -t dyndnsc-x86-alpine -vv -c /etc/dyndnsc.ini --loop --log-json 16 | 17 | For further reference, please consult https://dyndnsc.readthedocs.io/ 18 | 19 | ## Building 20 | 21 | The surrounding scripting allows to 22 | 23 | * build locally: `make build` 24 | * build armhf image: `BUILD=armhf-alpine make post_checkout build` 25 | * build x86 and armhf on hub.docker.com using hooks 26 | -------------------------------------------------------------------------------- /packaging/docker/arm32v7-ubuntu/Dockerfile: -------------------------------------------------------------------------------- 1 | # use multi stage dockerfile to first create a virtualenv, 2 | # then copy the virtualenv (without all build deps) into target image 3 | 4 | FROM ghcr.io/linuxserver/baseimage-ubuntu:arm32v7-focal as build 5 | RUN apt-get update && apt-get upgrade -y 6 | RUN apt-get install -y python3 python3-virtualenv python3-dev python3-wheel gcc 7 | COPY qemu-arm-static /usr/bin 8 | ADD src /src 9 | RUN virtualenv /usr/local/dyndnsc && \ 10 | /usr/local/dyndnsc/bin/pip install /src 11 | 12 | FROM ghcr.io/linuxserver/baseimage-ubuntu:arm32v7-focal 13 | RUN apt-get update && \ 14 | apt-get upgrade -y && \ 15 | apt-get install -y python3 && \ 16 | rm -rf /var/cache/apt/* 17 | COPY --from=build /usr/local/dyndnsc /usr/local/dyndnsc 18 | RUN ln -s /usr/local/dyndnsc/bin/dyndnsc /usr/local/bin/dyndnsc 19 | 20 | ENTRYPOINT ["/usr/local/bin/dyndnsc"] 21 | -------------------------------------------------------------------------------- /packaging/docker/arm32v7-ubuntu/hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR=$(pwd) make -C .. post_checkout build post_push 3 | -------------------------------------------------------------------------------- /packaging/docker/prepare_source.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | # this allows us to copy the source into the docker image without 3 | # bothering to install git or other more complicated logic inside it. Also, 4 | # this script outputs the current version so we can tag the created image 5 | # with it. 6 | if ! test -d src; 7 | then 8 | cat /etc/*release 1>&2 || true # display distribution name 9 | env 1>&2 # display env vars 10 | id 1>&2 # display current user 11 | if ! hash tox 2>&-; 12 | then 13 | if ! hash virtualenv 2>&-; 14 | then 15 | if [ "$(id -u)" = 0 ]; then 16 | apt-get update 1>&2 && apt-get install -y python-virtualenv 1>&2 17 | fi 18 | # else: no idea 19 | fi 20 | virtualenv .venv 1>&2 21 | source .venv/bin/activate 22 | pip install tox 1>&2 23 | fi 24 | tox -e build 1>&2 25 | mkdir src 26 | ls ../../dist/dyndnsc-*.tar.gz | xargs -n1 tar -C src --strip-components 1 -xzf 27 | python -c "import re, os; print(re.compile(r'.*__version__ = \"(.*?)\"', re.S).match(open(os.path.join('src/dyndnsc', '__init__.py'), 'r').read()).group(1))" > src/version 28 | fi 29 | cat src/version 30 | -------------------------------------------------------------------------------- /packaging/docker/x86-alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | # use multi stage dockerfile to first create a virtualenv, 2 | # then copy the virtualenv (without all build deps) into target image 3 | 4 | FROM alpine:3.19 as build 5 | RUN apk -U update && apk -U upgrade 6 | RUN apk -U add python3 py3-virtualenv python3-dev gcc musl-dev linux-headers 7 | ADD src /src 8 | RUN virtualenv /usr/local/dyndnsc && \ 9 | /usr/local/dyndnsc/bin/pip install /src 10 | 11 | FROM alpine:3.19 12 | RUN apk -U update && \ 13 | apk -U upgrade && \ 14 | apk -U add --no-cache python3 && \ 15 | rm -rf /var/cache/apk/* 16 | COPY --from=build /usr/local/dyndnsc /usr/local/dyndnsc 17 | RUN ln -s /usr/local/dyndnsc/bin/dyndnsc /usr/local/bin/dyndnsc 18 | 19 | ENTRYPOINT ["/usr/local/bin/dyndnsc"] 20 | -------------------------------------------------------------------------------- /packaging/docker/x86-alpine/hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR=$(pwd) make -C .. build post_push 3 | -------------------------------------------------------------------------------- /packaging/docker/x86-ubuntu/Dockerfile: -------------------------------------------------------------------------------- 1 | # use multi stage dockerfile to first create a virtualenv, 2 | # then copy the virtualenv (without all build deps) into target image 3 | 4 | FROM ghcr.io/linuxserver/baseimage-ubuntu:focal as build 5 | RUN apt-get update && apt-get upgrade -y 6 | RUN apt-get install -y python3 python3-virtualenv python3-wheel python3-dev gcc 7 | ADD src /src 8 | RUN virtualenv /usr/local/dyndnsc && \ 9 | /usr/local/dyndnsc/bin/pip install /src 10 | 11 | FROM ghcr.io/linuxserver/baseimage-ubuntu:focal 12 | RUN apt-get update && \ 13 | apt-get upgrade -y && \ 14 | apt-get install -y python3 && \ 15 | rm -rf /var/cache/apt/* 16 | COPY --from=build /usr/local/dyndnsc /usr/local/dyndnsc 17 | RUN ln -s /usr/local/dyndnsc/bin/dyndnsc /usr/local/bin/dyndnsc 18 | 19 | ENTRYPOINT ["/usr/local/bin/dyndnsc"] 20 | -------------------------------------------------------------------------------- /packaging/docker/x86-ubuntu/hooks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR=$(pwd) make -C .. build post_push 3 | -------------------------------------------------------------------------------- /packaging/provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sudo apt-get update 3 | sudo apt-get install -y python3-setuptools build-essential devscripts debhelper cdbs 4 | -------------------------------------------------------------------------------- /requirements-style.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.7.5 2 | pydocstyle==6.3.0 3 | flake8-bandit>=1.0.1 4 | flake8-bugbear>=17.12.0 5 | flake8-comprehensions>=1.4.1 6 | flake8_docstrings>=1.1.0 7 | flake8-tidy-imports>=1.1.0 8 | flake8-rst-docstrings>=0.0.8 9 | Pygments>=2.2.0 10 | flake8-quotes>=0.13.0 11 | flake8-print>=3.0.1 12 | flake8-mutable>=1.2.0 13 | flake8-string-format>=0.2.3 14 | check-manifest>=0.36 15 | safety>=1.6.1 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # The dependencies listed here are not formally required to install the 2 | # package from pypi. Some functions are only enabled if certain packages 3 | # are installed, so we install them here. 4 | dnsimple-dyndns==0.1 5 | coverage==7.2.7 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | # https://docs.pytest.org/en/latest/goodpractices.html#integrating-with-setuptools-python-setup-py-test-pytest-runner 5 | [aliases] 6 | test=pytest 7 | 8 | [flake8] 9 | #ignore = F821,F401,B101 10 | exclude = .git,__pycache__,build,dist,.tox,.eggs,.direnv,docs/conf.py 11 | max_line_length = 120 12 | # flake8-quotes: 13 | inline-quotes = double 14 | 15 | [tool:pytest] 16 | # https://pypi.python.org/pypi/pytest-warnings 17 | filterwarnings= ignore 18 | once::DeprecationWarning 19 | 20 | [check-manifest] 21 | # https://pypi.python.org/pypi/check-manifest 22 | ignore = 23 | .coveragerc 24 | .pre-commit-config.yaml 25 | .pylintrc 26 | .github 27 | .renovaterc.json 28 | Makefile 29 | dyndns.plist 30 | packaging 31 | packaging/* 32 | tox.ini 33 | requirements-style.txt 34 | requirements.txt 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Setup for dyndnsc.""" 5 | 6 | import os 7 | import re 8 | import sys 9 | from setuptools import setup 10 | from setuptools import __version__ as setuptools_version 11 | 12 | BASEDIR = os.path.dirname(__file__) 13 | 14 | with open(os.path.join(BASEDIR, "dyndnsc", "__init__.py"), "r") as f: 15 | PACKAGE_INIT = f.read() 16 | 17 | VERSION = re.compile( 18 | r".*__version__ = \"(.*?)\"", re.S).match(PACKAGE_INIT).group(1) 19 | 20 | with open(os.path.join(BASEDIR, "README.rst"), "r") as f: 21 | README = f.read() 22 | 23 | with open(os.path.join(BASEDIR, "CHANGELOG.rst"), "r") as f: 24 | CHANGELOG = f.read() 25 | 26 | 27 | CLASSIFIERS = [ 28 | "Development Status :: 4 - Beta", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: System Administrators", 31 | "License :: DFSG approved", 32 | "License :: OSI Approved", 33 | "License :: OSI Approved :: MIT License", 34 | "Topic :: Internet", 35 | "Topic :: Internet :: Name Service (DNS)", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | "Topic :: System :: Systems Administration", 38 | "Environment :: Console", 39 | "Natural Language :: English", 40 | "Operating System :: MacOS :: MacOS X", 41 | "Operating System :: POSIX :: Linux", 42 | "Operating System :: POSIX :: BSD", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3.7", 45 | "Programming Language :: Python :: 3.8", 46 | "Programming Language :: Python :: 3.9", 47 | "Programming Language :: Python :: 3.10" 48 | ] 49 | 50 | INSTALL_REQUIRES = [ 51 | "daemonocle>=1.0.1", 52 | "dnspython>=1.15.0", 53 | "netifaces>=0.10.5", 54 | "requests>=2.0.1", 55 | "json-logging", 56 | "setuptools", 57 | ] 58 | 59 | TESTS_REQUIRE = [ 60 | "pytest>=4.0.0", 61 | "pytest-console-scripts", 62 | "responses>=0.19.0" 63 | ] 64 | 65 | EXTRAS_REQUIRE = {} 66 | 67 | # See https://hynek.me/articles/conditional-python-dependencies/ 68 | # for a good explanation of this hackery. 69 | if int(setuptools_version.split(".", 1)[0]) < 18: 70 | # For legacy setuptools + sdist 71 | assert "bdist_wheel" not in sys.argv, "setuptools 18 required for wheels." # noqa: S101 72 | 73 | setup( 74 | name="dyndnsc", 75 | packages=[ 76 | "dyndnsc", 77 | "dyndnsc.common", 78 | "dyndnsc.detector", 79 | "dyndnsc.plugins", 80 | "dyndnsc.resources", 81 | "dyndnsc.tests", 82 | "dyndnsc.updater", 83 | ], 84 | version=VERSION, 85 | author="Paul Kremer", 86 | author_email="@".join(("paul", "spurious.biz")), # avoid spam, 87 | license="MIT License", 88 | description="dynamic dns (dyndns) update client with support for multiple " 89 | "protocols", 90 | long_description=README + "\n\n" + CHANGELOG, 91 | keywords="dynamic dns dyndns", 92 | url="https://github.com/infothrill/python-dyndnsc", 93 | # https://packaging.python.org/tutorials/distributing-packages/#python-requires 94 | python_requires=">=3.7", 95 | setup_requires=["pytest-runner"], 96 | install_requires=INSTALL_REQUIRES, 97 | extras_require=EXTRAS_REQUIRE, 98 | entry_points={ 99 | "console_scripts": [ 100 | "dyndnsc=dyndnsc.cli:main", 101 | ], 102 | }, 103 | classifiers=CLASSIFIERS, 104 | test_suite="dyndnsc.tests", 105 | tests_require=TESTS_REQUIRE, 106 | package_data={"dyndnsc/resources": ["dyndnsc/resources/*.ini"]}, 107 | include_package_data=True, 108 | zip_safe=False 109 | ) 110 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) 2 | 3 | [tox] 4 | minversion = 2.0 5 | envlist = pypy, py37, py38, py39, py310, style, docs 6 | skipsdist=True 7 | 8 | [testenv] 9 | deps = -rrequirements.txt 10 | commands = coverage run --source dyndnsc setup.py test 11 | 12 | [testenv:style] 13 | basepython = python3 14 | deps = -rrequirements-style.txt 15 | commands = flake8 {posargs} --count --statistics 16 | flake8 --version 17 | check-manifest -v 18 | # Check for security issues in installed packages 19 | safety check --full-report 20 | 21 | [testenv:docs] 22 | basepython=python3 23 | whitelist_externals = cd 24 | deps = -rdocs/requirements.txt 25 | commands= 26 | {envpython} setup.py develop 27 | cd docs && sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 28 | 29 | # Release tooling 30 | [testenv:build] 31 | basepython = python3 32 | skip_install = true 33 | deps = 34 | wheel 35 | setuptools 36 | commands = 37 | python setup.py -q sdist bdist_wheel 38 | 39 | [testenv:release] 40 | basepython = python3 41 | skip_install = true 42 | deps = 43 | {[testenv:build]deps} 44 | twine >= 1.5.0 45 | commands = 46 | {[testenv:build]commands} 47 | twine upload --skip-existing dist/* 48 | 49 | [gh-actions] 50 | python = 51 | 3.7: py37 52 | 3.8: py38 53 | 3.9: py39, style 54 | 3.10: py310 55 | --------------------------------------------------------------------------------