diff --git a/.gitignore b/.gitignore index b4a5ac9..ac12599 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,178 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python + src/cloudflare_cname.py \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3381580..c88a61f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,27 @@ FROM python:3.9-slim -# Install required packages -RUN apt-get update && apt-get install -y \ - cron \ - make \ - && rm -rf /var/lib/apt/lists/* - RUN mkdir -p /app/cloudflare-ddns/ WORKDIR /app/cloudflare-ddns RUN mkdir -p /app/cloudflare-ddns/config -COPY . . +COPY requirements.txt /app/cloudflare-ddns/ -RUN pip install --no-cache-dir -r requirements.txt +COPY ./etc /app/cloudflare-ddns/ +COPY ./src /app/cloudflare-ddns/ +COPY ./*.sh /app/cloudflare-ddns/ -RUN cp /app/cloudflare-ddns/cron/cronjob /etc/cron.d/cloudflare-ddns - -RUN touch /var/log/cron.log -RUN chmod +x /app/cloudflare-ddns/start.sh -RUN chmod +x /app/cloudflare-ddns/run_script.sh +# Install required packages +RUN apt-get update && apt-get --no-install-recommends install -y \ + cron \ + libaugeas0 \ + make \ + python3.11 python3.11-dev python3.11-pip python3.11-venv \ + && rm -rf /var/lib/apt/lists/* \ + cp /app/cloudflare-ddns/cron/cronjob-ddns /etc/cron.d/cloudflare-ddns \ + pip install --no-cache-dir -r requirements.txt \ + touch /var/log/cron.log \ + chmod +x /app/cloudflare-ddns/start.sh \ + chmod +x /app/cloudflare-ddns/run_script.sh VOLUME [ "/app/cloudflare-ddns/config" ] diff --git a/Makefile b/Makefile index 5cca1bc..1668639 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ IMAGE_NAME = "cloudflare-ddns" -DIR = $(shell pwd) +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" default: build @@ -23,26 +23,19 @@ stop: .PHONY: install install: - @echo "Installing cloudflare-ddns to '/app/cloudflare-ddns'" - @mkdir -p /app/cloudflare-ddns - @cp -r ./* /app/cloudflare-ddns - @if [ ! -f /app/cloudflare-ddns/config/env.json ]; then \ - echo "Creating default env.json"; \ - mkdir -p /app/cloudflare-ddns/config; \ - cp /app/cloudflare-ddns/init/default_env.json /app/cloudflare-ddns/config/env.json; \ - echo "Please edit /app/cloudflare-ddns/config/env.json"; \ - fi - @cp /app/cloudflare-ddns/cron/cronjob /etc/cron.d/cloudflare-ddns - @touch /var/log/cloudflare_ddns.log - @chmod +x /app/cloudflare-ddns/run_script.sh - @chmod +x /app/cloudflare-ddns/start.sh - - @echo "Enter 'make configure' to configure the Cloudflare API key, domain and ZONE ID" + @echo "Installing cloudflare-ddns" + @scripts/install.sh - @echo "Done" +.PHONY: certbot +certbot: + @echo "Installing certbot" + @scripts/certbot.sh -.PHONY: configure -configure: - @echo "Please edit /app/cloudflare-ddns/config/env.json" - @vi /app/cloudflare-ddns/config/env.json +.PHONY: clean +clean: + @rm -rf /app/cloudflare-ddns + @rm -rf /app/certbot + @rm /etc/cron.d/cloudflare-ddns + @rm /var/log/cloudflare_ddns.log + @rm /var/log/cloudflare_ddns.log.* @echo "Done" \ No newline at end of file diff --git a/init/.gitignore b/config/.gitignore similarity index 100% rename from init/.gitignore rename to config/.gitignore diff --git a/config/env_example.json b/config/env_example.json new file mode 100644 index 0000000..f8a7e49 --- /dev/null +++ b/config/env_example.json @@ -0,0 +1,15 @@ +{ + "CLOUDFLARE_API_KEY": "your_cloudflare_api_key", + "CLOUDFLARE_ZONE_ID": "your_cloudflare_zone_id", + "CLOUDFLARE_DOMAIN": "example.com", + "EMAIL": "your_email@example.com", + "CLOUDFLARE_A": { + "@": true + }, + "CLOUDFLARE_CNAME": { + "@": { + "www": true + } + }, + "CLOUDFLARE_MX": {} +} \ No newline at end of file diff --git a/cron/cronjob-certbot b/cron/cronjob-certbot new file mode 100644 index 0000000..8414f35 --- /dev/null +++ b/cron/cronjob-certbot @@ -0,0 +1 @@ +0 0,12 * * * /usr/bin/certbot renew --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini --quiet diff --git a/cron/cronjob b/cron/cronjob-ddns similarity index 100% rename from cron/cronjob rename to cron/cronjob-ddns diff --git a/init/default_env.json b/init/default_env.json deleted file mode 100644 index db0e5c7..0000000 --- a/init/default_env.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "CLOUDFLARE_API_KEY": "", - "CLOUDFLARE_ZONE_ID": "", - "CLOUDFLARE_DOMAIN": "", - "CLOUDFLARE_A": { - "@": true - }, - "CLOUDFLARE_CNAME": { - "*": { - "@": true - } - }, - "CLOUDFLARE_MX": {} -} \ No newline at end of file diff --git a/scripts/certbot.sh b/scripts/certbot.sh new file mode 100755 index 0000000..ad9fec6 --- /dev/null +++ b/scripts/certbot.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Certbot 및 Cloudflare API 도구 설치 +sudo apt update +sudo apt install -y certbot python3-certbot-dns-cloudflare jq + +# 필요한 변수 설정 +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +CONFIG_FILE="$DIR/../config/env.json" +DOMAIN=$(jq -r '.CLOUDFLARE_DOMAIN' $CONFIG_FILE) +CERTBOT_EMAIL=$(jq -r '.EMAIL' $CONFIG_FILE) +CLOUDFLARE_API_TOKEN=$(jq -r '.CLOUDFLARE_API_KEY' $CONFIG_FILE) + +# Cloudflare API 자격 증명 파일 생성 +CLOUDFLARE_CREDENTIALS_FILE="/etc/letsencrypt/cloudflare.ini" +sudo mkdir -p /etc/letsencrypt +sudo bash -c "echo 'dns_cloudflare_api_token = ${CLOUDFLARE_API_TOKEN}' > ${CLOUDFLARE_CREDENTIALS_FILE}" +sudo chmod 600 ${CLOUDFLARE_CREDENTIALS_FILE} + +# Certbot을 사용하여 인증서 발급 및 갱신 +sudo certbot certonly \ + --dns-cloudflare \ + --dns-cloudflare-credentials ${CLOUDFLARE_CREDENTIALS_FILE} \ + --email ${CERTBOT_EMAIL} \ + --agree-tos \ + --no-eff-email \ + -d "*.${DOMAIN}" \ + -d "${DOMAIN}" + +# 크론탭을 사용하여 자동 갱신 설정 (매일 0시, 12시 실행) +echo "0 0,12 * * * /usr/bin/certbot renew --dns-cloudflare --dns-cloudflare-credentials ${CLOUDFLARE_CREDENTIALS_FILE} --quiet" > $DIR/../cron/cronjob-certbot +sudo cp $DIR/../cron/cronjob-certbot /etc/cron.d/certbot-renew + +echo "Certbot and Cloudflare API tools installation completed." diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..8a94092 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,21 @@ +#!/bin/bash +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +@echo off +echo "Certbot and Cloudflare API tools installation" +sudo apt update +sudo apt install -y certbot python3-certbot-dns-cloudflare jq + +sudo mkdir -p /app/cloudflare-ddns +sudo cp -r $DIR/../* /app/cloudflare-ddns +if [ -f /app/cloudflare-ddns/config/env.json ]; then + echo "Environment configuration file already exists. Back up the existing file and create a new file." + sudo mv /app/cloudflare-ddns/config/env.json /app/cloudflare-ddns/config/env.json.bak +fi +sudo mkdir -p /app/cloudflare-ddns/config +sudo cp $DIR/../config/env_example.json /app/cloudflare-ddns/config/env.json +sudo chmod 600 /app/cloudflare-ddns/config/env.json +sudo cp $DIR/../cron/cronjob-ddns /etc/cron.d/cloudflare-ddns +echo "Please modify the environment configuration file and save it in the /app/cloudflare-ddns/config/env.json path." + +echo "Certbot and Cloudflare API tools installation completed." \ No newline at end of file diff --git a/run_script.sh b/scripts/run_script.sh old mode 100644 new mode 100755 similarity index 84% rename from run_script.sh rename to scripts/run_script.sh index 9df7461..36681e4 --- a/run_script.sh +++ b/scripts/run_script.sh @@ -7,4 +7,4 @@ export CLOUDFLARE_ZONE_ID=$ZONE_ID DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # Run the script -python3 $DIR/src/update_dns.py $@ \ No newline at end of file +python3 $DIR/../src/update_dns.py $@ \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..df33b23 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +if [ ! -f $DIR/../config/env.json ]; then + cp $DIR/../config/default_env.json $DIR/../config/env.json +fi + +$DIR/run_script.sh + +tail -f /var/log/cloudflare_ddns.log \ No newline at end of file diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..dd85e15 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,7 @@ +#!/bin/bash +@echo "Uninstalling Cloudflare DDNS and Certbot..." +@sudo rm -rf /app/cloudflare-ddns +@sudo rm /etc/cron.d/cloudflare-ddns +@sudo rm /var/log/cloudflare_ddns.log +@sudo rm /var/log/cloudflare_ddns.log.* +@echo "Done" \ No newline at end of file diff --git a/src/update_dns.py b/src/update_dns.py index 9540e09..11ffb5b 100644 --- a/src/update_dns.py +++ b/src/update_dns.py @@ -21,9 +21,11 @@ fileHandler.setFormatter(format) logger.addHandler(fileHandler) logger.addHandler(logging.StreamHandler()) +DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "../config/env.json") + class DDNS: - def __init__(self, config_path="/app/cloudflare-ddns/config/env.json"): + def __init__(self, config_path=DEFAULT_CONFIG_PATH): self.config = self.load_config(config_path) current_ip = self.get_ip() previous_ip = self.previous_ip() @@ -38,12 +40,12 @@ class DDNS: "Authorization": f"Bearer {self.config['CLOUDFLARE_API_KEY']}", } - def load_config(self, config_path="/app/cloudflare-ddns/config/env.json"): - """ + def load_config(self, config_path=DEFAULT_CONFIG_PATH): + f""" 환경 변수와 env.json 파일에서 설정을 로드합니다. Args: - config_path (str): 설정 파일 경로. Defaults to "/app/cloudflare-ddns/config/env.json". + config_path (str): 설정 파일 경로. Defaults to {DEFAULT_CONFIG_PATH}. Raises: ValueError: 필수 설정이 없는 경우 @@ -129,7 +131,7 @@ class DDNS: file.write(ip) logger.info("IP is updated") except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"UPDATE IP Error: {e}") return None def read_record(self, type=Literal["A", "CNAME"], name=None, content=None): @@ -148,7 +150,7 @@ class DDNS: ) return records if records else None except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"READ RECORD Error: {e}") sys.exit(1) def create_record( @@ -171,7 +173,7 @@ class DDNS: raise requests.exceptions.RequestException(f"Failed to create {name}") return success if success else None except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"CREATE RECORD Error: {e}") sys.exit(2) def update_record( @@ -194,7 +196,7 @@ class DDNS: raise requests.exceptions.RequestException(f"Failed to update {name}") return success if success else None except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"UPDATE RECORD Error: {e}") sys.exit(3) def delete_record(self, record_id): @@ -208,7 +210,7 @@ class DDNS: ) return success if success else None except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"DELETE RECORD Error: {e}") sys.exit(4) def update_a_list(self, a_list, ips): @@ -240,7 +242,6 @@ class DDNS: logger.info(f"{a} is updated") pre_list.pop(a) else: - print(a, ips, proxy) self.create_record(type="A", name=a, content=ips, proxy=proxy) logger.info(f"{a} is created") @@ -254,60 +255,64 @@ class DDNS: return True except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"A RECORDS UPDATE Error: {e}") sys.exit(5) - def update_cname_list(self, cname_list, domain): + def update_cname_list(self, cname_list): try: - records_list = self.read_record(type="CNAME", content=domain) - tmp_cname_list = [] - for cname in cname_list.keys(): - a_record = list(cname_list[cname].keys())[0] - a_record = a_record if a_record != "@" else self.domain.split(".")[0] - proxy = list(cname_list[cname].values())[0] - if a_record == domain.split(".")[0]: - tmp_cname_list.append([cname, proxy]) + for a_record in cname_list.keys(): + domain = ( + a_record + "." + self.domain if a_record != "@" else self.domain + ) - if not records_list: - for [cname, proxy] in tmp_cname_list: - self.create_record( - type="CNAME", name=cname, content=domain, proxy=proxy - ) - logger.info(f"{cname} is created") - else: - pre_list = {} - for r in records_list: - pre_list[r["name"].split(".")[0]] = [r["proxied"], r["id"]] + records_list = self.read_record(type="CNAME", content=domain) + tmp_cname_list = cname_list[a_record] - for [cname, proxy] in tmp_cname_list: - if cname in pre_list.keys(): - if proxy != pre_list[cname][0]: - self.update_record( - record_id=pre_list[cname][1], - type="CNAME", - name=cname, - content=domain, - proxy=proxy, - ) - logger.info(f"{cname} is updated") - pre_list.pop(cname) - - else: + if not records_list: + for cname, proxy in tmp_cname_list.items(): self.create_record( type="CNAME", name=cname, content=domain, proxy=proxy ) logger.info(f"{cname} is created") + else: + pre_list = {} + for record in records_list: + pre_list[record["name"].split(".")[0]] = [ + record["proxied"], + record["id"], + ] - for p in pre_list: - records = self.read_record(type="CNAME", name=p + "." + domain) - record_id = records[0]["id"] - self.delete_record(record_id) - logger.info(f"{p} is deleted") + for cname, proxy in tmp_cname_list.items(): + if cname in pre_list.keys(): + if proxy != pre_list[cname][0]: + self.update_record( + record_id=pre_list[cname][1], + type="CNAME", + name=cname, + content=domain, + proxy=proxy, + ) + logger.info(f"{cname} is updated") + pre_list.pop(cname) + + else: + self.create_record( + type="CNAME", name=cname, content=domain, proxy=proxy + ) + logger.info(f"{cname} is created") + + for p in pre_list: + record = self.read_record( + type="CNAME", name=p + "." + self.domain + ) + record_id = record[0]["id"] + self.delete_record(record_id) + logger.info(f"{p} is deleted") logger.info("CNAME records are updated") return True except Exception as e: - logger.error(f"Error: {e}") + logger.error(f"CNAME RECORDS UPDATE Error: {e}") sys.exit(5) @@ -325,13 +330,7 @@ if __name__ == "__main__": API.update_ip(API.current_ip) # Update CNAME records - for a in config["CLOUDFLARE_A"]: - domain = ( - a + "." + config["CLOUDFLARE_DOMAIN"] - if a != "@" - else config["CLOUDFLARE_DOMAIN"] - ) - result = API.update_cname_list(config["CLOUDFLARE_CNAME"], domain) + result = API.update_cname_list(config["CLOUDFLARE_CNAME"]) if not result: logger.error("Failed to update CNAME records") sys.exit(1) diff --git a/start.sh b/start.sh deleted file mode 100644 index 6e51e4b..0000000 --- a/start.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -if [ ! -f $DIR/config/env.json ]; then - cp $DIR/init/default_env.json $DIR/config/env.json -fi - -sh $DIR/run_script.sh - -# tail -f /var/log/cloudflare_ddns.log \ No newline at end of file