1
0
Fork 0
development
Kevin Wojkovich 2024-12-25 21:51:41 -06:00
parent 2cdb3b2d53
commit 241b638449
3 changed files with 193 additions and 6 deletions

7
.gitignore vendored
View File

@ -1,3 +1,10 @@
# Application files
tokens
hosts
# vim
*.swp
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# Motivations
* Delegate DNS management while maintaining the smallest footprint ever.
* Do whatever is necessary to keep customized code to a minimum.
I wanted to use off-the-shelf components where possible.
* Provide a convenient interface for managing DNS records.
* Do as little as possible as `root`.
# Usage
Visit the Swagger or Redoc documentation at `/docs/` or `/redoc`, respectively.
It's a simple path-based HTTP API.
## Token File
The token file is a brain-dead access control mechanism. It is a file that
contains a single "API Key" per line. You are free to mint and distribute
API Keys that users can include in the `X-MASKER-TOKEN` header with their
requests.
## Hosts File
The hosts file is created and managed by `dnsmasker`. It follows the
standard hosts file convention: `IP address <tab> Name`
# Setup
## Python Application
```sh
$ python -m venv .venv
$ source .venv/bin/acticate
$ pip install -r requirements.txt
# run the application
$ flask run server.py --host 0.0.0.0 --port 8000
```
## Sudoers configuration
```
# allow 'user' to hup dnsmasq
user ALL=(root) pkill -HUP dnsmasq
```
## dnsmasq.conf
Modify the dnsmasq configuration file to use a custom
hosts file and prevent any forwarding loops.
```
# Use a custom hosts file
no-hosts
addn-hosts=/home/user/pydnsmasker/hosts
# Prevent unnecessary forwarding
domain-needed
bogus-priv
no-resolv
local=/domain/
local=/domain2/
```

133
server.py
View File

@ -1,9 +1,130 @@
from fastapi import FastAPI import os
import re
import subprocess
from typing import List
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
TOKEN_FILE = os.getenv("DNSMASKER_TOKEN_FILE", "tokens")
HOSTS_FILE = os.getenv("DNSMASKER_HOSTS_FILE", "hosts")
RELOAD_COMMAND = os.getenv("DNSMASKER_RELOAD_CMD", "sudo pkill -HUP dnsmasq")
API_TOKEN_HEADER = os.getenv("DNSMASKER_API_TOKEN_HEADER", "x-api-token")
app = FastAPI() app = FastAPI()
@app.get("/") header_scheme = APIKeyHeader(name=API_TOKEN_HEADER)
@app.get("/status")
async def status():
"""Status endpoint""" def update_hosts_file(registry):
return {"message": "Hello World!"} """Writes the hosts file."""
hosts_file = ["# managed by dnsmasker"]
ip_registry = {}
for service, ips in registry.items():
for ip in ips:
if ip in ip_registry:
ip_registry[ip].append(service)
else:
ip_registry[ip] = [service]
for ip, services in ip_registry.items():
line = f"{ip}\t {' '.join(services)}"
hosts_file.append(line)
with open(HOSTS_FILE, "w") as fd:
fd.write("\n".join(hosts_file))
def check_token(token: str = Depends(header_scheme)):
"""Check if provided token is valid."""
try:
with open(TOKEN_FILE, "r") as fd:
tokens = fd.read().split("\n")
if token not in tokens:
raise HTTPException(status_code=403, detail="Unauthorized")
except KeyError:
raise HTTPException(status_code=403, detail="Unauthorized")
@app.post("/reload", dependencies=[Depends(check_token)])
def reload_config():
"""Tell dnsmasq to reload the config and refresh its cache."""
command = RELOAD_COMMAND.split()
proc = subprocess.run(command)
if proc.returncode != 0:
raise HTTPException(
status_code=500, detail=f"Unable to reload dnsmasq with: {command}"
)
else:
return "success"
@app.post("/service/{name}/{ip}", dependencies=[Depends(check_token)])
async def register_service(name: str, ip: str):
"""Add a service to the catalog"""
registry = await read_services()
if name in registry:
registry[name].append(ip)
else:
registry[name] = [ip]
update_hosts_file(registry)
return registry
@app.delete("/service/{name}/{ip}", dependencies=[Depends(check_token)])
async def register_service(name: str, ip: str):
"""Remove a service to the catalog"""
registry = await read_services()
if name in registry:
current = registry[name]
updated = [addr for addr in current if addr != ip]
registry[name] = updated
update_hosts_file(registry)
return registry
@app.get("/service/{name}")
async def read_service(name: str):
"""Reads the hosts file, produces a service catalog entry."""
catalog = {}
with open(HOSTS_FILE, "r") as fd:
hosts_file = fd.read().split("\n")
entries = [
entry for entry in hosts_file if not entry.startswith("#") and entry != "\n"
]
for entry in entries:
match = re.match(r"\s*(\d+\.\d+\.\d+\.\d+)\s+(.+)", entry)
if match:
ip_address = match.group(1)
hostnames = match.group(2).split()
for hostname in hostnames:
if hostname not in catalog:
catalog[hostname] = [ip_address]
else:
catalog[hostname].append(ip_address)
return catalog[name]
@app.get("/services")
async def read_services():
"""Reads the hosts file, produces a service catalog."""
catalog = {}
with open(HOSTS_FILE, "r") as fd:
hosts_file = fd.read().split("\n")
entries = [
entry for entry in hosts_file if not entry.startswith("#") and entry != "\n"
]
for entry in entries:
match = re.match(r"\s*(\d+\.\d+\.\d+\.\d+)\s+(.+)", entry)
if match:
ip_address = match.group(1)
hostnames = match.group(2).split()
for hostname in hostnames:
if hostname not in catalog:
catalog[hostname] = [ip_address]
else:
catalog[hostname].append(ip_address)
return catalog