Compare commits
2 Commits
Author | SHA1 | Date |
---|---|---|
Martin | 8b280c3971 | |
Martin | 559b1857b9 |
|
@ -1,3 +1,2 @@
|
|||
norbert
|
||||
.idea
|
||||
result
|
||||
|
|
7
LICENSE
7
LICENSE
|
@ -1,7 +0,0 @@
|
|||
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
141
README.md
|
@ -1,141 +0,0 @@
|
|||
# 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
|
|
@ -1,5 +1,7 @@
|
|||
# base domain for all records
|
||||
baseDomain = "acme.example.com"
|
||||
# the domain this dns server can be reached at
|
||||
serverDomain = "dns.example.com"
|
||||
dnsPort = 15353
|
||||
apiPort = 18000
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
#!/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\"}"
|
|
@ -1,10 +0,0 @@
|
|||
#!/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
26
flake.lock
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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
96
flake.nix
|
@ -1,96 +0,0 @@
|
|||
{
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
30
lib/dns.nim
30
lib/dns.nim
|
@ -61,7 +61,7 @@ type
|
|||
header*: DnsHeader
|
||||
questions*: seq[DnsQuestion]
|
||||
answer*: seq[DnsRecord]
|
||||
authroity*: seq[DnsRecord]
|
||||
authority*: seq[DnsRecord]
|
||||
additional*: seq[DnsRecord]
|
||||
|
||||
func parseNameField*(data: string, startOffset: uint16): (seq[string], uint16) =
|
||||
|
@ -142,15 +142,6 @@ func parseQuestion*(data: string, startOffset: uint16): (DnsQuestion, uint16) =
|
|||
qclass: DnsClass(toUint16(data[offset + 3], data[offset + 2]))
|
||||
), 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
|
||||
func parseResourceRecord*(data: string, startOffset: uint16): (DnsRecord, uint16) =
|
||||
let (names, offset) = parseNameField(data, startOffset)
|
||||
|
@ -167,13 +158,14 @@ func parseResourceRecord*(data: string, startOffset: uint16): (DnsRecord, uint16
|
|||
|
||||
func packResourceRecord*(data: DnsRecord): string =
|
||||
var record = ""
|
||||
let body = (if data.rtype == DnsType.NS: packNameField(data.rdata) else: data.rdata)
|
||||
|
||||
record.add(packNameField(data.name))
|
||||
record.add(uint16ToString(data.rtype.uint16))
|
||||
record.add(uint16ToString(data.class.uint16))
|
||||
record.add(uint32ToString(data.ttl.uint32))
|
||||
record.add(uint16ToString(data.rdlength.uint16))
|
||||
record.add(data.rdata)
|
||||
record.add(uint16ToString(body.len.uint16))
|
||||
record.add(body)
|
||||
|
||||
return record
|
||||
|
||||
|
@ -192,12 +184,12 @@ func parseMessage*(data: string): DnsMessage =
|
|||
func packMessage*(message: DnsMessage): string =
|
||||
var encoded = packHeader(message.header)
|
||||
|
||||
for question in message.questions:
|
||||
encoded.add(packQuestion(question))
|
||||
|
||||
for answer in message.answer:
|
||||
encoded.add(packResourceRecord(answer))
|
||||
|
||||
for authroity in message.authority:
|
||||
encoded.add(packResourceRecord(authroity))
|
||||
|
||||
return encoded
|
||||
|
||||
func mkRecord*(rtype: DnsType, question: string, answer: string): DnsRecord =
|
||||
|
@ -210,16 +202,16 @@ func mkRecord*(rtype: DnsType, question: string, answer: string): DnsRecord =
|
|||
rdata: (if rtype == DnsType.TXT: chr(len(answer)) & answer else: answer)
|
||||
)
|
||||
|
||||
func mkResponse*(id: uint16, question: DnsQuestion, answer: seq[string]): DnsMessage =
|
||||
func mkResponse*(id: uint16, question: DnsQuestion, answer: seq[string], authority: string, base: string): DnsMessage =
|
||||
return DnsMessage(
|
||||
header: DnsHeader(
|
||||
id: id,
|
||||
qr: DnsQr.RESPONSE,
|
||||
aa: true,
|
||||
rcode: Rcode.NO_ERROR,
|
||||
qdcount: 1,
|
||||
ancount: len(answer).uint16
|
||||
ancount: len(answer).uint16,
|
||||
nscount: 1
|
||||
),
|
||||
questions: @[question],
|
||||
authority: @[mkRecord(DnsType.NS, base, authority)],
|
||||
answer: answer.map(proc (a: string): DnsRecord = mkRecord(question.qtype, question.qname, a))
|
||||
)
|
|
@ -22,8 +22,15 @@ proc initConfig(): AppConfig =
|
|||
echo "Error parsing port config"
|
||||
quit 1
|
||||
|
||||
let serverName = configFile.getSectionValue("", "serverDomain")
|
||||
|
||||
if serverName == "":
|
||||
echo "Missing serverDomain"
|
||||
quit 1
|
||||
|
||||
let config = AppConfig(
|
||||
base: configFile.getSectionValue("", "baseDomain"),
|
||||
serverName: serverName,
|
||||
users: newStringTable(),
|
||||
apiPort: Port(apiPort),
|
||||
dnsPort: Port(dnsPort)
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# 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"
|
|
@ -2,7 +2,7 @@ import asyncnet, asyncdispatch, nativesockets
|
|||
import strutils, options, tables, strformat
|
||||
import ../lib/dns, state
|
||||
|
||||
proc handleDnsRequest(records: RecordsTable, data: string): Option[string] =
|
||||
proc handleDnsRequest(records: RecordsTable, data: string, config: AppConfig): Option[string] =
|
||||
let msg = parseMessage(data)
|
||||
|
||||
echo msg
|
||||
|
@ -14,7 +14,9 @@ proc handleDnsRequest(records: RecordsTable, data: string): Option[string] =
|
|||
let response = mkResponse(
|
||||
msg.header.id,
|
||||
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
|
||||
|
@ -31,7 +33,7 @@ proc serveDns*(config: AppConfig) {.async.} =
|
|||
while true:
|
||||
try:
|
||||
let request = await dns.recvFrom(size=512)
|
||||
let response = handleDnsRequest(records, request.data)
|
||||
let response = handleDnsRequest(records, request.data, config)
|
||||
|
||||
if (response.isSome):
|
||||
await dns.sendTo(request.address, request.port, response.unsafeGet)
|
||||
|
|
|
@ -13,6 +13,7 @@ type
|
|||
AppConfig* = object
|
||||
users*: StringTableRef
|
||||
base*: string
|
||||
serverName*: string
|
||||
apiPort*: Port
|
||||
dnsPort*: Port
|
||||
|
||||
|
|
Loading…
Reference in New Issue