rename podman_ projects to container_

This commit is contained in:
2026-02-11 11:34:02 -05:00
parent 7d2e8b6b7b
commit d4fbbb185f
78 changed files with 10 additions and 0 deletions

View File

@@ -0,0 +1 @@
3.13

View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@@ -0,0 +1,26 @@
FROM python:3.12-slim-bookworm
# The installer requires curl (and certificates) to download the release archive
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
# Download the latest installer
ADD https://astral.sh/uv/install.sh /uv-installer.sh
# Run the installer then remove it
RUN sh /uv-installer.sh && rm /uv-installer.sh
# Ensure the installed binary is on the `PATH`
ENV PATH="/root/.local/bin/:$PATH"
# Copy the project into the image
COPY update.py uv.lock pyproject.toml /app/
# Copy the records file
COPY records.yaml /etc/ddns/records.yaml
# Sync the project into a new environment, using the frozen lockfile
WORKDIR /app
RUN uv sync --frozen
# Presuming there is a `my_app` command provided by the project
CMD ["uv", "run", "update.py"]

View File

@@ -0,0 +1,20 @@
[Unit]
Description=DDNS
After=network-online.target
Wants=network-online.target
[Container]
Environment=ROUTE53_RECORDS_FILE=/etc/ddns/records.yaml
Environment=AWS_ACCESS_KEY_ID={{ aws.access_key_id }}
Environment=AWS_SECRET_ACCESS_KEY={{ aws.secret_access_key }}
{% if item.skip_ipv6 | default(false) %}
Environment=GLOBAL_SKIP_IPV6=true
{% endif %}
{% if item.skip_ipv4 | default(false) %}
Environment=GLOBAL_SKIP_IPV4=true
{% endif %}
Image=gitea.reeseapps.com/services/ddns:latest
Network=ddns.network
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,109 @@
# DDNS for Route53
- [DDNS for Route53](#ddns-for-route53)
- [Quickly Update DDNS Records](#quickly-update-ddns-records)
- [Install a New DDNS Service](#install-a-new-ddns-service)
- [Ansible Caddy Records](#ansible-caddy-records)
- [Development](#development)
- [Testing](#testing)
- [Building Container Image](#building-container-image)
This service will automatically keep ipv4 and ipv6 records updated in AWS
Route53.
**NOTE**: This requires the aws cli to be installed on each node with
credentials that can modify records in route53. See
[aws_iam](/active/aws_iam/aws_iam.md) and [aws_cli](/active/aws_cli/aws_cli.md)
## Quickly Update DDNS Records
In the event of a record change you can quickly trigger the ddns services with
```bash
systemctl start --all ddns*.service
```
## Install a New DDNS Service
You need two files:
1. secrets/vars.yaml (with aws credentials)
2. secrets/records.yaml (with AWS records)
`secrets/vars.yaml` example:
```yaml
aws:
access_key_id: key_here
secret_access_key: secret_here
```
`secrets/records.yaml` example:
```yaml
records:
- record: some.domain.com
hosted_zone_id: ABC123456789
- record: someother.domain.com
hosted_zone_id: ABC123456789
```
Then you'll need to pick a server responsible for keeping those records
updated. Whichever host you run the service on will also be the host which
provides the public IP. Choose the host accordingly if it will be updating a
public IP on behalf of another server, as the IPv6 address will not be correct.
Now you can install the DDNS service with something like:
```bash
ansible-playbook \
-i ansible/inventory.yaml \
-l proxy \
active/podman_ddns/install_ddns.yaml
```
See ansible playbook [install_ddns.yaml](/install_ddns.yaml)
It's recommended that you have multiple secret `foobar-records.yaml` files for
multiple servers. If you have a podman server, it'll have its own
`podman-records.yaml`. If you have a docker server, it'll have its own
`docker-records.yaml`. Etc. etc.
### Ansible Caddy Records
```bash
ansible-playbook \
-i ansible/inventory.yaml \
-l caddy \
active/podman_ddns/install_ddns.yaml \
-e "@active/podman_ddns/secrets/records.yaml"
```
## Development
### Testing
```bash
export ROUTE53_RECORD=test-ddns.reeseapps.com
export HOSTED_ZONE_ID=$(cat secrets/secret_vars.yaml | yq -r '.reeseapps_zone_id')
uv run update.py
```
### Building Container Image
```bash
# Build
podman build -t gitea.reeseapps.com/services/ddns:latest -f ./Containerfile
podman push gitea.reeseapps.com/services/ddns:latest
# Run
export ROUTE53_RECORD=test-ddns.reeseapps.com
export HOSTED_ZONE_ID=$(cat secrets/secret_vars.yaml | yq -r '.reeseapps_zone_id')
podman run \
-e ROUTE53_RECORD=$ROUTE53_RECORD \
-e HOSTED_ZONE_ID=$HOSTED_ZONE_ID \
-e AWS_PROFILE=prod \
-v $HOME/.aws:/root/.aws:Z \
-it --rm \
gitea.reeseapps.com/services/ddns:latest
```

View File

@@ -0,0 +1,8 @@
[Unit]
Description=DDNS
[Network]
IPv6=true
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run ddns.service every hour
[Timer]
OnCalendar=hourly
AccuracySec=10min
Persistent=true
Unit=ddns.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,59 @@
- name: Create DDNS Service
hosts: all
vars_files:
- secrets/vars.yaml
tasks:
- name: Create container build dir
ansible.builtin.file:
path: /tmp/ddns
state: directory
mode: '0755'
- name: Copy container build files
copy:
src: "{{ item }}"
dest: /tmp/ddns/
with_items:
- uv.lock
- pyproject.toml
- update.py
- Containerfile
- secrets/records.yaml
- name: Run container build
shell:
cmd: podman build -t gitea.reeseapps.com/services/ddns:latest -f ./Containerfile
chdir: /tmp/ddns/
- name: Remove container build dir
ansible.builtin.file:
path: /tmp/ddns
state: absent
- name: Copy ddns.network
template:
src: ddns.network
dest: /etc/containers/systemd/ddns.network
owner: root
group: root
mode: '0644'
- name: Template DDNS Container Service
template:
src: ddns.container
dest: /etc/containers/systemd/ddns.container
owner: root
group: root
mode: '0644'
- name: Template DDNS Container Timer
template:
src: ddns.timer
dest: /etc/systemd/system/ddns.timer
owner: root
group: root
mode: '0644'
- name: Reload ddns timer
ansible.builtin.systemd_service:
state: restarted
name: ddns.timer
enabled: true
daemon_reload: true
- name: Run ddns service
ansible.builtin.systemd_service:
state: restarted
name: ddns.service

View File

@@ -0,0 +1,13 @@
[project]
name = "ddns"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3>=1.37.30",
"boto3-stubs[all]>=1.38.23",
"pytest>=8.3.5",
"pyyaml>=6.0.3",
"types-pyyaml>=6.0.12.20250915",
]

View File

@@ -0,0 +1,32 @@
import re
from update import get_ipv4, get_ipv6
regex_match_ipv4 = (
r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
)
regex_match_ipv6 = (
r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:)"
r"{1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:)"
r"{1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]"
r"{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:"
r"[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4})"
r"{0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9])"
r"{0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"
r"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"
)
def test_get_ipv4():
ip = get_ipv4()
assert re.match(
regex_match_ipv4,
ip
)
def test_get_ipv6():
ip = get_ipv6()
assert re.match(
regex_match_ipv6,
ip
)

View File

@@ -0,0 +1,173 @@
"""
export HOSTED_ZONE_ID=<aws hosted zone ID>
export ROUTE53_RECORD=something.mydomain.com
"""
import logging
import os
import subprocess
import yaml
import sys
from typing import TYPE_CHECKING, TypedDict
import boto3
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader # type: ignore
if TYPE_CHECKING:
from mypy_boto3_route53 import Route53Client
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
ROUTE53_RECORDS_FILE = os.getenv("ROUTE53_RECORDS_FILE")
GLOBAL_SKIP_IPV4 = os.getenv("GLOBAL_SKIP_IPV4", "false").lower() == "true"
GLOBAL_SKIP_IPV6 = os.getenv("GLOBAL_SKIP_IPV6", "false").lower() == "true"
class RecordType(TypedDict):
record: str
hosted_zone_id: str
skip_ipv4: bool | None
skip_ipv6: bool | None
class RecordYamlStruct(TypedDict):
records: list[RecordType]
def get_ipv4() -> str:
result = subprocess.run(["curl", "-4", "ifconfig.me"], capture_output=True)
return result.stdout.decode()
def get_ipv6() -> str:
result = subprocess.run(["curl", "-6", "ifconfig.me"], capture_output=True)
return result.stdout.decode()
def update_ipv4(hosted_zone_id: str, record: str, public_ipv4: str):
client: Route53Client = boto3.client("route53")
try:
logger.info("Calling upsert for ipv4.")
client.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={
"Comment": "Update Public Addresses",
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": f"{record}",
"Type": "A",
"TTL": 300,
"ResourceRecords": [{"Value": public_ipv4}],
},
}
],
},
)
logger.info(f"Successfully updated ipv4 for {record}")
except Exception as e:
logger.error(f"Error updating ipv4 for {record}.")
raise e
def update_ipv6(hosted_zone_id: str, record: str, public_ipv6: str):
client = boto3.client("route53")
try:
logger.info("Calling upsert for ipv6.")
client.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={
"Comment": "Update Public Addresses",
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": f"{record}",
"Type": "AAAA",
"TTL": 300,
"ResourceRecords": [{"Value": public_ipv6}],
},
}
],
},
)
logger.info(f"Successfully updated ipv6 for {record}")
except Exception as e:
logger.error(f"Error updating ipv6 for {record}.")
raise e
def main():
if not ROUTE53_RECORDS_FILE:
logger.error("ROUTE53_RECORDS_FILE env var not found!")
exit(1)
try:
with open(ROUTE53_RECORDS_FILE) as f:
records_file_contents: RecordYamlStruct = yaml.load(f, Loader)
except FileNotFoundError as e:
logger.error(e)
sys.exit(1)
if GLOBAL_SKIP_IPV4:
public_ipv4 = None
logger.warning("Globally skipping IPv4.")
else:
logger.info("Getting IPv4 address from ifconfig.me")
public_ipv4 = get_ipv4()
if not public_ipv4:
logger.error("Public IPv4 not found.")
exit(1)
logger.info(f"Public IPv4 is {public_ipv4}")
if GLOBAL_SKIP_IPV6:
public_ipv6 = None
logger.warning("Globally Skipping IPv6")
else:
logger.info("Getting IPv6 address from ifconfig.me")
public_ipv6 = get_ipv6()
if not public_ipv6:
logger.error("Public IPv6 not found.")
exit(1)
logger.info(f"Public IPv6 is {public_ipv6}")
for record in records_file_contents["records"]:
logger.info(f"Attempting to update {record['record']} from {record['hosted_zone_id']}.")
if record.get("skip_ipv4"):
logger.info(f"{record['record']} requested to skip IPv4")
elif GLOBAL_SKIP_IPV4 or not public_ipv4:
logger.info("Globally skipping IPv4")
else:
update_ipv4(
hosted_zone_id=record["hosted_zone_id"],
record=record["record"],
public_ipv4=public_ipv4,
)
if record.get("skip_ipv6"):
logger.info(f"{record['record']} requested to skip IPv6")
elif GLOBAL_SKIP_IPV6 or not public_ipv6:
logger.info("Globally skipping IPv6")
else:
update_ipv6(
hosted_zone_id=record["hosted_zone_id"],
record=record["record"],
public_ipv6=public_ipv6,
)
if __name__ == "__main__":
main()

5573
active/container_ddns/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff