···11+# Changelog
22+33+All notable changes to this project will be documented in this file.
44+55+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77+88+## [Unreleased]
99+1010+## [0.1.0] - 2025-11-29
1111+1212+### Added
1313+1414+- First version of CLI command to update a DNS record with the public IP.
+18
README.md
···11+# DNS Updater
22+33+This small application can be used to update the value of a DNS record in
44+**DigitalOcean** with the public IP.
55+66+This uses [ipify](https://www.ipify.org/) API to obtain the public IP of the host that
77+executes this application.
88+99+## Install
1010+1111+The recommended way to install `dyns` is using [pipx](https://pipx.pypa.io/latest/installation/).
1212+This could be done by using directly the repository URL:
1313+1414+```bash
1515+pipx install git+https://github.com/marcosgabarda/dyns-cli
1616+```
1717+1818+This will add `dyns` command to your shell.
···11+"""Module with CLI application."""
22+33+import argparse
44+import logging
55+import sys
66+77+import dyns
88+99+from .updater import updater
1010+1111+logger = logging.getLogger(__name__)
1212+logging.basicConfig(level=logging.INFO)
1313+1414+1515+def update_dns_record(args: argparse.Namespace) -> None:
1616+ """Parse the record and update the DNS record."""
1717+ parts = args.record.split(".")
1818+ domain = ".".join(parts[-2:])
1919+ name = ".".join(parts[:-2])
2020+ logger.debug(f"Updating for name {name} in domain {domain}")
2121+2222+ updater(name=name, domain=domain)
2323+2424+2525+def main():
2626+ """Execute main function to handle CLI parameters."""
2727+ parser = argparse.ArgumentParser(
2828+ description=(
2929+ "A simple command to update a DNS record in DigitalOcean with your public "
3030+ "IP."
3131+ )
3232+ )
3333+ parser.add_argument(
3434+ "record",
3535+ type=str,
3636+ help="domain name (ex. 'home.example.com') to update with your public IP",
3737+ )
3838+ parser.add_argument("-v", "--version", help="show version", action="store_true")
3939+ parser.set_defaults(func=update_dns_record)
4040+ args = parser.parse_args()
4141+4242+ # just print version
4343+ if args.version:
4444+ print(dyns.__version__)
4545+ sys.exit(0)
4646+4747+ args.func(args)
+75
src/dyns/updater.py
···11+"""Module with the code to update a DNS record."""
22+33+import logging
44+55+import httpx
66+from pydantic import SecretStr
77+from pydantic_settings import BaseSettings, SettingsConfigDict
88+99+logger = logging.getLogger(__name__)
1010+1111+1212+class Settings(BaseSettings):
1313+ """Configuration of the updater."""
1414+1515+ digital_ocean_token: SecretStr
1616+1717+ model_config = SettingsConfigDict(env_prefix="dyns_")
1818+1919+2020+def public_ip() -> str:
2121+ """Get the public IP of the host.
2222+2323+ It uses https://www.ipify.org/.
2424+ """
2525+ response = httpx.get("https://api.ipify.org?format=json")
2626+ response.raise_for_status()
2727+ data = response.json()
2828+ logger.debug(f"Public IP: {str(data)}")
2929+ return response.json()["ip"]
3030+3131+3232+def updater(name: str, domain: str) -> None:
3333+ """Update the DNS record using the DigitalOcean API."""
3434+ settings = Settings()
3535+ token = settings.digital_ocean_token.get_secret_value()
3636+3737+ base_url = "https://api.digitalocean.com/v2"
3838+ headers = {"Authorization": f"Bearer {token}"}
3939+4040+ # creates a client
4141+ with httpx.Client(base_url=base_url, headers=headers) as do_client:
4242+ # looks for the record ID using the list endpoint
4343+ # ------------------------------------------------------------------------------
4444+ response = do_client.get(f"/domains/{domain}/records")
4545+ response.raise_for_status()
4646+4747+ data = response.json()
4848+ domain_records = data["domain_records"]
4949+5050+ # handle pagination
5151+ next = data["links"]["pages"].get("next")
5252+ while next:
5353+ response = do_client.get(next)
5454+ response.raise_for_status()
5555+ data = response.json()
5656+ domain_records += data["domain_records"]
5757+ next = data["links"]["pages"].get("next")
5858+5959+ # filter the results
6060+ records = [record for record in domain_records if record["name"] == name]
6161+ if not records:
6262+ logger.error(f"Record {name} not found in domain {domain}")
6363+ return None
6464+ record = records[0]
6565+6666+ # updates the record
6767+ # ------------------------------------------------------------------------------
6868+6969+ ip = public_ip()
7070+ payload = {"type": "A", "data": ip}
7171+ response = do_client.patch(
7272+ f"/domains/{domain}/records/{record['id']}", json=payload
7373+ )
7474+ response.raise_for_status()
7575+ logger.info(f"DNS record updated with '{ip}'")