Add HTTP api
This commit is contained in:
parent
29f039872a
commit
7918aa5fd2
|
@ -1,2 +1,2 @@
|
||||||
server
|
norbert
|
||||||
.idea
|
.idea
|
||||||
|
|
|
@ -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"]
|
|
@ -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"
|
|
@ -15,7 +15,7 @@ type
|
||||||
DnsType* = enum
|
DnsType* = enum
|
||||||
A = 1, NS = 2, MD = 3, MF =4, CNAME = 5, SOA = 6, MB = 7, MG = 8,
|
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,
|
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
|
type
|
||||||
DnsClass* = enum
|
DnsClass* = enum
|
||||||
|
@ -85,8 +85,7 @@ func packNameField*(input: string): string =
|
||||||
finalName.add(chr(len(name)))
|
finalName.add(chr(len(name)))
|
||||||
finalName = finalName & name
|
finalName = finalName & name
|
||||||
|
|
||||||
if len(finalName) mod 2 != 0:
|
finalName.add(chr(0))
|
||||||
finalName.add(chr(0))
|
|
||||||
|
|
||||||
return finalName
|
return finalName
|
||||||
|
|
||||||
|
|
|
@ -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()
|
42
server.nim
42
server.nim
|
@ -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()
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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]]()
|
Loading…
Reference in New Issue