progress
parent
2cdb3b2d53
commit
241b638449
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 = 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue