|
|
|
#!/usr/bin/python3
|
|
|
|
|
|
|
|
import shutil, os, re, _thread, socket, sys, re, yaml, logging as log
|
|
|
|
|
|
|
|
import dnslib as dns
|
|
|
|
|
|
|
|
DATA_PATH="/data"
|
|
|
|
APP_PATH="/app"
|
|
|
|
HOSTS_PATH="/app/hosts"
|
|
|
|
|
|
|
|
config = None
|
|
|
|
def main(args):
|
|
|
|
setupLogging(True)
|
|
|
|
|
|
|
|
setupEnvironment()
|
|
|
|
|
|
|
|
global config
|
|
|
|
config = readConfig(os.path.join(DATA_PATH, "config.yml"))
|
|
|
|
|
|
|
|
s = setupSocket(config['socket']['address'], config['socket']['port'])
|
|
|
|
|
|
|
|
startListen(s)
|
|
|
|
|
|
|
|
def setupEnvironment():
|
|
|
|
if not os.path.exists(DATA_PATH):
|
|
|
|
os.mkdir(DATA_PATH)
|
|
|
|
|
|
|
|
(all, some) = copyAllFiles(f"{APP_PATH}/data/", DATA_PATH)
|
|
|
|
if all:
|
|
|
|
log.warning("Configuration-files were created!")
|
|
|
|
log.warning(" Make sure to change them according to your setup")
|
|
|
|
elif some:
|
|
|
|
log.warning("Some configuration-files were recreated, because they were missing")
|
|
|
|
|
|
|
|
if not os.path.exists(HOSTS_PATH):
|
|
|
|
os.mkdir(HOSTS_PATH)
|
|
|
|
|
|
|
|
def copyAllFiles(src, dst, overwrite=False):
|
|
|
|
all=True
|
|
|
|
some=False
|
|
|
|
for file in os.listdir(src):
|
|
|
|
src_file = os.path.join(src, file)
|
|
|
|
dst_file = os.path.join(dst, file)
|
|
|
|
if os.path.isfile(src_file):
|
|
|
|
if overwrite or not os.path.exists(dst_file):
|
|
|
|
shutil.copyfile(src_file, dst_file)
|
|
|
|
some=True
|
|
|
|
else:
|
|
|
|
all=False
|
|
|
|
return (all, some)
|
|
|
|
|
|
|
|
def setupSocket(address, port):
|
|
|
|
# IPv4/IPv6-check
|
|
|
|
if address == "" or ":" in address:
|
|
|
|
family = socket.AF_INET6
|
|
|
|
else:
|
|
|
|
family = socket.AF_INET
|
|
|
|
|
|
|
|
s = socket.socket(family, socket.SOCK_DGRAM)
|
|
|
|
s.bind((address, port))
|
|
|
|
return s
|
|
|
|
|
|
|
|
def startListen(s):
|
|
|
|
log.debug(f'Now listening')
|
|
|
|
while True:
|
|
|
|
(address, dmsg) = receiveFromWire(s)
|
|
|
|
_thread.start_new_thread(handleQuery, (s, address, dmsg))
|
|
|
|
|
|
|
|
def receiveFromWire(s):
|
|
|
|
(wire, address) = s.recvfrom(512)
|
|
|
|
dmsg = dns.DNSRecord.parse(wire)
|
|
|
|
return (address, dmsg)
|
|
|
|
|
|
|
|
def handleQuery(s, address, dmsg):
|
|
|
|
log.info(f'{address[0]} |\tGot query {dmsg.header.id}')
|
|
|
|
|
|
|
|
opcode = dmsg.header.opcode
|
|
|
|
if opcode != dns.OPCODE.NOTIFY:
|
|
|
|
log.error(f"{address[0]} |\tExpected opcode=NOTIFY, but was {dns.OPCODE[opcode]}")
|
|
|
|
makeResponseWithRCode(s, address, dmsg, dns.RCODE.REFUSED)
|
|
|
|
return False
|
|
|
|
|
|
|
|
rcode = dmsg.header.rcode
|
|
|
|
if rcode != dns.RCODE.NOERROR:
|
|
|
|
log.error(f"{address[0]} |\tExpected rcode=NOERROR, but was {dns.RCODE[rcode]}")
|
|
|
|
makeResponseWithRCode(s, address, dmsg, dns.RCODE.REFUSED)
|
|
|
|
return False
|
|
|
|
|
|
|
|
#flags = dmsg.flags
|
|
|
|
#if flags != dns.flags.AA:
|
|
|
|
# print('Expected flags=AA, but was', dns.flags.to_text(flags))
|
|
|
|
# continue
|
|
|
|
|
|
|
|
if len(dmsg.questions) != 1:
|
|
|
|
log.error(f'{address[0]} |\tExpected question-len=1, but was {len(dmsg.question)}')
|
|
|
|
makeResponseWithRCode(s, address, dmsg, dns.RCODE.FORMERR)
|
|
|
|
return False
|
|
|
|
|
|
|
|
# Check record in question
|
|
|
|
record = dmsg.questions[0]
|
|
|
|
|
|
|
|
r_qtype = record.qtype
|
|
|
|
if r_qtype != dns.QTYPE.SOA:
|
|
|
|
log.error(f'{address[0]} |\tExpected record to be SOA, but was {dns.QTYPE[r_qtype]}')
|
|
|
|
makeResponseWithRCode(s, address, dmsg, dns.RCODE.FORMERR)
|
|
|
|
return False
|
|
|
|
|
|
|
|
name = str(record.qname)
|
|
|
|
|
|
|
|
log.info(f'{address[0]} |\tNOTIFY for {name}')
|
|
|
|
|
|
|
|
response = dmsg.reply() # type: dns.message.Message
|
|
|
|
response.header.aa = 1
|
|
|
|
sendResponse(s, address, response)
|
|
|
|
log.debug(f'{address[0]} |\tSent response')
|
|
|
|
|
|
|
|
# FIXME: see: data/dnsconfig.js require_glob
|
|
|
|
#_thread.start_new_thread(updateNsData, (name,))
|
|
|
|
updateNsData(name)
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
def makeResponseWithRCode(socket, address, dmsg, rcode):
|
|
|
|
response = dmsg.reply() # type: dns.message.Message
|
|
|
|
response.header.rcode = rcode
|
|
|
|
sendResponse(socket, address, response)
|
|
|
|
|
|
|
|
def sendResponse(socket, address, response):
|
|
|
|
socket.sendto(response.pack(), address)
|
|
|
|
|
|
|
|
def setupLogging(verbose: bool):
|
|
|
|
level = log.INFO
|
|
|
|
format = '%(levelname)s:\t%(message)s'
|
|
|
|
|
|
|
|
if verbose:
|
|
|
|
level = log.DEBUG
|
|
|
|
|
|
|
|
log.basicConfig(stream=sys.stdout, format=format, level=level)
|
|
|
|
log.debug("Logging started")
|
|
|
|
|
|
|
|
def readConfig(file: str):
|
|
|
|
log.debug(f"Reading config '{file}'..")
|
|
|
|
|
|
|
|
if not os.path.isfile(file):
|
|
|
|
raise OSError(2, file)
|
|
|
|
|
|
|
|
return readYamlFile(file)
|
|
|
|
|
|
|
|
def readYamlFile(file: str):
|
|
|
|
with open(file, "r") as f:
|
|
|
|
return yaml.load(f, Loader=yaml.FullLoader)
|
|
|
|
|
|
|
|
def updateNsData(zone):
|
|
|
|
hasToDelete = False
|
|
|
|
try:
|
|
|
|
zone = zone[:-1]
|
|
|
|
adaptedZone = adaptZoneName(zone)
|
|
|
|
|
|
|
|
log.info(f'{adaptedZone} |\tUpdating NS-Data')
|
|
|
|
|
|
|
|
# FIXME: see: data/dnsconfig.js require_glob
|
|
|
|
#dumpFile = f"{adaptedZone}.dump.js"
|
|
|
|
dumpFile = "dump.js"
|
|
|
|
|
|
|
|
if dumpZoneData(zone, dumpFile) != 0:
|
|
|
|
raise Exception("Dumping data failed!")
|
|
|
|
zone = adaptedZone
|
|
|
|
|
|
|
|
hasToDelete = True
|
|
|
|
|
|
|
|
adaptFileForRequire(zone, HOSTS_PATH, dumpFile)
|
|
|
|
if dnscontrolPush(zone) != 0:
|
|
|
|
raise Exception("Pushing data failed!")
|
|
|
|
|
|
|
|
log.info(f'{adaptedZone} |\tFinished')
|
|
|
|
except:
|
|
|
|
log.warning(f'{adaptedZone} |\t{sys.exc_info()}')
|
|
|
|
log.warning(f'{adaptedZone} |\tUpdating NS-Data failed!')
|
|
|
|
|
|
|
|
if(hasToDelete):
|
|
|
|
deleteFile(os.path.join(HOSTS_PATH, dumpFile))
|
|
|
|
|
|
|
|
def adaptZoneName(zone):
|
|
|
|
if config['zone']['public-suffix'] != "" and zone.endswith(config['zone']['public-suffix']):
|
|
|
|
adaptedZone = zone[:-len(config['zone']['public-suffix'])]
|
|
|
|
return adaptedZone
|
|
|
|
return zone
|
|
|
|
|
|
|
|
def dumpZoneData(zone, dumpFile):
|
|
|
|
log.debug(f"{zone} |\tDumping to '{dumpFile}'..")
|
|
|
|
return os.system(f"dnscontrol get-zones --creds {DATA_PATH}/creds.json --format=js --out={HOSTS_PATH}/{dumpFile} powerdns POWERDNS {zone}")
|
|
|
|
|
|
|
|
def deleteFile(file):
|
|
|
|
log.debug(f"Deleting file '{file}'")
|
|
|
|
os.remove(file)
|
|
|
|
|
|
|
|
ignoreLinesRexp = r"^\s*(var|D\(|DnsProvider\(|DefaultTTL\()"
|
|
|
|
def adaptFileForRequire(zone, path, dumpFile):
|
|
|
|
log.debug(f"{zone} |\tRewriting file '{dumpFile}'..")
|
|
|
|
|
|
|
|
dumpFilePath = os.path.join(path, dumpFile)
|
|
|
|
|
|
|
|
with open(dumpFilePath, 'r') as fin:
|
|
|
|
with open(f"{dumpFilePath}.tmp", 'w+') as fout:
|
|
|
|
fout.write(f'D_EXTEND("{zone}",\n')
|
|
|
|
|
|
|
|
for line in fin:
|
|
|
|
if not re.match(ignoreLinesRexp, line):
|
|
|
|
fout.write(line)
|
|
|
|
os.replace(f"{dumpFilePath}.tmp", dumpFilePath)
|
|
|
|
|
|
|
|
def dnscontrolPush(zone):
|
|
|
|
log.debug(f'{zone} |\tPushing..')
|
|
|
|
return os.system(f"dnscontrol push --config {DATA_PATH}/dnsconfig.js --creds {DATA_PATH}/creds.json --domains {zone}")
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
sys.exit(main(sys.argv))
|