Quickstart🔗
Installation🔗
Install via pip🔗
Minimal Server🔗
Preparing our server🔗
from nserver import NameServer, Query, A
server = NameServer("example")
@server.rule("example.com", ["A"])
def example_a_records(query: Query):
return A(query.name, "1.2.3.4")
if __name__ == "__main__":
server.run()
Here's what this code does:
-
To start we import:
NameServer
- an instance of this class will contain our applicationQuery
- instances of this class will be passed to our rule functions so that we can inspect the incoming DNS queryA
- the class used to create DNSA
records
-
Next we create a
NameServer
instance for our application to use. The name we give the server will be used to help distinguish it from others that are also running. -
We then use the
rule
decorator to tell our server when to trigger our function. In this case we will trigger forA
queries that exactly match the nameexample.com
. -
When triggered our function will then return a single
A
record as a response. -
Finally we add code so that we can run our server.
Running our server🔗
With our server written we can now run it:
We can access it using dig
.
; <<>> DiG 9.18.12-0ubuntu0.22.04.3-Ubuntu <<>> -p 9953 @localhost A example.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 20379
;; flags: qr aa rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 300 IN A 1.2.3.4
;; Query time: 324 msec
;; SERVER: 127.0.0.1#9953(localhost) (UDP)
;; WHEN: Thu Nov 02 21:27:12 AEDT 2023
;; MSG SIZE rcvd: 45
Rules🔗
Rules tell our server which queries to send to which functions. NServer ships with a number of rule types.
StaticRule
matches on an exact string.ZoneRule
matches the given domain and all subdomains.WildcardStringRule
which allows writing rules using a shorthand syntax.RegexRule
which uses regular expressions for matching.
The NameServer.rule
decorator uses smart_make_rule
to automatically select the "best" matching rule type based on the input. This will result in string (str
) rules will be used to create either a WildcardStringRule
or a StaticRule
, whilst regular expression (typing.Pattern
) rules will create a RegexRule
. This decorator also return the original function unchanged meaning it is possible to decorate the same function with multiple rules.
@saerver.rule("google-dns", ["A"])
def this_will_be_a_static_rule(query):
return A(query.name, "8.8.8.8")
@server.rule("{base_name}", ["A"])
@server.rule("www.{base_name}", ["A"])
@server.rule("mail.{base_name}", ["A"])
def we_only_have_three_servers_for_everything(query):
return list(A(query.name, f"1.1.1.{i+1}") for i in range(3))
Rules can also be added to a server by calling the register_rule
method with an exiting rule.
from nserver import RegexRule
server.register_rule(
RegexRule(
re.compile(r"[0-9a-f]{1-4}\.com"),
["A"],
lambda q: return A(q.name, "1.2.3.4"),
)
)
By default all rules match in a case-insensitive manner. This is the expected behaviour for name servers operating on the internet. You can override this by setting case_sensitive=True
in the constructors or rule
decorator.
The WildcardStringRule
🔗
The WildcardStringRule
allows using a shorthand notation for matching DNS names.
*
will match a single label in the query domain**
will match one or more labels in the query domain (in a greedy manner){base_name}
will match the "base" of the query name using the Public Suffix List. In general this means the "registered" domain for public TLDs or the last label for non-TLDs (e.g..local
,.internal
).
For example:
*.example.com.au
will match all first level subdomains ofexample.com.au
, but will not matchexample.com.au
orfoo.bar.example.com.au
.**.example.com.au
will match all subdomains ofexample.com.au
but will not matchexample.com.au
.www.{base_name}
will matchwww
on all registered and internal domains (www.example.com.au
,www.au
) but will not match on other subdomains, or as a registered name (www.com.au
,www.foo.au
)
Responses🔗
Rule functions are expected to return only the following types:
None
- A single record instance (of any type)
- A list of record instances (of any record type, including mixed)
- A
Response
instance
When records are returned, these will automatically be added to a Response
instance as answer records. For simple responses this is usually enough. When None
is returned it will be converted to an empty response.
However if you wish to return Additional or Authority Records, or change the Error Code you will need to return a Response
instance.
For example a typical NS
lookup when our application is the authoritive server for the domain may look like this:
# ... server setup exlcuded
from nserver import Response, NS, A, SOA
MY_SERVERS = {
"ns1.example.com": "1.2.3.4",
"ns2.example.com": "1.2.3.5",
"ns-backup.example.com": "9.8.7.6",
}
@server.rule("example.com", ["NS"])
def name_servers(query: Query) -> Response:
response = Response()
for ns, ip in MY_SERVERS.items():
response.answers.append(NS(query.name, ns))
response.additional.append(A(ns, ip))
response.authority.append(SOA(
"example.com",
list(MY_SERVERS.keys())[0],
"admin.example.com",
1,
))
return response