diff --git a/powerdns_setup.yml b/powerdns_setup.yml index 1cff698..09bcd7d 100644 --- a/powerdns_setup.yml +++ b/powerdns_setup.yml @@ -15,7 +15,7 @@ state: present vars: pdns_auth_version: "48" - + - name: Update apt cache ansible.builtin.apt: update_cache: yes @@ -24,7 +24,7 @@ ansible.builtin.file: state: absent path: /etc/resolv.conf - + - name: Copy Resolve configuration file ansible.builtin.copy: src: resolv.conf @@ -32,7 +32,7 @@ owner: root group: root mode: '0644' - + - name: Disable and stop systemd-resolved ansible.builtin.systemd: name: systemd-resolved @@ -48,7 +48,7 @@ - logrotate - nginx state: present - + - name: Install Node.js 18.x repository shell: "curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -" @@ -57,7 +57,7 @@ name: nodejs state: latest update_cache: yes - + - name: Create the destination directory file: path: /opt/healthchecker/src @@ -69,7 +69,7 @@ src: src/ dest: /opt/healthchecker/src recursive: yes - + - name: Template the health route ansible.builtin.template: src: src/routes/health.js.j2 @@ -89,7 +89,7 @@ command: /opt/healthchecker/src/start.sh args: chdir: /opt/healthchecker/src - + - name: Copy PowerDNS configuration file ansible.builtin.template: @@ -112,6 +112,9 @@ - name: Set variables for STAGING set_fact: APP_LIST: + - START: "0" + END: "9" + IPs: ["fdm-lb-2-1.runonflux.io"] - START: "a" END: "m" IPs: ["fdm-lb-2-1.runonflux.io"] @@ -123,6 +126,9 @@ - name: Set variables for PRODUCTION set_fact: APP_LIST: + - START: "0" + END: "9" + IPs: ["fdm-lb-1-1.runonflux.io"] - START: "a" END: "g" IPs: ["fdm-lb-1-1.runonflux.io"] @@ -173,4 +179,4 @@ job: "/usr/sbin/logrotate /etc/logrotate.conf" day: "*" hour: "0" - minute: "0" \ No newline at end of file + minute: "0" diff --git a/scripts/pdns_pipe_backend.py.j2 b/scripts/pdns_pipe_backend.py.j2 index 6bf351e..6072766 100644 --- a/scripts/pdns_pipe_backend.py.j2 +++ b/scripts/pdns_pipe_backend.py.j2 @@ -1,13 +1,12 @@ #!/usr/bin/python3 +from __future__ import annotations +import re +from dataclasses import dataclass, field from sys import stdin, stdout -import os -import hashlib +from typing import ClassVar -SPLIT_NAME = "NAME" -SPLIT_HASH = "HASH" - -SPLIT_LIST = [ +targets = [ {% for app in APP_LIST %} { "start": "{{ app.START }}", @@ -17,42 +16,213 @@ SPLIT_LIST = [ {% endfor %} ] -APP_CONF = { - "TYPE": SPLIT_NAME, - "SPLITS": { - char: ip - for conf in SPLIT_LIST - for char_code in range(ord(conf['start']), ord(conf['end'])+1) - for char, ip in zip(chr(char_code), conf['ips']) - }, -} - -data = stdin.readline() -stdout.write("OK\tMy Backend\n") -stdout.flush() - -config = APP_CONF -def get_char_to_find(name, split_type): - if split_type == SPLIT_NAME: - return name[0] - else: - hash_object = hashlib.sha256(name) - hex_dig = hash_object.hexdigest() - return hex_dig[0] - -while True: - data = stdin.readline().strip() - kind, qname, qclass, qtype, id, ip = data.split("\t") - if qtype == "SOA": - stdout.write("DATA\t" + qname + "\t" + qclass + "\tSOA\t3600\t" + id + "\tns1.runonflux.io st.runonflux.io 2022040801 3600 600 86400 3600\n") - else: - name = qname.split(".")[0] - char_to_search = get_char_to_find(name, config["TYPE"]) - if char_to_search not in config["SPLITS"]: - stdout.write("NXDOMAIN\n") - else: - stdout.write("DATA\t" + qname + "\t" + qclass + "\tCNAME\t3600\t" + id + f'\t{config["SPLITS"][char_to_search]}\n') - - stdout.write("LOG\t" + data + "\n") - stdout.write("END\n") - stdout.flush() \ No newline at end of file + +def build_domain_map() -> dict[str, list[str]]: + domain_map = {} + + for target in targets: + char_range = range(ord(target["start"]), ord(target["end"]) + 1) + + chars = [chr(x) for x in char_range] + + domain_map = domain_map | { + first_char: target["ips"] for first_char in chars + } + + return domain_map + + +@dataclass +class Response: + question: Question + answers: list[Answer] + enable_logging: bool = True + + def __str__(self) -> str: + """Serializes one or more answers into a response. + + If there are no answers (NXDOMAIN) we sent an empty end, + which pdns interprets as NXDOMAIN + + Returns: + str: The serialized string + """ + data = self.answers[:] + terminator = "END\n" + + if self.enable_logging: + data.append(f"LOG\t{str(self.question)}") + + # some answers can be empty - for example *.app.runonflux.io + filtered = [str(x) for x in data if str(x)] + + as_str = "\n".join(filtered) + as_str = as_str + "\n" if as_str else "" + + return f"{as_str}{terminator}" + + +@dataclass +class Answer: + domain_map: ClassVar[dict] = build_domain_map() + qname: str + qclass: str + qtype: str + id: str + content: list[str] = field(default_factory=list) + ttl: str = "3600" + + def __str__(self) -> str: + lines = [] + + for item in self.content: + line = [ + "DATA", + self.qname, + self.qclass, + self.qtype, + self.ttl, + self.id, + item, + ] + lines.append("\t".join(line)) + + return "\n".join(lines) + + def build(self) -> Answer: + if not len(self.content): + self.content = Answer.domain_map.get(self.qname[0], []) + return self + + +@dataclass(kw_only=True) +class SoaAnswer(Answer): + qtype: str = "SOA" + content: list = field( + default_factory=lambda: [ + "ns1.runonflux.io st.runonflux.io 2022040801 3600 600 86400 3600" + ] + ) + + +@dataclass(kw_only=True) +class CnameAnswer(Answer): + qtype: str = "CNAME" + + +@dataclass +class Question: + answer_map: ClassVar[dict] = { + "ANY": [SoaAnswer, CnameAnswer], + "SOA": [SoaAnswer], + "CNAME": [CnameAnswer], + } + question_type: str # Q or AXFR + qname: str # domain + qclass: str # IN (INternet question) + qtype: str # The type, SOA, A, AAAA, CNAME etc + id: str + remote_address: str + + @classmethod + def fromString(cls, data: str) -> Question: + fields = data.rstrip("\n").split("\t") + return cls(*fields) + + def __post_init__(self) -> None: + self.qname = self.qname.lower() + + def __str__(self) -> str: + return "\t".join( + [ + self.question_type, + self.qname, + self.qclass, + self.qtype, + self.id, + self.remote_address, + ] + ) + + def answers(self) -> list[Answer]: + answer_classes = Question.answer_map.get(self.qtype) + answers = [] + + if not answer_classes: + return answers + + for cls in answer_classes: + answer: Answer = cls( + qname=self.qname, + qclass=self.qclass, + id=self.id, + ) + answers.append(answer.build()) + + return answers + + def response(self) -> Response: + answers = self.answers() + + response = Response(self, answers) + return str(response) + + +@dataclass +class AbiV1Question(Question): ... + + +@dataclass +class AbiV2Question(AbiV1Question): + local_address: str + + +@dataclass +class AbiV3Question(AbiV2Question): + edns_address: str + + +def fail() -> None: + stdout.write("FAIL\n") + stdout.flush() + stdin.readline() + + +def main() -> None: + helo_line = stdin.readline() + + handshake: re.Match = re.search("^HELO\\t(?P[1-3])", helo_line) + + if not handshake: + return fail() + + abi_version = handshake.group("version") + + if abi_version not in ["1", "2", "3"]: + return fail() + + stdout.write("OK\tFlux DNS Backend\n") + stdout.flush() + + # I believe we only support V1, if this changes ,we can update easily + match abi_version: + case 1: + cls = AbiV1Question + # case 2: + # cls = AbiV2Question + # case 3: + # cls = AbiV3Question + case _: + cls = AbiV1Question + + for line in stdin: + question = cls.fromString(line) + response = question.response() + stdout.write(response) + stdout.flush() + + +# ques = AbiV1Question.fromString("Q\tGrAvY.com\tIN\tANY\tblah\t192.168.1.1\n") +# print(ques.response()) + +main()