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
 | 
					.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
 | 
					  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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										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