Compare commits
24 Commits
3d43006257
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a1df4dcfdf | |||
| af3d1fd3cb | |||
| 0989f51a55 | |||
| 827ed9f83b | |||
| 6d5dbee874 | |||
| 63f67c7188 | |||
| bfebad5e5d | |||
| bc946e62e2 | |||
| 14d90d34aa | |||
| d77801788f | |||
| f77ade44d5 | |||
| c5d6916fa6 | |||
| 1b2bb83eeb | |||
| 2884cd91a8 | |||
| 62586de020 | |||
| fa95fd0279 | |||
| 4b943ee0c9 | |||
| 59f3d2377d | |||
| dbf59fd124 | |||
| 9f6344ba4c | |||
| c2189bf826 | |||
| 2db1671d8a | |||
| b78c848525 | |||
| 68b7bd9997 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
.env
|
**/.venv
|
||||||
|
**/.env
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pem
|
||||||
|
|||||||
68
README.md
Normal file
68
README.md
Normal 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
22
app/Dockerfile
Normal 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
101
app/app.py
Executable 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
14
app/dns-updater.service
Normal 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
14
app/entrypoint.sh
Normal 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
13
app/install
Executable 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
5
app/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
flask
|
||||||
|
flask-httpauth
|
||||||
|
gunicorn
|
||||||
|
requests
|
||||||
|
jmespath
|
||||||
4
build
Executable file
4
build
Executable 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
18
delete-record.py
Executable 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.")
|
||||||
@@ -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
13
list-records.py
Executable 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
7
miab/__init__.py
Normal 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
48
miab/dns.py
Normal 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
19
set-record.py
Executable 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.")
|
||||||
Reference in New Issue
Block a user