From 7918aa5fd2da0ca8035c3877131ae03d5cfbf5f4 Mon Sep 17 00:00:00 2001 From: mawalu Date: Sun, 6 Feb 2022 18:34:54 +0100 Subject: [PATCH] Add HTTP api --- .gitignore | 2 +- Dockerfile | 11 ++++++ example.config | 8 ++++ lib/dns.nim | 5 +-- norbert.nim | 59 +++++++++++++++++++++++++++++ server.nim | 42 --------------------- src/apiserver.nim | 94 +++++++++++++++++++++++++++++++++++++++++++++++ src/dnsserver.nim | 35 ++++++++++++++++++ src/state.nim | 42 +++++++++++++++++++++ 9 files changed, 252 insertions(+), 46 deletions(-) create mode 100644 Dockerfile create mode 100644 example.config create mode 100644 norbert.nim delete mode 100644 server.nim create mode 100644 src/apiserver.nim create mode 100644 src/dnsserver.nim create mode 100644 src/state.nim diff --git a/.gitignore b/.gitignore index 56bf7c6..6b956b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -server +norbert .idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b47573 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM nimlang/nim:alpine as builder + +COPY . /build +WORKDIR /build + +RUN nim c -d:release --passL:-static norbert.nim + +FROM scratch + +COPY --from=builder /build/norbert /norbert +ENTRYPOINT ["/norbert"] \ No newline at end of file diff --git a/example.config b/example.config new file mode 100644 index 0000000..365b1cf --- /dev/null +++ b/example.config @@ -0,0 +1,8 @@ +# base domain for all records +baseDomain = "acme.example.com" +dnsPort = 15353 +apiPort = 18000 + +# can create records for *.exampleuser.acme.example.com +[exampleuser] +password = "changeme" diff --git a/lib/dns.nim b/lib/dns.nim index 18b6505..9b0fe63 100644 --- a/lib/dns.nim +++ b/lib/dns.nim @@ -15,7 +15,7 @@ type DnsType* = enum A = 1, NS = 2, MD = 3, MF =4, CNAME = 5, SOA = 6, MB = 7, MG = 8, MR = 9, NULL = 10, WKS = 11, PTR = 12, HINFO = 13, MINFO = 14, MX = 15, - TXT = 16, AXFR = 252, MAILB = 253, MAILA = 254, ANY = 255 + TXT = 16, AAAA = 28, AXFR = 252, MAILB = 253, MAILA = 254, ANY = 255 type DnsClass* = enum @@ -85,8 +85,7 @@ func packNameField*(input: string): string = finalName.add(chr(len(name))) finalName = finalName & name - if len(finalName) mod 2 != 0: - finalName.add(chr(0)) + finalName.add(chr(0)) return finalName diff --git a/norbert.nim b/norbert.nim new file mode 100644 index 0000000..ad037e2 --- /dev/null +++ b/norbert.nim @@ -0,0 +1,59 @@ +import asyncdispatch, nativesockets, strtabs, parsecfg, os, parseUtils, strformat +import src/dnsserver, src/apiserver, src/state + +proc initConfig(): AppConfig = + const exampleConfig = readFile("example.config") + + if paramCount() != 1: + echo "Usage: norbert ./path/to/config" + echo "" + echo "Example config:" + echo "" + echo exampleConfig + quit 0 + + try: + let configFile = loadConfig(paramStr(1)) + var apiPort: int + var dnsPort: int + + if parseInt(configFile.getSectionValue("", "apiPort", "18000"), apiPort) == 0 or + parseInt(configFile.getSectionValue("", "dnsPort", "15353"), dnsPort) == 0: + echo "Error parsing port config" + quit 1 + + let config = AppConfig( + base: configFile.getSectionValue("", "baseDomain"), + users: newStringTable(), + apiPort: Port(apiPort), + dnsPort: Port(dnsPort) + ) + + for user in configFile.sections: + if user == "": + continue + + echo &"Loading user {user}" + let password = configFile.getSectionValue(user, "password") + + if password == "": + echo &"Password missing for user {user}" + quit 1 + + config.users[user] = password + + return config + + except IOError: + echo "Could not read config" + quit 1 + +proc main() = + let config = initConfig() + + asyncCheck serveDns(config) + asyncCheck serveApi(config) + + runForever() + +main() \ No newline at end of file diff --git a/server.nim b/server.nim deleted file mode 100644 index cbba02f..0000000 --- a/server.nim +++ /dev/null @@ -1,42 +0,0 @@ -import asyncnet, asyncdispatch, nativesockets -import strutils, options, tables -import lib/dns - -const records = { - DnsType.A: {"m5w.de": @["\127\0\0\1"]}.toTable, - DnsType.TXT: {"m5w.de": @["hello world", "abc"]}.toTable -}.toTable - -proc handleDnsRequest(data: string): Option[string] = - let msg = parseMessage(data) - - if len(msg.questions) == 0: - return - - let question = msg.questions[0] - # todo: handle missing record - let answer = records[question.qtype][question.qname] - let response = mkResponse(msg.header.id, question, answer) - - return some(packMessage(response)) - -proc serve() {.async.} = - let server = newAsyncSocket(sockType=SockType.SOCK_DGRAM, protocol=Protocol.IPPROTO_UDP, buffered = false) - server.setSockOpt(OptReuseAddr, true) - server.bindAddr(Port(12345)) - - while true: - try: - let request = await server.recvFrom(size=512) - let response = handleDnsRequest(request.data) - - if (response.isSome): - await server.sendTo(request.address, request.port, response.unsafeGet) - except: - continue - -proc main() = - asyncCheck serve() - runForever() - -main() \ No newline at end of file diff --git a/src/apiserver.nim b/src/apiserver.nim new file mode 100644 index 0000000..abf8c01 --- /dev/null +++ b/src/apiserver.nim @@ -0,0 +1,94 @@ +import asynchttpserver, asyncdispatch, json, strtabs, base64, strutils, strformat, options, tables +import ../lib/dns, state + +const headers = {"Content-type": "text/plain; charset=utf-8"} +const authHeader = "authorization" + +type + NewRecordReq = object + fqdn: string + value: string + +type + Auth = tuple + name: string + password: string + +proc handleAuth(req: Request, config: AppConfig): Option[Auth] = + if not req.headers.hasKey(authHeader): + return none(Auth) + + let token = req.headers[authHeader].split(" ")[1] + let credentials = decode(token).split(":") + + let user = credentials[0] + let pw = credentials[1] + + if not config.users.hasKey(user) or config.users[user] != pw: + return none(Auth) + + return some((name: user, password: pw)) + +proc forbidden(req: Request): Future[void] = + return req.respond(Http401, "forbidden", headers.newHttpHeaders()) + +proc ok(req: Request): Future[void] = + return req.respond(Http200, "ok", headers.newHttpHeaders()) + +proc notFound(req: Request): Future[void] = + return req.respond(Http404, "not found", headers.newHttpHeaders()) + +proc present(req: Request, auth: Auth, base: string): Future[void] {.async.} = + let record = to(parseJson(req.body), NewRecordReq) + let name = trimName(record.fqdn) & "." & auth.name & "." & base + + addRecord( + records, + (name: name, dtype: DnsType.TXT), + record.value + ) + + await ok(req) + +proc cleanup(req: Request, auth: Auth, base: string): Future[void] {.async.} = + let record = to(parseJson(req.body), NewRecordReq) + let name = trimName(record.fqdn) & "." & auth.name & "." & base + + delRecord( + records, + (name: name, dtype: DnsType.TXT), + record.value + ) + + await ok(req) + +proc serveApi*(config: AppConfig) {.async.} = + let http = newAsyncHttpServer() + + proc cb(req: Request) {.async.} = + let auth = handleAuth(req, config) + + if auth.isNone(): + await forbidden(req) + return + + let user = auth.unsafeGet() + + if req.url.path == "/present": + await present(req, user, config.base) + elif req.url.path == "/cleanup": + await cleanup(req, user, config.base) + else: + await notFound(req) + + http.listen(config.apiPort) + echo &"API listening on port {config.apiPort.int}" + + while true: + if http.shouldAcceptRequest(): + try: + await http.acceptRequest(cb) + except: + continue + else: + await sleepAsync(500) \ No newline at end of file diff --git a/src/dnsserver.nim b/src/dnsserver.nim new file mode 100644 index 0000000..5d59043 --- /dev/null +++ b/src/dnsserver.nim @@ -0,0 +1,35 @@ +import asyncnet, asyncdispatch, nativesockets +import strutils, options, tables, strformat +import ../lib/dns, state + +proc handleDnsRequest(records: RecordsTable, data: string): Option[string] = + let msg = parseMessage(data) + + if len(msg.questions) == 0: + return + + let question = msg.questions[0] + let response = mkResponse( + msg.header.id, + question, + records.getOrDefault((name: question.qname, dtype: question.qtype), @[]) + ) + + return some(packMessage(response)) + +proc serveDns*(config: AppConfig) {.async.} = + let dns = newAsyncSocket(sockType=SockType.SOCK_DGRAM, protocol=Protocol.IPPROTO_UDP, buffered = false) + dns.setSockOpt(OptReuseAddr, true) + dns.bindAddr(config.dnsPort) + + echo &"DNS listening on port {config.dnsPort.int}" + + while true: + try: + let request = await dns.recvFrom(size=512) + let response = handleDnsRequest(records, request.data) + + if (response.isSome): + await dns.sendTo(request.address, request.port, response.unsafeGet) + except: + continue \ No newline at end of file diff --git a/src/state.nim b/src/state.nim new file mode 100644 index 0000000..3b5e467 --- /dev/null +++ b/src/state.nim @@ -0,0 +1,42 @@ +import tables, strtabs, sequtils, nativesockets +import ../lib/dns + +type + RecordKey* = tuple + name: string + dtype: DnsType + +type + RecordsTable* = Table[RecordKey, seq[string]] + +type + AppConfig* = object + users*: StringTableRef + base*: string + apiPort*: Port + dnsPort*: Port + +func trimName*(name: string): string = + if name[^1] == '.': + return name[0 .. ^2] + else: + return name + +proc addRecord*(records: var RecordsTable, key: RecordKey, record: string) = + if records.hasKey(key): + records[key].add(record) + else: + records[key] = @[record] + +proc delRecord*(records: var RecordsTable, key: RecordKey, record: string) = + if not records.hasKey(key): + return + + records[key].keepItIf(it != record) + + if len(records[key]) == 0: + records.del(key) + +# TODO: don't use a global for this +var records* {.threadvar.}: RecordsTable +records = initTable[RecordKey, seq[string]]() \ No newline at end of file