Compare commits

..

24 Commits

Author SHA1 Message Date
a1df4dcfdf Moved the web app to a separate directory. 2025-12-29 11:57:59 +01:00
af3d1fd3cb Update README and service configuration for Gunicorn usage and permissions 2025-12-28 21:33:34 +01:00
0989f51a55 Fix ExecStart command in dns-updater.service to properly handle environment variables 2025-12-28 21:30:49 +01:00
827ed9f83b Update dns-updater.service to run as root and change working directory 2025-12-28 21:27:52 +01:00
6d5dbee874 Add installation script for dns-updater service 2025-12-28 21:26:51 +01:00
63f67c7188 Add requirements.txt and update Dockerfile to install packages from it 2025-12-28 21:02:51 +01:00
bfebad5e5d Add .venv to .gitignore to prevent virtual environment files from being tracked 2025-12-28 21:02:46 +01:00
bc946e62e2 Updated the Dockerfile. 2025-12-28 21:01:34 +01:00
14d90d34aa Change container start-up procedure. 2025-12-28 21:00:53 +01:00
d77801788f Fix whitespace inconsistencies in delete-record.py 2025-12-28 18:15:21 +01:00
f77ade44d5 Fix whitespace inconsistencies in set-record.py 2025-12-28 18:15:03 +01:00
c5d6916fa6 Fix formatting inconsistencies in dns.py 2025-12-28 18:14:58 +01:00
1b2bb83eeb Add instructions for creating and using a self-signed SSL certificate in README.md 2025-12-28 18:14:53 +01:00
2884cd91a8 Add .pem files to .gitignore to prevent sensitive data from being tracked 2025-12-28 18:14:49 +01:00
62586de020 Add README.md with project overview, features, requirements, and installation instructions 2025-12-28 13:03:22 +01:00
fa95fd0279 Add Dockerfile for application containerization 2025-12-28 13:03:12 +01:00
4b943ee0c9 Add systemd service file for dns-updater to manage application lifecycle 2025-12-28 13:03:08 +01:00
59f3d2377d Refactor set_record and delete_record functions to improve value checking and response handling 2025-12-28 13:02:58 +01:00
dbf59fd124 Enhance set_record function to check current value before updating and improve response messages for clarity 2025-12-27 14:27:31 +01:00
9f6344ba4c Refactor response messages in set and delete record endpoints for consistency 2025-12-27 13:54:00 +01:00
c2189bf826 Implemented set,delete,list proxy for MIAB API. 2025-12-27 13:40:42 +01:00
2db1671d8a Replace HTTP server implementation with Flask API for DNS updates 2025-12-27 10:22:26 +01:00
b78c848525 Add DNS record management module for Mail-in-a-Box 2025-12-27 10:11:42 +01:00
68b7bd9997 Add __pycache__ to .gitignore 2025-12-27 10:11:19 +01:00
15 changed files with 350 additions and 30 deletions

5
.gitignore vendored
View File

@@ -1 +1,4 @@
.env
**/.venv
**/.env
**/__pycache__
**/*.pem

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# Omada Custom Dynamic DNS for Mail-In-A-Box
The repository contains a Flask-based API proxy that allows Omada controller to update DNS records in a Mail-In-A-Box (MIAB) server. The Omada SDN software does not natively support MIAB as a Dynamic DNS provider, so this proxy bridges that gap.
## Features
- Provides endpoints for listing, setting, and deleting DNS records.
- Relays authentication credentials from Omada supplied username and password to MIAB.
## Requirements
- Python 3.x
- Flask
- Flask-HTTPAuth
- Requests
- Base64
## Installation
On an Ubuntu/Debian system, you can install the required packages using apt:
```bash
sudo apt install -y python3 python3-dotenv python3-flask python3-flask-httpauth python3-requests gunicorn
```
Copy the `app.py` file to your desired location, and run it using Python:
```bash
flask run app.py
```
or use Gunicorn for production:
```bash
gunicorn --bind 0.0.0.0:8080 app:app
```
## Self-Signed SSL Certificate (Optional)
To run the Flask app with HTTPS, you can create a self-signed SSL certificate:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"
```
Then run the Flask app with SSL context:
```bash
flask run --cert=cert.pem --key=key.pem
```
To use the Mail In A Box server's SSL certificate, use the following files:
- certificate: `/miab-data/ssl/ssl_certificate.pem`
- private key: `/miab-data/ssl/ssl_private_key.pem`
> **Note:** You have to run the web server as root to access the private key file.
## Service Installation
```
sudo mkdir -p /opt/dns-updater
sudo cp app.py /opt/dns-updater/
sudo cp dns-updater.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now dns-updater.service
sudo systemctl status dns-updater.service
```

22
app/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM alpine:3.23
RUN apk add --no-cache python3 py3-pip
# Copy requirements and install Python packages
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --break-system-packages -r /tmp/requirements.txt
# Clean up apk cache
RUN rm -rf /var/cache/apk/*
WORKDIR /app
COPY app.py /app/app.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV LISTEN_ADDRESS=0.0.0.0
ENV LISTEN_PORT=8080
EXPOSE 8080
ENTRYPOINT [ "/entrypoint.sh" ]

101
app/app.py Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from flask import Flask, Response, request, jsonify
from flask_httpauth import HTTPBasicAuth
from base64 import b64encode
from requests import get, put, delete
from os import getenv
import jmespath
# Define global variables for MIAB host and auth header, to be set during authentication
MIAB_HOST = getenv('MIAB_HOST', 'localhost')
MIAB_AUTH_HEADER = ''
app = Flask('dns-updater')
auth = HTTPBasicAuth()
# Verify username and password by attempting to access a dummy DNS record
@auth.verify_password
def verify(username, password):
global MIAB_AUTH_HEADER, MIAB_HOST
MIAB_AUTH_HEADER = "Basic " + b64encode(f"{username}:{password}".encode()).decode()
result = get(f'https://{MIAB_HOST}/admin/dns/custom/test/A', headers={'Authorization': MIAB_AUTH_HEADER})
if result.status_code != 200:
return False
return True
@app.route('/api/setrecord/<qname>/<value>', methods=['GET'], defaults={'rtype': 'A'})
@app.route('/api/setrecord/<qname>/<rtype>/<value>', methods=['GET'])
@app.route('/api/setrecord/<qname>', methods=['PUT'], defaults={'rtype': 'A'})
@app.route('/api/setrecord/<qname>/<rtype>', methods=['PUT'])
@auth.login_required
def set_record(qname, rtype, value = None):
global MIAB_HOST, MIAB_AUTH_HEADER
# Use request data as value for PUT method
if request.method == 'PUT':
value = request.data.decode()
url = f'https://{MIAB_HOST}/admin/dns/custom/{qname}/{rtype}'
# Check the currect value
resp = get(url, headers={'Authorization': MIAB_AUTH_HEADER})
if resp.status_code == 200:
current_value = jmespath.compile('[0].value').search(resp.json())
if current_value == value:
return f'OK, no change' # No change needed
# Values are different or record does not exist, proceed to set the new value
resp = put(url, headers={'Authorization': MIAB_AUTH_HEADER, 'Content-Type': 'text/plain'}, data=value)
# Propagate 400-499 as is, 500+ as 502
if resp.status_code != 200:
if 400 <= resp.status_code < 500:
status = resp.status_code
elif resp.status_code >= 500:
status = 502
else:
status = 500
return Response(f'ERROR: {resp.status_code}\n', status=status)
# Success
return f'OK, record set'
@app.route('/api/deleterecord/<qname>/<rtype>', methods=['GET', 'DELETE'])
@auth.login_required
def delete_record(qname, rtype):
global MIAB_HOST, MIAB_AUTH_HEADER
url = f'https://{MIAB_HOST}/admin/dns/custom/{qname}/{rtype}'
resp = delete(url, headers={'Authorization': MIAB_AUTH_HEADER, 'Content-Type': 'text/plain'}, data='')
# Propagate 400-499 as is, 500+ as 502
if resp.status_code != 200:
if 400 <= resp.status_code < 500:
status = resp.status_code
elif resp.status_code >= 500:
status = 502
else:
status = 500
return Response(f'ERROR\n', status=status)
# Success
return f'OK, record deleted.\n'
@app.route('/api/listrecords', methods=['GET'], defaults={'qname': None, 'rtype': None})
@app.route('/api/listrecords/<qname>', methods=['GET'], defaults={'rtype': None})
@app.route('/api/listrecords/<qname>/<rtype>', methods=['GET'])
@auth.login_required
def list_records(qname, rtype):
global MIAB_HOST, MIAB_AUTH_HEADER
url = f'https://{MIAB_HOST}/admin/dns/custom'
if qname:
url += f'/{qname}'
if rtype:
url += f'/{rtype}'
resp = get(url, headers={'Authorization': MIAB_AUTH_HEADER})
return jsonify(resp.json())
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)

14
app/dns-updater.service Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=dns-updater
After=network.target
[Service]
User=root
Group=root
WorkingDirectory=/opt/dns-updater
Environment="MIAB_HOST=box.koszewscy.waw.pl"
ExecStart=/usr/bin/gunicorn --workers 4 --bind 0.0.0.0:8443 --certfile="/miab-data/ssl/ssl_certificate.pem" --keyfile="/miab-data/ssl/ssl_private_key.pem" app:app
Restart=always
[Install]
WantedBy=multi-user.target

14
app/entrypoint.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -e
LISTEN_ADDRESS="${LISTEN_ADDRESS:-0.0.0.0}"
LISTEN_PORT="${LISTEN_PORT:-8080}"
cd /app
if [ -z "$CERT_FILE" ] || [ -z "$KEY_FILE" ]; then
exec gunicorn --bind ${LISTEN_ADDRESS}:${LISTEN_PORT} app:app
else
exec gunicorn --bind ${LISTEN_ADDRESS}:${LISTEN_PORT} --certfile=${CERT_FILE} --keyfile=${KEY_FILE} app:app
fi

13
app/install Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/bash
if [[ $(id -u) -ne 0 ]]; then
echo "This script must be run as root" >&2
exit 1
fi
mkdir -p /opt/dns-updater
cp app.py /opt/dns-updater/
cp dns-updater.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now dns-updater.service
systemctl status dns-updater.service

5
app/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask
flask-httpauth
gunicorn
requests
jmespath

4
build Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/bash
# Build the Docker image
docker build -t skoszewski/omada-dyndns-miab-proxy app

18
delete-record.py Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import miab.dns as miab_dns
import argparse
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Delete a DNS record in Mail-in-a-Box")
parser.add_argument("name", type=str, help="The name of the DNS record to delete")
parser.add_argument("type", type=str, help="The type of the DNS record to delete (e.g., A, CNAME, MX)")
args = parser.parse_args()
miab = miab_dns.MailInABox()
response = miab.delete_record(name=args.name, type=args.type)
if response == "OK":
print("Record deleted successfully.")
else:
print("Failed to delete record.")

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env python3
import http.server
import argparse
class DNSUpdateHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(b'DNS Update Service is running.\n')
def run(server_class=http.server.HTTPServer, handler_class=DNSUpdateHandler, address='127.0.0.1', port=8080):
server_address = (address, port)
httpd = server_class(server_address, handler_class)
print(f'Starting DNS Update Service on {address}:{port}...')
httpd.serve_forever()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Run the DNS Update Service.')
parser.add_argument('--port', type=int, default=8080, help='Port to run the server on')
parser.add_argument('--address', type=str, default='127.0.0.1', help='IP address to listen on (not used in this simple example)')
args = parser.parse_args()
try:
run(address=args.address, port=args.port)
except KeyboardInterrupt:
print("\nShutting down DNS Update Service.")

13
list-records.py Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import miab.dns as miab_dns
import json
import argparse
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="List DNS records in Mail-in-a-Box")
parser.add_argument("--type", type=str, help="Filter records by type (e.g., A, CNAME, MX)")
args = parser.parse_args()
records = miab_dns.list_records(record_type=args.type)
for record in records:
print(json.dumps(record, indent=2))

7
miab/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
# Initializer for helpers package
from os import getenv
from base64 import b64encode
MIAB_HOST = getenv('MIAB_HOST', 'box.koszewscy.waw.pl')
MIAB_AUTH_HEADER = "Basic " + b64encode(f"{getenv('MIAB_USERNAME', 'admin')}:{getenv('MIAB_PASSWORD', 'password')}".encode()).decode()

48
miab/dns.py Normal file
View File

@@ -0,0 +1,48 @@
from . import MIAB_HOST, MIAB_AUTH_HEADER
from requests import get, put, delete
import jmespath
def list_records(record_type: str = None):
response = get(
f"https://{MIAB_HOST}/admin/dns/custom",
headers={"Authorization": MIAB_AUTH_HEADER}
)
if response.status_code == 200:
records = response.json()
if record_type:
jmespath_expr = f"[?rtype=='{record_type.upper()}']"
else:
jmespath_expr = "[]"
return jmespath.search(jmespath_expr + ".{name: qname, type: rtype, value: value}", records)
else:
raise Exception(f"Failed to retrieve DNS records: {response.status_code} {response.text}")
def set_record(name: str, value: str, type: str = "A"):
response = put(
f"https://{MIAB_HOST}/admin/dns/custom/{name}/{type.upper()}",
headers={
"Authorization": MIAB_AUTH_HEADER,
"Content-Type": "text/plain"
},
data=value
)
if response.status_code == 200:
return response.text
else:
raise Exception(f"Failed to set DNS record: {response.status_code} {response.text}")
def delete_record(name: str, type: str):
response = delete(
f"https://{MIAB_HOST}/admin/dns/custom/{name}/{type}",
headers={
"Authorization": MIAB_AUTH_HEADER,
"Content-Type": "text/plain"
},
data=""
)
if response.status_code == 200:
return response.text
else:
raise Exception(f"Failed to delete DNS record: {response.status_code} {response.text}")

19
set-record.py Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
import miab.dns as miab_dns
import argparse
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Set a DNS record in Mail-in-a-Box")
parser.add_argument("name", type=str, help="The name of the DNS record to set")
parser.add_argument("value", type=str, help="The value of the DNS record to set")
parser.add_argument("--type", type=str, default="A", help="The type of the DNS record (default: A)")
args = parser.parse_args()
miab = miab_dns.MailInABox()
response = miab.set_record(name=args.name, value=args.value, type=args.type)
if response == "OK":
print("Record set successfully.")
else:
print("Failed to set record.")