Add HTTP api
This commit is contained in:
		
							parent
							
								
									29f039872a
								
							
						
					
					
						commit
						7918aa5fd2
					
				
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,2 +1,2 @@
 | 
			
		||||
server
 | 
			
		||||
norbert
 | 
			
		||||
.idea
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							@ -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"]
 | 
			
		||||
							
								
								
									
										8
									
								
								example.config
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								example.config
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										59
									
								
								norbert.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								norbert.nim
									
									
									
									
									
										Normal file
									
								
							@ -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()
 | 
			
		||||
							
								
								
									
										94
									
								
								src/apiserver.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/apiserver.nim
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
			
		||||
							
								
								
									
										35
									
								
								src/dnsserver.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/dnsserver.nim
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
							
								
								
									
										42
									
								
								src/state.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/state.nim
									
									
									
									
									
										Normal file
									
								
							@ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user