diff --git a/.gitignore b/.gitignore index 15201ac..103fa1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Application files +tokens +hosts + +# vim +*.swp + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7920b74 --- /dev/null +++ b/README.md @@ -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 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/ +``` diff --git a/server.py b/server.py index ff15307..c59a706 100644 --- a/server.py +++ b/server.py @@ -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.get("/") -@app.get("/status") -async def status(): - """Status endpoint""" - return {"message": "Hello World!"} +header_scheme = APIKeyHeader(name=API_TOKEN_HEADER) + + +def update_hosts_file(registry): + """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