Skip to content

Commit

Permalink
Merge pull request #3 from RunOnFlux/feature/rework_backend
Browse files Browse the repository at this point in the history
Feature/rework backend
  • Loading branch information
TheTrunk authored Jul 3, 2024
2 parents d905d79 + a95ff31 commit 5cd6e69
Show file tree
Hide file tree
Showing 2 changed files with 229 additions and 53 deletions.
22 changes: 14 additions & 8 deletions powerdns_setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
state: present
vars:
pdns_auth_version: "48"

- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
Expand All @@ -24,15 +24,15 @@
ansible.builtin.file:
state: absent
path: /etc/resolv.conf

- name: Copy Resolve configuration file
ansible.builtin.copy:
src: resolv.conf
dest: /etc/resolv.conf
owner: root
group: root
mode: '0644'

- name: Disable and stop systemd-resolved
ansible.builtin.systemd:
name: systemd-resolved
Expand All @@ -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 -"

Expand All @@ -57,7 +57,7 @@
name: nodejs
state: latest
update_cache: yes

- name: Create the destination directory
file:
path: /opt/healthchecker/src
Expand All @@ -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
Expand All @@ -89,7 +89,7 @@
command: /opt/healthchecker/src/start.sh
args:
chdir: /opt/healthchecker/src


- name: Copy PowerDNS configuration file
ansible.builtin.template:
Expand All @@ -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"]
Expand All @@ -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"]
Expand Down Expand Up @@ -173,4 +179,4 @@
job: "/usr/sbin/logrotate /etc/logrotate.conf"
day: "*"
hour: "0"
minute: "0"
minute: "0"
260 changes: 215 additions & 45 deletions scripts/pdns_pipe_backend.py.j2
Original file line number Diff line number Diff line change
@@ -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 }}",
Expand All @@ -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()

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<version>[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()

0 comments on commit 5cd6e69

Please sign in to comment.