rename podman_ projects to container_
This commit is contained in:
1
active/container_ddns/.python-version
Normal file
1
active/container_ddns/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
7
active/container_ddns/.vscode/settings.json
vendored
Normal file
7
active/container_ddns/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"."
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
26
active/container_ddns/Containerfile
Normal file
26
active/container_ddns/Containerfile
Normal 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"]
|
||||
20
active/container_ddns/ddns.container
Normal file
20
active/container_ddns/ddns.container
Normal 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
|
||||
109
active/container_ddns/ddns.md
Normal file
109
active/container_ddns/ddns.md
Normal 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
|
||||
```
|
||||
8
active/container_ddns/ddns.network
Normal file
8
active/container_ddns/ddns.network
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=DDNS
|
||||
|
||||
[Network]
|
||||
IPv6=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
11
active/container_ddns/ddns.timer
Normal file
11
active/container_ddns/ddns.timer
Normal 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
|
||||
59
active/container_ddns/install_ddns.yaml
Normal file
59
active/container_ddns/install_ddns.yaml
Normal 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
|
||||
13
active/container_ddns/pyproject.toml
Normal file
13
active/container_ddns/pyproject.toml
Normal 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",
|
||||
]
|
||||
32
active/container_ddns/test_update.py
Normal file
32
active/container_ddns/test_update.py
Normal 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
|
||||
)
|
||||
173
active/container_ddns/update.py
Normal file
173
active/container_ddns/update.py
Normal 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
5573
active/container_ddns/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user