progress
parent
2cdb3b2d53
commit
241b638449
|
|
@ -1,3 +1,10 @@
|
|||
# Application files
|
||||
tokens
|
||||
hosts
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -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
133
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue