Compare commits

..

5 Commits

Author SHA1 Message Date
Martin a60baaf13c
Update flake 2024-07-13 19:45:33 +01:00
Martin 23c1c7ef8e
Add option 2022-10-10 23:18:34 +02:00
Martin 7d23fc01f0
Flakeify 2022-10-06 23:58:33 +02:00
Martin 305161e6be
Add readme 2022-02-13 19:00:03 +01:00
Martin 38cdb839d1
Include question in response 2022-02-13 11:31:07 +01:00
13 changed files with 327 additions and 27 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
norbert norbert
.idea .idea
result

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2022 Martin Wagner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

141
README.md Normal file
View File

@ -0,0 +1,141 @@
# Norbert - DNS Server for ACMEv2 challenges
Norbert is a DNS server designed for use with the `DNS-01` ACME challenge and written in dependency-free [nim](https://nim-lang.org).
Norbert provides a HTTP API for managing TXT records and can handle multiple users, both useful if your primary DNS provider has no API or if you don't want to give full DNS API access to a webserver.
## Background
The ACME `DNS-01` challenge allows clients to verify the ownership of a domain by creating a TXT record with predefined content.
Using it instead of other challenges like `HTTP-01` is useful if the host that should use the certificate isn't public reachable or if you want to acquire a wildcard certificate.
Since most certificates issued using the ACME protocol are short-lived it is necessary to automate the renewal process including the creation of the required TXT record.
While most DNS providers offer an API and there exist many plugins for ACME clients like certbot most providers don't allow the creation of API credentials that are scoped to a limited set of records.
As a result, a webserver that has API access the DNS provider to complete the `DNS-01` challenge has full control over the domain in question.
Tools like Norbert, [acme-dns-server](https://github.com/pawitp/acme-dns-server) or [acme-dns](https://github.com/joohoi/acme-dns) solve this problem by running a dedicated DNS server responsible for a subset of the DNS zone that can be used to complete the ACME challenge.
A `CNAME` record is required to tell the ACME server that a subdomain managed by this server is the one holding the validation keys.
### Design
Norbert needs to bind to port `53` on a publicly accessible host and requires a dedicated subdomain like `*.acme.example.com`.
Every client configured in Norbert can create records in the `*.CLIENT-NAME.acme.exmple.com` namespace.
For this to work, two static DNS entries have to be created in the zone of the used domain:
```dns
acme.example.com NS host-running-norbert.example.com
_acme-challenge.exmaple.com CNAME example.com.CLIENT-NAME.acme.example.com
```
Now the client can use the Norbert API to set the verification TXT record on `example.com.CLIENT-NAME.acme.example.com` and the ACME server will read this record when querying `_acme-challenge.example.com`.
## Usage
### Installation
Norbert is written in nim.
A Dockerfile to build a container containing only a single static binary is included with the code.
Sample docker-compose config:
```yaml
version: "3"
services:
norbert:
build: ./norbert
volumes:
- ./norbert.conf:/config
ports:
- "53:15353/udp"
- "18000:18000"
command: "/config"
```
If you aren't using docker, the binary can be compiled manually using a recent version of the nim compiler:
```shell
nim c -d:release norbert.nim
```
Since Norbert needs to bind to the privileged port `53` the following option might be useful when running user systemd:
```
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
```
### DNS Setup
You'll need to create at least the two records described in [Design](#Design):
A `NS` record to specify your host running Norbert as the nameserver for the subdomain used, and a `CNAME` record to tell Let's Encrypt where to find your validation TXT records.
Norbert can be used for multiple domains, so multiple `CNAME` records for different domains can exist.
### Configuration
Norbert expects the path to a simple configuration file as its only argument.
```ini
# base domain for all records
baseDomain = "acme.example.com"
dnsPort = 15353
apiPort = 18000
# list of clients
# can create records for *.exampleuser.acme.example.com
[exampleuser]
password = "changeme"
[exampleuser2]
password = "changmetoo"
```
Only the base domain for which Norbert should resolve names and at least one client section are required.
If no port is specified, Norbert will fall back to `15353` & `18000`.
### HTTP API
Norbert's HTTP API provides only two routes and is designed with the [legos `HTTPREQ` DNS plugin](https://go-acme.github.io/lego/dns/httpreq/) in mind:
**Set a record**
```http request
POST /present
{
"fqdn": "example.com",
"value": "txt record content"
}
```
**Remove a record**
```http request
POST /cleanup
{
"fqdn": "example.com",
"value": "txt record content"
}
```
Records are only stored in memory and don't persist across restarts.
Both endpoints require HTTP basic auth using one of the credentials specified in the config file.
The `CLIENT-NAME.acme.example.com` suffix is automatically added and does not need to be specified in the `fqdn` field.
### Certbot example
Certbot provides the `--manual-auth-hook` and `--manual-cleanup-hook` options to specify custom scripts for challenges.
The `examples/` directory contains sample scripts that can be used here.
```shell
certbot certonly --manual \
--manual-auth-hook norbert/examples/certbot-norbert-auth.sh \
--manual-cleanup-hook norbert/examples/certbot-norbert-cleanup.sh \
-d "example.com,*.example.com" \
--preferred-challenges=dns \
--renew-hook "systemctl reload nginx"
```
# License
MIT

View File

@ -1,7 +1,5 @@
# base domain for all records # base domain for all records
baseDomain = "acme.example.com" baseDomain = "acme.example.com"
# the domain this dns server can be reached at
serverDomain = "dns.example.com"
dnsPort = 15353 dnsPort = 15353
apiPort = 18000 apiPort = 18000

View File

@ -0,0 +1,10 @@
#!/bin/sh
API_USER=""
API_KEY=""
curl -s --request POST \
--url http://10.200.100.10:18000/present \
--header 'Content-Type: application/json' \
--user "$API_USER:$API_KEY" \
--data "{\"fqdn\": \"$CERTBOT_DOMAIN\", \"value\": \"$CERTBOT_VALIDATION\"}"

View File

@ -0,0 +1,10 @@
#!/bin/sh
API_USER=""
API_KEY=""
curl -s --request POST \
--url http://10.200.100.10:18000/cleanup \
--header 'Content-Type: application/json' \
--user "$API_USER:$API_KEY" \
--data "{\"fqdn\": \"$CERTBOT_DOMAIN\", \"value\": \"$CERTBOT_VALIDATION\"}"

26
flake.lock Normal file
View File

@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1720896038,
"narHash": "sha256-4wHyQxCN7H2K00k90jOz/pFjd+3/pvC4Ueg5c2gOno4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "eec0d0b42f3f34a35b918d4c523b20477a04962b",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

96
flake.nix Normal file
View File

@ -0,0 +1,96 @@
{
description = "A DNS server for the ACME DNS-01 challenge written in dependency-free nim";
inputs.nixpkgs.url = github:NixOS/nixpkgs;
outputs = { self, nixpkgs }:
let
# System types to support.
supportedSystems = [ "x86_64-linux" "aarch64-darwin" ];
# Helper function to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
# Nixpkgs instantiated for supported system types.
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in
{
packages = forAllSystems(system:
let
pkgs = nixpkgsFor.${system};
in
{
default = pkgs.buildNimPackage {
name = "norbert";
src = self;
};
}
);
nixosModules.default = { config, lib, pkgs, ... }:
with lib;
let cfg = config.mawalu.services.norbert;
in
{
options.mawalu.services.norbert = {
enable = mkEnableOption "Enable the norbert DNS server";
config = {
baseDomain = mkOption {
type = types.str;
description = "Base domain.";
};
dnsPort = mkOption {
type = types.port;
description = "DNS server port";
default = 15353;
};
apiPort = mkOption {
type = types.port;
description = "API port";
default = 18000;
};
};
users = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
password = mkOption {
type = types.str;
default = null;
description = "API password for the user";
};
};
});
example = literalExpression ''
{
"exampleuser" = {
password = "insecure";
};
}
'';
};
};
config = mkIf cfg.enable {
systemd.services.norbert = {
wantedBy = [ "multi-user.target" ];
serviceConfig = let pkg = self.packages.${pkgs.system}.default;
in {
Restart = "on-failure";
ExecStart = "${pkg}/bin/norbert ${pkgs.writeText "config" (generators.toINIWithGlobalSection {} {
globalSection = cfg.config;
sections = cfg.users;
})}";
DynamicUser = "yes";
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
};
};
};
};
};
}

View File

@ -61,7 +61,7 @@ type
header*: DnsHeader header*: DnsHeader
questions*: seq[DnsQuestion] questions*: seq[DnsQuestion]
answer*: seq[DnsRecord] answer*: seq[DnsRecord]
authority*: seq[DnsRecord] authroity*: seq[DnsRecord]
additional*: seq[DnsRecord] additional*: seq[DnsRecord]
func parseNameField*(data: string, startOffset: uint16): (seq[string], uint16) = func parseNameField*(data: string, startOffset: uint16): (seq[string], uint16) =
@ -142,6 +142,15 @@ func parseQuestion*(data: string, startOffset: uint16): (DnsQuestion, uint16) =
qclass: DnsClass(toUint16(data[offset + 3], data[offset + 2])) qclass: DnsClass(toUint16(data[offset + 3], data[offset + 2]))
), offset + 4) ), offset + 4)
func packQuestion*(data: DnsQuestion): string =
var question = ""
question.add(packNameField(data.qname))
question.add(uint16ToString(data.qtype.uint16))
question.add(uint16ToString(data.qclass.uint16))
return question
# BROKEN # BROKEN
func parseResourceRecord*(data: string, startOffset: uint16): (DnsRecord, uint16) = func parseResourceRecord*(data: string, startOffset: uint16): (DnsRecord, uint16) =
let (names, offset) = parseNameField(data, startOffset) let (names, offset) = parseNameField(data, startOffset)
@ -158,14 +167,13 @@ func parseResourceRecord*(data: string, startOffset: uint16): (DnsRecord, uint16
func packResourceRecord*(data: DnsRecord): string = func packResourceRecord*(data: DnsRecord): string =
var record = "" var record = ""
let body = (if data.rtype == DnsType.NS: packNameField(data.rdata) else: data.rdata)
record.add(packNameField(data.name)) record.add(packNameField(data.name))
record.add(uint16ToString(data.rtype.uint16)) record.add(uint16ToString(data.rtype.uint16))
record.add(uint16ToString(data.class.uint16)) record.add(uint16ToString(data.class.uint16))
record.add(uint32ToString(data.ttl.uint32)) record.add(uint32ToString(data.ttl.uint32))
record.add(uint16ToString(body.len.uint16)) record.add(uint16ToString(data.rdlength.uint16))
record.add(body) record.add(data.rdata)
return record return record
@ -184,12 +192,12 @@ func parseMessage*(data: string): DnsMessage =
func packMessage*(message: DnsMessage): string = func packMessage*(message: DnsMessage): string =
var encoded = packHeader(message.header) var encoded = packHeader(message.header)
for question in message.questions:
encoded.add(packQuestion(question))
for answer in message.answer: for answer in message.answer:
encoded.add(packResourceRecord(answer)) encoded.add(packResourceRecord(answer))
for authroity in message.authority:
encoded.add(packResourceRecord(authroity))
return encoded return encoded
func mkRecord*(rtype: DnsType, question: string, answer: string): DnsRecord = func mkRecord*(rtype: DnsType, question: string, answer: string): DnsRecord =
@ -202,16 +210,16 @@ func mkRecord*(rtype: DnsType, question: string, answer: string): DnsRecord =
rdata: (if rtype == DnsType.TXT: chr(len(answer)) & answer else: answer) rdata: (if rtype == DnsType.TXT: chr(len(answer)) & answer else: answer)
) )
func mkResponse*(id: uint16, question: DnsQuestion, answer: seq[string], authority: string, base: string): DnsMessage = func mkResponse*(id: uint16, question: DnsQuestion, answer: seq[string]): DnsMessage =
return DnsMessage( return DnsMessage(
header: DnsHeader( header: DnsHeader(
id: id, id: id,
qr: DnsQr.RESPONSE, qr: DnsQr.RESPONSE,
aa: true, aa: true,
rcode: Rcode.NO_ERROR, rcode: Rcode.NO_ERROR,
ancount: len(answer).uint16, qdcount: 1,
nscount: 1 ancount: len(answer).uint16
), ),
authority: @[mkRecord(DnsType.NS, base, authority)], questions: @[question],
answer: answer.map(proc (a: string): DnsRecord = mkRecord(question.qtype, question.qname, a)) answer: answer.map(proc (a: string): DnsRecord = mkRecord(question.qtype, question.qname, a))
) )

View File

@ -22,15 +22,8 @@ proc initConfig(): AppConfig =
echo "Error parsing port config" echo "Error parsing port config"
quit 1 quit 1
let serverName = configFile.getSectionValue("", "serverDomain")
if serverName == "":
echo "Missing serverDomain"
quit 1
let config = AppConfig( let config = AppConfig(
base: configFile.getSectionValue("", "baseDomain"), base: configFile.getSectionValue("", "baseDomain"),
serverName: serverName,
users: newStringTable(), users: newStringTable(),
apiPort: Port(apiPort), apiPort: Port(apiPort),
dnsPort: Port(dnsPort) dnsPort: Port(dnsPort)
@ -63,4 +56,4 @@ proc main() =
runForever() runForever()
main() main()

13
norbert.nimble Normal file
View File

@ -0,0 +1,13 @@
# Package
version = "1.0.0"
author = "mawalu"
description = "A DNS server for the ACME DNS-01 challenge"
license = "MIT"
srcDir = "."
bin = @["norbert"]
# Dependencies
requires "nim >= 1.6.0"

View File

@ -2,7 +2,7 @@ import asyncnet, asyncdispatch, nativesockets
import strutils, options, tables, strformat import strutils, options, tables, strformat
import ../lib/dns, state import ../lib/dns, state
proc handleDnsRequest(records: RecordsTable, data: string, config: AppConfig): Option[string] = proc handleDnsRequest(records: RecordsTable, data: string): Option[string] =
let msg = parseMessage(data) let msg = parseMessage(data)
echo msg echo msg
@ -14,9 +14,7 @@ proc handleDnsRequest(records: RecordsTable, data: string, config: AppConfig): O
let response = mkResponse( let response = mkResponse(
msg.header.id, msg.header.id,
question, question,
records.getOrDefault((name: question.qname.toLowerAscii(), dtype: question.qtype), @[]), records.getOrDefault((name: question.qname.toLowerAscii(), dtype: question.qtype), @[])
config.serverName,
config.base
) )
echo response echo response
@ -33,7 +31,7 @@ proc serveDns*(config: AppConfig) {.async.} =
while true: while true:
try: try:
let request = await dns.recvFrom(size=512) let request = await dns.recvFrom(size=512)
let response = handleDnsRequest(records, request.data, config) let response = handleDnsRequest(records, request.data)
if (response.isSome): if (response.isSome):
await dns.sendTo(request.address, request.port, response.unsafeGet) await dns.sendTo(request.address, request.port, response.unsafeGet)

View File

@ -13,7 +13,6 @@ type
AppConfig* = object AppConfig* = object
users*: StringTableRef users*: StringTableRef
base*: string base*: string
serverName*: string
apiPort*: Port apiPort*: Port
dnsPort*: Port dnsPort*: Port