├── requirements.txt ├── scripts ├── docker-run.sh ├── docker-build.sh ├── docker-publish.sh └── docker-build-all.sh ├── .github ├── FUNDING.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── image.yml ├── feature-graphic.jpg ├── systemd ├── cloudflare-ddns.timer └── cloudflare-ddns.service ├── start-sync.sh ├── docker └── docker-compose.yml ├── Dockerfile ├── .vscode └── settings.json ├── config-example.json ├── k8s └── cloudflare-ddns.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── cloudflare-ddns.py ├── README.md └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.31.0 -------------------------------------------------------------------------------- /scripts/docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker run timothyjmiller/cloudflare-ddns:latest 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [timothymiller] 4 | -------------------------------------------------------------------------------- /feature-graphic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timothymiller/cloudflare-ddns/HEAD/feature-graphic.jpg -------------------------------------------------------------------------------- /scripts/docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | BASH_DIR=$(dirname $(realpath "${BASH_SOURCE}")) 3 | docker build --platform linux/amd64 --tag timothyjmiller/cloudflare-ddns:latest ${BASH_DIR}/../ 4 | -------------------------------------------------------------------------------- /systemd/cloudflare-ddns.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Update DDNS on Cloudflare every 15 minutes 3 | 4 | [Timer] 5 | OnBootSec=2min 6 | OnUnitActiveSec=15m 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /scripts/docker-publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | BASH_DIR=$(dirname $(realpath "${BASH_SOURCE}")) 3 | docker buildx build --platform linux/ppc64le,linux/s390x,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64 --tag timothyjmiller/cloudflare-ddns:latest --push ${BASH_DIR}/../ 4 | -------------------------------------------------------------------------------- /scripts/docker-build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | BASH_DIR=$(dirname $(realpath "${BASH_SOURCE}")) 3 | docker buildx build --platform linux/ppc64le,linux/s390x,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64 --tag timothyjmiller/cloudflare-ddns:latest ${BASH_DIR}/../ 4 | # TODO: Support linux/riscv64 5 | -------------------------------------------------------------------------------- /start-sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | 4 | python3 -m venv venv 5 | source ./venv/bin/activate 6 | 7 | cd $DIR 8 | set -o pipefail; pip install -r requirements.txt | { grep -v "already satisfied" || :; } 9 | 10 | python3 cloudflare-ddns.py 11 | -------------------------------------------------------------------------------- /systemd/cloudflare-ddns.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Update DDNS on Cloudflare 3 | ConditionPathExists=/etc/cloudflare-ddns/config.json 4 | Wants=network-online.target 5 | After=network-online.target 6 | 7 | [Service] 8 | Type=oneshot 9 | Environment=CONFIG_PATH=/etc/cloudflare-ddns 10 | ExecStart=cloudflare-ddns 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'pip' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | 8 | - package-ecosystem: 'docker' 9 | directory: '/' 10 | schedule: 11 | interval: 'daily' 12 | 13 | - package-ecosystem: 'github-actions' 14 | directory: '/' 15 | schedule: 16 | interval: 'daily' 17 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | cloudflare-ddns: 4 | image: timothyjmiller/cloudflare-ddns:latest 5 | container_name: cloudflare-ddns 6 | security_opt: 7 | - no-new-privileges:true 8 | network_mode: 'host' 9 | environment: 10 | - PUID=1000 11 | - PGID=1000 12 | volumes: 13 | - /YOUR/PATH/HERE/config.json:/config.json 14 | restart: unless-stopped 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Base ---- 2 | FROM python:alpine AS base 3 | 4 | # 5 | # ---- Dependencies ---- 6 | FROM base AS dependencies 7 | # install dependencies 8 | COPY requirements.txt . 9 | RUN pip install --user -r requirements.txt 10 | 11 | # 12 | # ---- Release ---- 13 | FROM base AS release 14 | # copy installed dependencies and project source file(s) 15 | WORKDIR / 16 | COPY --from=dependencies /root/.local /root/.local 17 | COPY cloudflare-ddns.py . 18 | CMD ["python", "-u", "/cloudflare-ddns.py", "--repeat"] 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | ".github": true, 10 | ".gitignore": true, 11 | ".vscode": true, 12 | "Dockerfile": true, 13 | "LICENSE": true, 14 | "requirements.txt": true, 15 | "venv": true 16 | }, 17 | "explorerExclude.backup": {}, 18 | "python.linting.pylintEnabled": true, 19 | "python.linting.enabled": true, 20 | "python.formatting.provider": "autopep8" 21 | } 22 | -------------------------------------------------------------------------------- /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "cloudflare": [ 3 | { 4 | "authentication": { 5 | "api_token": "api_token_here", 6 | "api_key": { 7 | "api_key": "api_key_here", 8 | "account_email": "your_email_here" 9 | } 10 | }, 11 | "zone_id": "your_zone_id_here", 12 | "subdomains": [ 13 | { 14 | "name": "", 15 | "proxied": false 16 | }, 17 | { 18 | "name": "remove_or_replace_with_your_subdomain", 19 | "proxied": false 20 | } 21 | ] 22 | } 23 | ], 24 | "a": true, 25 | "aaaa": true, 26 | "purgeUnknownRecords": false, 27 | "ttl": 300 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /k8s/cloudflare-ddns.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: cloudflare-ddns 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: cloudflare-ddns 9 | 10 | template: 11 | metadata: 12 | labels: 13 | app: cloudflare-ddns 14 | 15 | spec: 16 | containers: 17 | - name: cloudflare-ddns 18 | image: timothyjmiller/cloudflare-ddns:latest 19 | resources: 20 | limits: 21 | memory: '32Mi' 22 | cpu: '50m' 23 | env: 24 | - name: CONFIG_PATH 25 | value: '/etc/cloudflare-ddns/' 26 | volumeMounts: 27 | - mountPath: '/etc/cloudflare-ddns' 28 | name: config-cloudflare-ddns 29 | readOnly: true 30 | volumes: 31 | - name: config-cloudflare-ddns 32 | secret: 33 | secretName: config-cloudflare-ddns 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: 'timothymiller' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Arch Linux] 28 | - Browser [e.g. Chrome, Safari] 29 | - Version [e.g. 60] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone X] 33 | - OS: [e.g. iOS 14.0] 34 | - Browser [e.g. stock browser, Safari] 35 | - Version [e.g. 60] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Private API keys for updating IPv4 & IPv6 addresses on Cloudflare 2 | config.json 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Jupyter Notebook 44 | .ipynb_checkpoints 45 | 46 | # IPython 47 | profile_default/ 48 | ipython_config.py 49 | 50 | # pyenv 51 | .python-version 52 | 53 | # Environments 54 | .env 55 | .venv 56 | env/ 57 | venv/ 58 | ENV/ 59 | env.bak/ 60 | venv.bak/ 61 | 62 | # Git History 63 | **/.history/* 64 | -------------------------------------------------------------------------------- /.github/workflows/image.yml: -------------------------------------------------------------------------------- 1 | name: Build cloudflare-ddns Docker image (multi-arch) 2 | 3 | on: 4 | push: 5 | branches: master 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | # https://github.com/docker/setup-qemu-action 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v1 17 | # https://github.com/docker/setup-buildx-action 18 | - name: Setting up Docker Buildx 19 | uses: docker/setup-buildx-action@v1 20 | - name: Login to DockerHub 21 | if: github.event_name != 'pull_request' 22 | uses: docker/login-action@v1 23 | with: 24 | username: ${{ secrets.DOCKER_USERNAME }} 25 | password: ${{ secrets.DOCKER_PASSWORD }} 26 | 27 | - name: Extract branch name 28 | shell: bash 29 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 30 | id: extract_branch 31 | - name: Docker meta 32 | id: meta 33 | uses: docker/metadata-action@v3 34 | with: 35 | images: timothyjmiller/cloudflare-ddns 36 | sep-tags: ',' 37 | flavor: | 38 | latest=false 39 | tags: | 40 | type=raw,enable=${{ steps.extract_branch.outputs.branch == 'master' }},value=latest 41 | type=schedule 42 | type=ref,event=pr 43 | 44 | - name: Build and publish 45 | uses: docker/build-push-action@v2 46 | with: 47 | context: . 48 | push: ${{ github.event_name != 'pull_request' }} 49 | tags: ${{ steps.meta.outputs.tags }} 50 | platforms: linux/ppc64le,linux/s390x,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64 51 | labels: | 52 | org.opencontainers.image.source=${{ github.event.repository.html_url }} 53 | org.opencontainers.image.created=${{ steps.meta.outputs.created }} 54 | org.opencontainers.image.revision=${{ github.sha }} 55 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /cloudflare-ddns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # cloudflare-ddns.py 3 | # Summary: Access your home network remotely via a custom domain name without a static IP! 4 | # Description: Access your home network remotely via a custom domain 5 | # Access your home network remotely via a custom domain 6 | # A small, 🕵️ privacy centric, and ⚡ 7 | # lightning fast multi-architecture Docker image for self hosting projects. 8 | 9 | __version__ = "1.0.2" 10 | 11 | from string import Template 12 | 13 | import json 14 | import os 15 | import signal 16 | import sys 17 | import threading 18 | import time 19 | import requests 20 | 21 | CONFIG_PATH = os.environ.get('CONFIG_PATH', os.getcwd()) 22 | # Read in all environment variables that have the correct prefix 23 | ENV_VARS = {key: value for (key, value) in os.environ.items() if key.startswith('CF_DDNS_')} 24 | 25 | class GracefulExit: 26 | def __init__(self): 27 | self.kill_now = threading.Event() 28 | signal.signal(signal.SIGINT, self.exit_gracefully) 29 | signal.signal(signal.SIGTERM, self.exit_gracefully) 30 | 31 | def exit_gracefully(self, signum, frame): 32 | print("🛑 Stopping main thread...") 33 | self.kill_now.set() 34 | 35 | 36 | def deleteEntries(type): 37 | # Helper function for deleting A or AAAA records 38 | # in the case of no IPv4 or IPv6 connection, yet 39 | # existing A or AAAA records are found. 40 | for option in config["cloudflare"]: 41 | answer = cf_api( 42 | "zones/" + option['zone_id'] + 43 | "/dns_records?per_page=100&type=" + type, 44 | "GET", option) 45 | if answer is None or answer["result"] is None: 46 | time.sleep(5) 47 | return 48 | for record in answer["result"]: 49 | identifier = str(record["id"]) 50 | cf_api( 51 | "zones/" + option['zone_id'] + "/dns_records/" + identifier, 52 | "DELETE", option) 53 | print("🗑️ Deleted stale record " + identifier) 54 | 55 | 56 | def getIPs(): 57 | a = None 58 | aaaa = None 59 | global ipv4_enabled 60 | global ipv6_enabled 61 | global purgeUnknownRecords 62 | if ipv4_enabled: 63 | try: 64 | a = requests.get( 65 | "https://1.1.1.1/cdn-cgi/trace").text.split("\n") 66 | a.pop() 67 | a = dict(s.split("=") for s in a)["ip"] 68 | except Exception: 69 | global shown_ipv4_warning 70 | if not shown_ipv4_warning: 71 | shown_ipv4_warning = True 72 | print("🧩 IPv4 not detected via 1.1.1.1, trying 1.0.0.1") 73 | # Try secondary IP check 74 | try: 75 | a = requests.get( 76 | "https://1.0.0.1/cdn-cgi/trace").text.split("\n") 77 | a.pop() 78 | a = dict(s.split("=") for s in a)["ip"] 79 | except Exception: 80 | global shown_ipv4_warning_secondary 81 | if not shown_ipv4_warning_secondary: 82 | shown_ipv4_warning_secondary = True 83 | print("🧩 IPv4 not detected via 1.0.0.1. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") 84 | if purgeUnknownRecords: 85 | deleteEntries("A") 86 | if ipv6_enabled: 87 | try: 88 | aaaa = requests.get( 89 | "https://[2606:4700:4700::1111]/cdn-cgi/trace").text.split("\n") 90 | aaaa.pop() 91 | aaaa = dict(s.split("=") for s in aaaa)["ip"] 92 | except Exception: 93 | global shown_ipv6_warning 94 | if not shown_ipv6_warning: 95 | shown_ipv6_warning = True 96 | print("🧩 IPv6 not detected via 1.1.1.1, trying 1.0.0.1") 97 | try: 98 | aaaa = requests.get( 99 | "https://[2606:4700:4700::1001]/cdn-cgi/trace").text.split("\n") 100 | aaaa.pop() 101 | aaaa = dict(s.split("=") for s in aaaa)["ip"] 102 | except Exception: 103 | global shown_ipv6_warning_secondary 104 | if not shown_ipv6_warning_secondary: 105 | shown_ipv6_warning_secondary = True 106 | print("🧩 IPv6 not detected via 1.0.0.1. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.") 107 | if purgeUnknownRecords: 108 | deleteEntries("AAAA") 109 | ips = {} 110 | if (a is not None): 111 | ips["ipv4"] = { 112 | "type": "A", 113 | "ip": a 114 | } 115 | if (aaaa is not None): 116 | ips["ipv6"] = { 117 | "type": "AAAA", 118 | "ip": aaaa 119 | } 120 | return ips 121 | 122 | 123 | def commitRecord(ip): 124 | global ttl 125 | for option in config["cloudflare"]: 126 | subdomains = option["subdomains"] 127 | response = cf_api("zones/" + option['zone_id'], "GET", option) 128 | if response is None or response["result"]["name"] is None: 129 | time.sleep(5) 130 | return 131 | base_domain_name = response["result"]["name"] 132 | for subdomain in subdomains: 133 | try: 134 | name = subdomain["name"].lower().strip() 135 | proxied = subdomain["proxied"] 136 | except: 137 | name = subdomain 138 | proxied = option["proxied"] 139 | fqdn = base_domain_name 140 | # Check if name provided is a reference to the root domain 141 | if name != '' and name != '@': 142 | fqdn = name + "." + base_domain_name 143 | record = { 144 | "type": ip["type"], 145 | "name": fqdn, 146 | "content": ip["ip"], 147 | "proxied": proxied, 148 | "ttl": ttl 149 | } 150 | dns_records = cf_api( 151 | "zones/" + option['zone_id'] + 152 | "/dns_records?per_page=100&type=" + ip["type"], 153 | "GET", option) 154 | identifier = None 155 | modified = False 156 | duplicate_ids = [] 157 | if dns_records is not None: 158 | for r in dns_records["result"]: 159 | if (r["name"] == fqdn): 160 | if identifier: 161 | if r["content"] == ip["ip"]: 162 | duplicate_ids.append(identifier) 163 | identifier = r["id"] 164 | else: 165 | duplicate_ids.append(r["id"]) 166 | else: 167 | identifier = r["id"] 168 | if r['content'] != record['content'] or r['proxied'] != record['proxied']: 169 | modified = True 170 | if identifier: 171 | if modified: 172 | print("📡 Updating record " + str(record)) 173 | response = cf_api( 174 | "zones/" + option['zone_id'] + 175 | "/dns_records/" + identifier, 176 | "PUT", option, {}, record) 177 | else: 178 | print("➕ Adding new record " + str(record)) 179 | response = cf_api( 180 | "zones/" + option['zone_id'] + "/dns_records", "POST", option, {}, record) 181 | if purgeUnknownRecords: 182 | for identifier in duplicate_ids: 183 | identifier = str(identifier) 184 | print("🗑️ Deleting stale record " + identifier) 185 | response = cf_api( 186 | "zones/" + option['zone_id'] + 187 | "/dns_records/" + identifier, 188 | "DELETE", option) 189 | return True 190 | 191 | 192 | def updateLoadBalancer(ip): 193 | 194 | for option in config["load_balancer"]: 195 | pools = cf_api('user/load_balancers/pools', 'GET', option) 196 | 197 | if pools: 198 | idxr = dict((p['id'], i) for i, p in enumerate(pools['result'])) 199 | idx = idxr.get(option['pool_id']) 200 | 201 | origins = pools['result'][idx]['origins'] 202 | 203 | idxr = dict((o['name'], i) for i, o in enumerate(origins)) 204 | idx = idxr.get(option['origin']) 205 | 206 | origins[idx]['address'] = ip['ip'] 207 | data = {'origins': origins} 208 | 209 | response = cf_api(f'user/load_balancers/pools/{option["pool_id"]}', 'PATCH', option, {}, data) 210 | 211 | 212 | def cf_api(endpoint, method, config, headers={}, data=False): 213 | api_token = config['authentication']['api_token'] 214 | if api_token != '' and api_token != 'api_token_here': 215 | headers = { 216 | "Authorization": "Bearer " + api_token, **headers 217 | } 218 | else: 219 | headers = { 220 | "X-Auth-Email": config['authentication']['api_key']['account_email'], 221 | "X-Auth-Key": config['authentication']['api_key']['api_key'], 222 | } 223 | try: 224 | if (data == False): 225 | response = requests.request( 226 | method, "https://api.cloudflare.com/client/v4/" + endpoint, headers=headers) 227 | else: 228 | response = requests.request( 229 | method, "https://api.cloudflare.com/client/v4/" + endpoint, 230 | headers=headers, json=data) 231 | 232 | if response.ok: 233 | return response.json() 234 | else: 235 | print("😡 Error sending '" + method + 236 | "' request to '" + response.url + "':") 237 | print(response.text) 238 | return None 239 | except Exception as e: 240 | print("😡 An exception occurred while sending '" + 241 | method + "' request to '" + endpoint + "': " + str(e)) 242 | return None 243 | 244 | 245 | def updateIPs(ips): 246 | for ip in ips.values(): 247 | commitRecord(ip) 248 | #updateLoadBalancer(ip) 249 | 250 | 251 | if __name__ == '__main__': 252 | shown_ipv4_warning = False 253 | shown_ipv4_warning_secondary = False 254 | shown_ipv6_warning = False 255 | shown_ipv6_warning_secondary = False 256 | ipv4_enabled = True 257 | ipv6_enabled = True 258 | purgeUnknownRecords = False 259 | 260 | if sys.version_info < (3, 5): 261 | raise Exception("🐍 This script requires Python 3.5+") 262 | 263 | config = None 264 | try: 265 | with open(os.path.join(CONFIG_PATH, "config.json")) as config_file: 266 | if len(ENV_VARS) != 0: 267 | config = json.loads(Template(config_file.read()).safe_substitute(ENV_VARS)) 268 | else: 269 | config = json.loads(config_file.read()) 270 | except: 271 | print("😡 Error reading config.json") 272 | # wait 10 seconds to prevent excessive logging on docker auto restart 273 | time.sleep(10) 274 | 275 | if config is not None: 276 | try: 277 | ipv4_enabled = config["a"] 278 | ipv6_enabled = config["aaaa"] 279 | except: 280 | ipv4_enabled = True 281 | ipv6_enabled = True 282 | print("⚙️ Individually disable IPv4 or IPv6 with new config.json options. Read more about it here: https://github.com/timothymiller/cloudflare-ddns/blob/master/README.md") 283 | try: 284 | purgeUnknownRecords = config["purgeUnknownRecords"] 285 | except: 286 | purgeUnknownRecords = False 287 | print("⚙️ No config detected for 'purgeUnknownRecords' - defaulting to False") 288 | try: 289 | ttl = int(config["ttl"]) 290 | except: 291 | ttl = 300 # default Cloudflare TTL 292 | print( 293 | "⚙️ No config detected for 'ttl' - defaulting to 300 seconds (5 minutes)") 294 | if ttl < 30: 295 | ttl = 1 # 296 | print("⚙️ TTL is too low - defaulting to 1 (auto)") 297 | if (len(sys.argv) > 1): 298 | if (sys.argv[1] == "--repeat"): 299 | if ipv4_enabled and ipv6_enabled: 300 | print( 301 | "🕰️ Updating IPv4 (A) & IPv6 (AAAA) records every " + str(ttl) + " seconds") 302 | elif ipv4_enabled and not ipv6_enabled: 303 | print("🕰️ Updating IPv4 (A) records every " + 304 | str(ttl) + " seconds") 305 | elif ipv6_enabled and not ipv4_enabled: 306 | print("🕰️ Updating IPv6 (AAAA) records every " + 307 | str(ttl) + " seconds") 308 | next_time = time.time() 309 | killer = GracefulExit() 310 | prev_ips = None 311 | while True: 312 | updateIPs(getIPs()) 313 | if killer.kill_now.wait(ttl): 314 | break 315 | else: 316 | print("❓ Unrecognized parameter '" + 317 | sys.argv[1] + "'. Stopping now.") 318 | else: 319 | updateIPs(getIPs()) 320 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 🚀 Cloudflare DDNS 4 | 5 | Access your home network remotely via a custom domain name without a static IP! 6 | 7 | ## ⚡ Efficiency 8 | 9 | - ❤️ Easy config. List your domains and you're done. 10 | - 🔁 The Python runtime will re-use existing HTTP connections. 11 | - 🗃️ Cloudflare API responses are cached to reduce API usage. 12 | - 🤏 The Docker image is small and efficient. 13 | - 0️⃣ Zero dependencies. 14 | - 💪 Supports all platforms. 15 | - 🏠 Enables low cost self hosting to promote a more decentralized internet. 16 | - 🔒 Zero-log IP provider ([cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace)) 17 | - 👐 GPL-3.0 License. Open source for open audits. 18 | 19 | ## 💯 Complete Support of Domain Names, Subdomains, IPv4 & IPv6, and Load Balancing 20 | 21 | - 🌐 Supports multiple domains (zones) on the same IP. 22 | - 📠 Supports multiple subdomains on the same IP. 23 | - 📡 IPv4 and IPv6 support. 24 | - 🌍 Supports all Cloudflare regions. 25 | - ⚖️ Supports [Cloudflare Load Balancing](https://developers.cloudflare.com/load-balancing/understand-basics/pools/). 26 | - 🇺🇸 Made in the U.S.A. 27 | 28 | ## 📊 Stats 29 | 30 | | Size | Downloads | Discord | 31 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | 32 | | [](https://hub.docker.com/r/timothyjmiller/cloudflare-ddns 'cloudflare-ddns docker image size') | [](https://hub.docker.com/r/timothyjmiller/cloudflare-ddns 'Total DockerHub pulls') | [](https://discord.gg/UgGmwMvNxm 'Official Discord Server') | 33 | 34 | ## 🚦 Getting Started 35 | 36 | First copy the example configuration file into the real one. 37 | 38 | ```bash 39 | cp config-example.json config.json 40 | ``` 41 | 42 | Edit `config.json` and replace the values with your own. 43 | 44 | ### 🔑 Authentication methods 45 | 46 | You can choose to use either the newer API tokens, or the traditional API keys 47 | 48 | To generate a new API tokens, go to your [Cloudflare Profile](https://dash.cloudflare.com/profile/api-tokens) and create a token capable of **Edit DNS**. Then replace the value in 49 | 50 | ```json 51 | "authentication": 52 | "api_token": "Your cloudflare API token, including the capability of **Edit DNS**" 53 | ``` 54 | 55 | Alternatively, you can use the traditional API keys by setting appropriate values for: 56 | 57 | ```json 58 | "authentication": 59 | "api_key": 60 | "api_key": "Your cloudflare API Key", 61 | "account_email": "The email address you use to sign in to cloudflare", 62 | ``` 63 | 64 | ### 📍 Enable or disable IPv4 or IPv6 65 | 66 | Some ISP provided modems only allow port forwarding over IPv4 or IPv6. In this case, you would want to disable any interface not accessible via port forward. 67 | 68 | ```json 69 | "a": true, 70 | "aaaa": true 71 | ``` 72 | 73 | ### 🎛️ Other values explained 74 | 75 | ```json 76 | "zone_id": "The ID of the zone that will get the records. From your dashboard click into the zone. Under the overview tab, scroll down and the zone ID is listed in the right rail", 77 | "subdomains": "Array of subdomains you want to update the A & where applicable, AAAA records. IMPORTANT! Only write subdomain name. Do not include the base domain name. (e.g. foo or an empty string to update the base domain)", 78 | "proxied": "Defaults to false. Make it true if you want CDN/SSL benefits from cloudflare. This usually disables SSH)", 79 | "ttl": "Defaults to 300 seconds. Longer TTLs speed up DNS lookups by increasing the chance of cached results, but a longer TTL also means that updates to your records take longer to go into effect. You can choose a TTL between 30 seconds and 1 day. For more information, see [Cloudflare's TTL documentation](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/)", 80 | ``` 81 | 82 | ## 📠 Hosting multiple subdomains on the same IP? 83 | 84 | This script can be used to update multiple subdomains on the same IP address. 85 | 86 | For example, if you have a domain `example.com` and you want to host additional subdomains at `foo.example.com` and `bar.example.com` on the same IP address, you can use this script to update the DNS records for all subdomains. 87 | 88 | ### ⚠️ Note 89 | 90 | Please remove the comments after `//` in the below example. They are only there to explain the config. 91 | 92 | Do not include the base domain name in your `subdomains` config. Do not use the [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name). 93 | 94 | ### 👉 Example 🚀 95 | 96 | ```bash 97 | { 98 | "cloudflare": [ 99 | { 100 | "authentication": { 101 | "api_token": "api_token_here", // Either api_token or api_key 102 | "api_key": { 103 | "api_key": "api_key_here", 104 | "account_email": "your_email_here" 105 | } 106 | }, 107 | "zone_id": "your_zone_id_here", 108 | "subdomains": [ 109 | { 110 | "name": "", // Root domain (example.com) 111 | "proxied": true 112 | }, 113 | { 114 | "name": "foo", // (foo.example.com) 115 | "proxied": true 116 | }, 117 | { 118 | "name": "bar", // (bar.example.com) 119 | "proxied": true 120 | } 121 | ] 122 | } 123 | ], 124 | "a": true, 125 | "aaaa": true, 126 | "purgeUnknownRecords": false, 127 | "ttl": 300 128 | } 129 | ``` 130 | 131 | ## 🌐 Hosting multiple domains (zones) on the same IP? 132 | 133 | You can handle ddns for multiple domains (cloudflare zones) using the same docker container by duplicating your configs inside the `cloudflare: []` key within `config.json` like below: 134 | 135 | ### ⚠️ Note: 136 | 137 | If you are using API Tokens, make sure the token used supports editing your zone ID. 138 | 139 | ```bash 140 | { 141 | "cloudflare": [ 142 | { 143 | "authentication": { 144 | "api_token": "api_token_here", 145 | "api_key": { 146 | "api_key": "api_key_here", 147 | "account_email": "your_email_here" 148 | } 149 | }, 150 | "zone_id": "your_first_zone_id_here", 151 | "subdomains": [ 152 | { 153 | "name": "", 154 | "proxied": false 155 | }, 156 | { 157 | "name": "remove_or_replace_with_your_subdomain", 158 | "proxied": false 159 | } 160 | ] 161 | }, 162 | { 163 | "authentication": { 164 | "api_token": "api_token_here", 165 | "api_key": { 166 | "api_key": "api_key_here", 167 | "account_email": "your_email_here" 168 | } 169 | }, 170 | "zone_id": "your_second_zone_id_here", 171 | "subdomains": [ 172 | { 173 | "name": "", 174 | "proxied": false 175 | }, 176 | { 177 | "name": "remove_or_replace_with_your_subdomain", 178 | "proxied": false 179 | } 180 | ] 181 | } 182 | ], 183 | "a": true, 184 | "aaaa": true, 185 | "purgeUnknownRecords": false 186 | } 187 | ``` 188 | 189 | ## ⚖️ Load Balancing 190 | 191 | If you have multiple IP addresses and want to load balance between them, you can use the `loadBalancing` option. This will create a CNAME record for each subdomain that points to the subdomain with the lowest IP address. 192 | 193 | ### 📜 Example config to support load balancing 194 | 195 | ```json 196 | { 197 | "cloudflare": [ 198 | { 199 | "authentication": { 200 | "api_token": "api_token_here", 201 | "api_key": { 202 | "api_key": "api_key_here", 203 | "account_email": "your_email_here" 204 | } 205 | }, 206 | "zone_id": "your_zone_id_here", 207 | "subdomains": [ 208 | { 209 | "name": "", 210 | "proxied": false 211 | }, 212 | { 213 | "name": "remove_or_replace_with_your_subdomain", 214 | "proxied": false 215 | } 216 | ] 217 | } 218 | ],{ 219 | "cloudflare": [ 220 | { 221 | "authentication": { 222 | "api_token": "api_token_here", 223 | "api_key": { 224 | "api_key": "api_key_here", 225 | "account_email": "your_email_here" 226 | } 227 | }, 228 | "zone_id": "your_zone_id_here", 229 | "subdomains": [ 230 | { 231 | "name": "", 232 | "proxied": false 233 | }, 234 | { 235 | "name": "remove_or_replace_with_your_subdomain", 236 | "proxied": false 237 | } 238 | ] 239 | } 240 | ], 241 | "load_balancer": [ 242 | { 243 | "authentication": { 244 | "api_token": "api_token_here", 245 | "api_key": { 246 | "api_key": "api_key_here", 247 | "account_email": "your_email_here" 248 | } 249 | }, 250 | "pool_id": "your_pool_id_here", 251 | "origin": "your_origin_name_here" 252 | } 253 | ], 254 | "a": true, 255 | "aaaa": true, 256 | "purgeUnknownRecords": false, 257 | "ttl": 300 258 | } 259 | ``` 260 | 261 | ### Docker environment variable support 262 | 263 | Define environmental variables starts with `CF_DDNS_` and use it in config.json 264 | 265 | For ex: 266 | 267 | ```json 268 | { 269 | "cloudflare": [ 270 | { 271 | "authentication": { 272 | "api_token": "${CF_DDNS_API_TOKEN}", 273 | ``` 274 | 275 | ### 🧹 Optional features 276 | 277 | `purgeUnknownRecords` removes stale DNS records from Cloudflare. This is useful if you have a dynamic DNS record that you no longer want to use. If you have a dynamic DNS record that you no longer want to use, you can set `purgeUnknownRecords` to `true` and the script will remove the stale DNS record from Cloudflare. 278 | 279 | ## 🐳 Deploy with Docker Compose 280 | 281 | Pre-compiled images are available via [the official docker container on DockerHub](https://hub.docker.com/r/timothyjmiller/cloudflare-ddns). 282 | 283 | Modify the host file path of config.json inside the volumes section of docker-compose.yml. 284 | 285 | ```yml 286 | version: '3.9' 287 | services: 288 | cloudflare-ddns: 289 | image: timothyjmiller/cloudflare-ddns:latest 290 | container_name: cloudflare-ddns 291 | security_opt: 292 | - no-new-privileges:true 293 | network_mode: 'host' 294 | environment: 295 | - PUID=1000 296 | - PGID=1000 297 | volumes: 298 | - /YOUR/PATH/HERE/config.json:/config.json 299 | restart: unless-stopped 300 | ``` 301 | 302 | ### ⚠️ IPv6 303 | 304 | Docker requires network_mode be set to host in order to access the IPv6 public address. 305 | 306 | ### 🏃♂️ Running 307 | 308 | From the project root directory 309 | 310 | ```bash 311 | docker-compose up -d 312 | ``` 313 | 314 | ## 🐋 Kubernetes 315 | 316 | Create config File 317 | 318 | ```bash 319 | cp ../../config-example.json config.json 320 | ``` 321 | 322 | Edit config.jsonon (vim, nvim, nano... ) 323 | 324 | ```bash 325 | ${EDITOR} config.json 326 | ``` 327 | 328 | Create config file as Secret. 329 | 330 | ```bash 331 | kubectl create secret generic config-cloudflare-ddns --from-file=config.json --dry-run=client -oyaml -n ddns > config-cloudflare-ddns-Secret.yaml 332 | ``` 333 | 334 | apply this secret 335 | 336 | ```bash 337 | kubectl apply -f config-cloudflare-ddns-Secret.yaml 338 | rm config.json # recomended Just keep de secret on Kubernetes Cluster 339 | ``` 340 | 341 | apply this Deployment 342 | 343 | ```bash 344 | kubectl apply -f cloudflare-ddns-Deployment.yaml 345 | ``` 346 | 347 | ## 🐧 Deploy with Linux + Cron 348 | 349 | ### 🏃 Running (all distros) 350 | 351 | This script requires Python 3.5+, which comes preinstalled on the latest version of Raspbian. Download/clone this repo and give permission to the project's bash script by running `chmod +x ./start-sync.sh`. Now you can execute `./start-sync.sh`, which will set up a virtualenv, pull in any dependencies, and fire the script. 352 | 353 | 1. Upload the cloudflare-ddns folder to your home directory /home/your_username_here/ 354 | 355 | 2. Run the following code in terminal 356 | 357 | ```bash 358 | crontab -e 359 | ``` 360 | 361 | 3. Add the following lines to sync your DNS records every 15 minutes 362 | 363 | ```bash 364 | */15 * * * * /home/your_username_here/cloudflare-ddns/start-sync.sh 365 | ``` 366 | 367 | ## Building from source 368 | 369 | Create a config.json file with your production credentials. 370 | 371 | ### 💖 Please Note 372 | 373 | The optional `docker-build-all.sh` script requires Docker experimental support to be enabled. 374 | 375 | Docker Hub has experimental support for multi-architecture builds. Their official blog post specifies easy instructions for building with [Mac and Windows versions of Docker Desktop](https://docs.docker.com/docker-for-mac/multi-arch/). 376 | 377 | 1. Choose build platform 378 | 379 | - Multi-architecture (experimental) `docker-build-all.sh` 380 | 381 | - Linux/amd64 by default `docker-build.sh` 382 | 383 | 2. Give your bash script permission to execute. 384 | 385 | ```bash 386 | sudo chmod +x ./docker-build.sh 387 | ``` 388 | 389 | ```bash 390 | sudo chmod +x ./docker-build-all.sh 391 | ``` 392 | 393 | 3. At project root, run the `docker-build.sh` script. 394 | 395 | Recommended for local development 396 | 397 | ```bash 398 | ./docker-build.sh 399 | ``` 400 | 401 | Recommended for production 402 | 403 | ```bash 404 | ./docker-build-all.sh 405 | ``` 406 | 407 | ### Run the locally compiled version 408 | 409 | ```bash 410 | docker run -d timothyjmiller/cloudflare_ddns:latest 411 | ``` 412 | 413 | ## Supported Platforms 414 | 415 | - [Docker](https://docs.docker.com/get-docker/) 416 | - [Docker Compose](https://docs.docker.com/compose/install/) 417 | - [Kubernetes](https://kubernetes.io/docs/tasks/tools/) 418 | - [Python 3](https://www.python.org/downloads/) 419 | - [Systemd](https://www.freedesktop.org/wiki/Software/systemd/) 420 | 421 | ## 📜 Helpful links 422 | 423 | - [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) 424 | - [Cloudflare zone ID](https://support.cloudflare.com/hc/en-us/articles/200167836-Where-do-I-find-my-Cloudflare-IP-address-) 425 | - [Cloudflare zone DNS record ID](https://support.cloudflare.com/hc/en-us/articles/360019093151-Managing-DNS-records-in-Cloudflare) 426 | 427 | ## License 428 | 429 | This Template is licensed under the GNU General Public License, version 3 (GPLv3). 430 | 431 | ## Author 432 | 433 | Timothy Miller 434 | 435 | [View my GitHub profile 💡](https://github.com/timothymiller) 436 | 437 | [View my personal website 💻](https://timknowsbest.com) 438 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc.