A multi-protocol WHOIS daemon: RFC 3912 on TCP 43, a 60-suffix query dispatcher fanning out to ~46 upstream service integrations, DN42 registry support, and a web dashboard with a JSON API — in one .NET 10 process.
Note — this is the C#/.NET rewrite of the original Rust implementation. The SSH server and the Lua plugin system are not carried over; see Differences from the Rust implementation.
- whois-server
| WHOIS server | RFC 3912, TCP 43. Domains, IPv4/IPv6, CIDR, ASNs — resolved via IANA referral by default, RDAP optionally. |
| Suffix dispatcher | example.com-DNS, AS213605-PEERINGDB, rust-CARGO, … 60 built-in suffixes across five categories in a default build. |
| DN42 | Git-mirrored private-internet registry, with automatic routing of DN42 IP/ASN ranges. |
| Web dashboard + API | Kestrel on port 9999: query UI, live stats, REST/JSON endpoints, OpenAPI document. |
| Response patches | SHA1-verified, remotely updatable DSL that rewrites specific lines of outgoing WHOIS text. |
| Colour protocol | Opt-in ANSI colourisation negotiated with an X-WHOIS-COLOR: header — an extension to raw WHOIS, invisible to standard clients. |
| Native plugins | Third-party query suffixes as ordinary .NET assemblies, loaded into an isolated AssemblyLoadContext with a manifest-declared permission set. |
| Cut builds | 44 MSBuild feature flags that genuinely remove code and dependencies, not just disable them at runtime. |
Requires the .NET 10 SDK. git must be on
PATH if DN42 is enabled.
git clone https://github.com/Akaere-NetWorks/whois-server.git
cd whois-server
# Port 4343 rather than 43 so it needs no root
make run # or: dotnet run --project src/WhoisServer.Host -- --port 4343Then query it:
whois -h 127.0.0.1 -p 4343 example.com
whois -h 127.0.0.1 -p 4343 AS213605-PEERINGDB
whois -h 127.0.0.1 -p 4343 HELP # authoritative suffix list for *your* build…and open http://127.0.0.1:9999 for the dashboard.
HELP is worth repeating: because suffixes are registered by whichever features were compiled in,
the running server's own help output is always the accurate list for that binary, where this README
can only describe a default build.
Tagged releases ship .deb and .rpm packages for amd64/x86_64 and arm64/aarch64
alongside the tarballs. The .NET runtime is bundled, so there is no dotnet dependency and no
third-party repository to add first:
# Debian / Ubuntu
curl -fLO https://github.com/Akaere-NetWorks/whois-server/releases/latest/download/whois-server_1.0.0_amd64.deb
sudo apt install ./whois-server_1.0.0_amd64.deb
# Fedora / RHEL / openSUSE
curl -fLO https://github.com/Akaere-NetWorks/whois-server/releases/latest/download/whois-server-1.0.0-1.x86_64.rpm
sudo dnf install ./whois-server-1.0.0-1.x86_64.rpmInstalling enables and starts whois-server.service, which listens on WHOIS port 43 and dashboard
port 9999 straight away. Verify with:
systemctl status whois-server
whois -h 127.0.0.1 HELPBoth formats install the same layout and share one systemd unit; only the config file path follows each family's convention.
| Path | Contents |
|---|---|
/usr/lib/whois-server/ |
Binary, bundled runtime, wwwroot/ |
/usr/bin/whois-server |
Symlink, for running it by hand |
/usr/lib/systemd/system/whois-server.service |
The unit |
/etc/default/whois-server (deb) |
Config file: CLI flags and API tokens |
/etc/sysconfig/whois-server (rpm) |
Same, at the RPM-conventional path |
/var/lib/whois-server/cache/ |
Every SQLite store and the DN42 clone |
/var/lib/whois-server/plugins/ |
Drop plugins here |
/usr/share/whois-server/plugins/weather/ |
The example plugin, to copy from |
The daemon runs as the unprivileged whois-server system account and binds port 43 through
AmbientCapabilities=CAP_NET_BIND_SERVICE rather than by being root. State lives under
/var/lib/whois-server, which is the unit's working directory, so the relative paths described in
State on disk resolve there.
Edit /etc/default/whois-server (or /etc/sysconfig/whois-server on RPM systems) — it is read as a
systemd EnvironmentFile, so it takes plain KEY=value lines and no shell syntax. CLI flags go in
WHOIS_SERVER_ARGS, and every environment variable from
Environment variables works here too:
WHOIS_SERVER_ARGS=--max-connections 200 --debug
IPINFO_TOKEN=...sudo systemctl restart whois-serverTo run entirely unprivileged, set --port 4343 there and drop the two capability lines with
systemctl edit whois-server.
On removal the two formats differ, as their conventions do. apt remove leaves
/var/lib/whois-server intact and apt purge deletes it along with the service account. dnf remove is always the non-destructive one: state and the account stay behind for you to clean up.
make deb # dist/whois-server_<version>_amd64.deb
make rpm # dist/whois-server-<version>-1.x86_64.rpm
make packages RID=linux-arm64 VERSION=1.4.0 # both, cross-built without QEMUThese wrap packaging/deb/build-deb.sh and packaging/rpm/build-rpm.sh, which take the same
--rid, --version, --output, --publish-dir, --no-plugin, and --features options — the
last for cut builds:
packaging/rpm/build-rpm.sh --features '-p:Feature_Dn42=false' # also drops the git dependencyRequirements are the .NET 10 SDK plus dpkg-deb (apt install dpkg-dev) or rpmbuild
(dnf install rpm-build, or apt install rpm). Neither package is built from source under the
packaging tool: both repack one dotnet publish tree through the same
packaging/common/stage-tree.sh, which is what keeps the two layouts identical and lets an amd64
runner emit arm64 packages.
CI exercises both on every push — the .deb through a full install, WHOIS query, dashboard fetch
and purge cycle under real systemd, and the .rpm by installing it on Fedora to prove the
hand-written dependency list resolves and the bundled runtime runs on a non-Debian base.
Images are published to GHCR for linux/amd64 and linux/arm64:
docker run -d --name whois-server \
-p 43:43 -p 9999:9999 \
-v whois-cache:/app/cache \
ghcr.io/akaere-networks/whois-server:latestConfiguration is by environment variable and CLI flag — flags go after the image name, since they are passed straight to the entrypoint:
docker run -d --name whois-server \
-p 43:43 -p 9999:9999 \
-v whois-cache:/app/cache \
-v "$PWD/plugins:/app/plugins" \
--env-file .env \
ghcr.io/akaere-networks/whois-server:latest \
--debug --max-connections 200Notes:
/app/cacheshould be a volume. Every SQLite store lives there (stats, patches, the DN42 clone, referral caches). Without it, each restart re-clones the DN42 registry and discards stats.- The container runs as root because binding port 43 is privileged. To run rootless, listen high
inside the container and remap on the host:
docker run -d -u 1654:1654 -p 43:4343 -p 9999:9999 -v whois-cache:/app/cache \ ghcr.io/akaere-networks/whois-server:latest --port 4343
- Building with feature flags is supported through a build argument:
Disabling DN42 also makes the image's
docker build --build-arg FEATURES="-p:Feature_Dn42=false -p:Feature_Pixiv=false" -t whois-server .
gitdependency redundant.
| Flag | Default | Meaning |
|---|---|---|
-H, --host <HOST> |
0.0.0.0 |
Listen address, for both the WHOIS and web listeners |
-p, --port <PORT> |
43 |
WHOIS TCP port |
--web-port <PORT> |
9999 |
Dashboard / REST API port |
-d, --debug |
off | Debug-level logging |
-t, --trace |
off | Trace-level logging (extremely verbose) |
--max-connections <N> |
100 |
Concurrent WHOIS connection ceiling |
--timeout <SECONDS> |
10 |
Per-connection timeout |
--dump-traffic |
off | Write raw queries and responses to disk |
--dump-dir <DIR> |
dumps |
Where --dump-traffic writes |
--enable-color |
true |
Honour the X-WHOIS-COLOR: negotiation header |
-h, --help |
Print usage |
All are optional. Each unlocks one integration; when unset, that integration degrades (usually to an explanatory WHOIS response) rather than failing startup.
| Variable | Used by |
|---|---|
IPINFO_TOKEN |
-GEO aggregated geolocation (ipinfo.io) |
IPINFO_API_TOKEN |
Hop geolocation in -PING / -TRACEROUTE |
STEAM_API_KEY |
-STEAM user profiles (app lookups work without it) |
OMDB_API_KEY |
-IMDB / -IMDBSEARCH |
CURSEFORGE_API_KEY |
-CURSEFORGE |
PIXIV_REFRESH_TOKEN |
-PIXIV and the image reverse-proxy |
TELEMETRY_ENABLED |
Set to true to enable query telemetry export |
TELEMETRY_ENDPOINT |
Telemetry collector URL (required when enabled) |
TELEMETRY_BEARER_TOKEN |
Telemetry Authorization bearer token |
The host does not read a
.envfile. It reads real process environment variables only. Use your shell, systemd'sEnvironmentFile=, ordocker run --env-file. A stray.envin the working directory has no effect. (The one exception isplugins/.plugins.env, which the plugin loader reads on plugins' behalf — see Plugins.)
Everything is relative to the process working directory:
| Path | Contents |
|---|---|
cache/patches.db |
Loaded response-patch set |
cache/stats.db |
Query counters and byte totals |
cache/iana-referral.db |
Cached IANA → registry WHOIS referrals |
cache/dn42.db, cache/dn42-registry/ |
DN42 object index and the git clone itself |
cache/manrs.db, peeringdb.db, icp.db, pen.db, openwrt.db |
Per-service caches |
cache/plugins.db |
Plugin-visible key/value store, namespaced per plugin |
plugins/ |
Runtime-scanned plugin directory |
dumps/ |
Only with --dump-traffic |
Suffixes are case-insensitive and matched longest-first, so a short suffix can never shadow a longer one. A query with no recognised suffix is classified by shape (domain / IPv4 / IPv6 / CIDR / ASN) and resolved normally.
Core
| Query | Result |
|---|---|
HELP |
Suffix list for this build |
UPDATE-PATCH |
Re-fetch response patches from the remote repository |
<query>-DESC |
Description/remarks-only summary |
<query>-RDAP |
Force an RDAP lookup (works regardless of Feature_RdapBackend) |
Network intelligence
| Query | Result |
|---|---|
<AS/prefix>-BGPTOOL |
BGP looking-glass data via bgp.tools |
<resource>-IRR |
IRR Explorer route-object lookup |
<resource>-LG |
Looking-glass query |
AS<n>-MANRS |
MANRS compliance status |
AS<n>-PEERINGDB |
PeeringDB network record |
AS<n>-PREFIXES |
Announced prefixes |
<ip>-GEO |
Aggregated geolocation, multiple sources |
<ip>-RIRGEO |
RIR-reported geolocation |
<domain>-DNS |
DNS records over DoH |
<host>-PING |
Globalping ping measurement |
<host>-TRACEROUTE, <host>-TRACE |
Globalping traceroute (-TRACE is an alias) |
<server>-NTP |
NTP server query |
<domain>-SSL |
TLS certificate details |
<domain>-CRT |
crt.sh certificate-transparency search |
CFSTATUS |
Cloudflare status summary |
<resource>-EMAIL |
DN42 e-mail search |
<resource>-<REGISTRY> |
Direct query to one IRR registry |
The direct-registry suffixes are -RADB, -ALTDB, -AFRINIC, -APNIC, -ARIN, -BELL,
-JPIRR, -LACNIC, -LEVEL3, -NTTCOM, -RIPE, -RIS and -TC.
Miscellaneous
<domain>-ICP (China ICP filing) · <n|name>-PEN (IANA Private Enterprise Number) ·
<id>-PIXIV (artwork metadata)
Entertainment
-STEAM / -STEAMSEARCH · -IMDB / -IMDBSEARCH · -MINECRAFT (-MC) · -MCU
(Minecraft user) · -ACGC (characters) · -LYRIC · -WIKIPEDIA · -GITHUB
Random meal suggestions are matched by whole query as well as by suffix: 今天吃什么 or
<x>-MEAL (TheMealDB), and 今天吃什么中国 or <x>-MEAL-CN (a bundled Chinese recipe dataset).
Package registries
-ALMA · -AOSC · -AUR · -CARGO · -CURSEFORGE · -DEBIAN · -EPEL · -MODRINTH ·
-NIXOS · -NPM · -OPENSUSE · -OPENWRT · -PYPI · -UBUNTU
An extension, not part of RFC 3912. A client may send a header line before its query:
X-WHOIS-COLOR-PROBE: 1.0 → X-WHOIS-COLOR-SUPPORT: 1.0 schemes=ripe,ripe-dark,bgptools,bgptools-dark
X-WHOIS-COLOR: ripe-dark → the following response is ANSI-colourised
Standard clients never send these headers and so never see colour. Disable server-side with
--enable-color=false.
Served by Kestrel on --web-port (default 9999).
| Endpoint | Purpose |
|---|---|
GET / |
Dashboard (query UI + statistics) |
GET /api/whois?q=<query> |
Run a query, JSON response |
POST /api/whois |
Same, query in the JSON body |
GET /raw/{query} |
Run a query, text/plain response |
GET /api/stats |
Request counters, bytes served, 24-hour histogram |
GET /api/openapi.json |
OpenAPI document |
GET /pixiv-proxy/{**path} |
Pixiv image reverse-proxy (needs Feature_Pixiv) |
GET /pixiv-proxy-health |
Proxy liveness check |
curl 'http://127.0.0.1:9999/raw/example.com'
curl 'http://127.0.0.1:9999/api/whois?q=AS213605-PEERINGDB'
curl -X POST http://127.0.0.1:9999/api/whois -H 'content-type: application/json' -d '{"query":"1.1.1.1-GEO"}'
Feature_Web=falseremoves the dashboard, the API routes and static-file serving, but Kestrel still binds the web port and answers 404 — the TCP listener and the HTTP host are deliberately one process. If the port must not be bound at all, keep it on loopback with--host.
With Feature_Dn42 on (the default), the server shallow-clones
https://git.akae.re/pysio/mirrors-dn42.git into cache/dn42-registry/ at startup and pulls
hourly, indexing objects into cache/dn42.db.
Queries falling inside DN42's private ranges are routed to that registry automatically, so
172.20.0.1 or AS4242420000 need no suffix. Handles ending in -DN42 or -MNT (person and
maintainer objects) are also recognised directly.
Sync happens through the system git binary, so git must be installed. A failed sync is logged
and retried on the next tick; it never stops the server.
A small DSL rewrites specific lines of outgoing WHOIS text — used to correct or annotate upstream
data. Patches load from cache/patches.db at startup; to fetch or refresh them, send the
UPDATE-PATCH query. Failure to load patches is a warning, not a fatal error: the server starts and
serves unpatched text.
The patch set is fetched over HTTPS from a patches.json manifest and SHA1-verified per patch
against the digest that manifest declares, so a tampered or truncated download is rejected rather
than applied. The manifest URL points at patches/patches.json on this repository's
main branch — that directory is the published source of truth for every deployed server, not just
a local copy, so removing it breaks patch updates everywhere. The URL is a constant in
PatchManager, with no CLI flag or environment override: running a private patch set means changing
it in source.
To add a patch: drop the .patch file in patches/, run patches/update-patches-json.sh to
recompute the manifest's digests and sizes, and verify with patches/verify-patches.sh. See
patches/README.md for the DSL itself.
A plugin is an ordinary .NET assembly plus a plugin.json manifest, in its own directory under
plugins/. Each is loaded into an isolated AssemblyLoadContext and given only what its manifest
requests.
plugins/
├── .plugins.env # optional KEY=VALUE lines, exposed per manifest `envVars`
└── weather/
├── plugin.json
└── Weather.dll
The contract is one method:
public sealed class WeatherPlugin(HttpClient http) : IWhoisPlugin
{
// `query` arrives already suffix-stripped
public Task<string> ProcessAsync(string query, CancellationToken ct = default) => /* … */;
}The type must have exactly one public constructor. Its parameters may include an HttpClient
(wrapped in the domain whitelist) and an IKvStore (namespaced to this plugin); the loader supplies
whichever are present, and refuses to load the plugin if a parameter asks for something the manifest
does not permit.
plugins-src/Weather/ is a complete worked example. Building it copies the DLL and manifest into
plugins/weather/ automatically:
dotnet build plugins-src/Weather/Weather.csprojWhen writing your own, reference WhoisServer.Plugins and WhoisServer.Core with
Private="false" ExcludeAssets="runtime" — copying those assemblies next to your plugin would load
second copies of the shared contract types into your isolated context and break the
IWhoisPlugin type check at runtime. plugins-src/Weather/Weather.csproj documents this in detail.
44 MSBuild properties, one per atomic feature, each defaulting to true — except
Feature_RdapBackend, which defaults to false so the default resolution order stays
WHOIS-via-IANA-referral rather than RDAP-first.
A flag does two things at once, closely mirroring Cargo's #[cfg(feature = "…")]:
- Drops that feature's source files from compilation (
<Compile Remove>), and - undefines its
FEATURE_*preprocessor symbol, so#if-guarded registrations and dispatch cases disappear too.
The result is a genuinely smaller binary with the disabled dependencies absent, not a build with dead code switched off at runtime.
dotnet build whois-server.slnx -p:Feature_Dn42=false
dotnet publish src/WhoisServer.Host -c Release -p:Feature_Web=false -p:Feature_Pixiv=false
make minimal # DN42 excludedFeature_Packages is a convenience preset, not an atomic flag: the 14 Feature_Pkg* flags default
to whatever it resolved to, so -p:Feature_Packages=false cascades to all of them while
-p:Feature_PkgNpm=false still disables just npm.
The full list lives in Directory.Build.props, which is the single source
of truth. CI builds several combinations plus an every-flag-off build whose flag list is derived
from that file, so a newly added flag is covered without editing the workflow.
make help # list targets
make build # Debug build of the solution
make run # run on port 4343
make watch # dotnet watch, auto-restart
make test # everything, including live-network tests
make test-unit # Core tests only — no network needed
make test-integration # live WHOIS/RDAP/HTTP tests
make format # apply style fixes
make format-check # verify style, CI-equivalent
make publish # framework-dependent Release build into publish/
make deb # self-contained .deb into dist/ (RID=, VERSION=)
make rpm # self-contained .rpm into dist/ (same variables)
make packages # both of the above
make clean # remove bin/obj, cache/, publish/ and dist/Three test projects, 61 tests:
WhoisServer.Core.Tests(45) — query classification, patch DSL, colouriser, storage. Offline.WhoisServer.Plugins.Tests(10) — manifest parsing, registry collisions, end-to-end load of a real compiled plugin.WhoisServer.Integration.Tests(6) — boots the actual DI-wired host on a free port and drives it over TCP, resolving real domains against real registries. Needs outbound 43 and 443, and is non-blocking in CI for that reason.
CI (.github/workflows/ci.yml) gates on a warning-free Release build, the offline tests,
dotnet format --verify-no-changes, the cut-build matrix, a full install/query/purge cycle of the
Debian package, and an install of the RPM on Fedora. Images are built and pushed by docker.yml;
tagged releases produce self-contained tarballs plus .deb and .rpm packages via release.yml.
src/
├── WhoisServer.Core/ # query classification, patch DSL, colour protocol, SQLite stores, stats
├── WhoisServer.Services/ # ~70 upstream integrations (BGP, geo, DNS, packages, entertainment, …)
├── WhoisServer.Services.Dn42/ # DN42 git mirror, object index, private-range routing
├── WhoisServer.Plugins/ # plugin loader, isolated load contexts, permission enforcement
└── WhoisServer.Host/ # composition root: TCP 43 listener, Kestrel, dashboard, CLI
plugins-src/Weather/ # worked plugin example
packaging/ # common systemd unit + layout, .deb and .rpm build scripts
tests/ # Core, Plugins, Integration
Storage is SQLite throughout (Microsoft.Data.Sqlite) behind a narrow IKvStore, one database file
per subsystem.
Deliberate, locked-in decisions of the rewrite — not gaps awaiting work:
| SSH server dropped | The interactive shell on port 2222 is gone entirely. WHOIS over TCP 43 and the web API are the only front doors. |
| Lua plugins replaced | No embedded scripting engine. Extensibility is native .NET assemblies with manifest-declared permissions (see Plugins). |
| DN42 backend unified | One git-backed, cross-platform implementation. The Windows / online-HTTP-fetch variant is gone. |
| SQLite instead of LMDB | Same IKvStore shape, one file per subsystem. |
| RDAP stays opt-in | Matching upstream's actual default (its full preset omits rdap-backend), though the explicit -RDAP suffix now works in every build rather than requiring a recompile. |
Not a byte-for-byte port: correctness and idiomatic .NET design took precedence, and known upstream bugs were fixed in passing rather than reproduced.
GNU Affero General Public License v3.0 or later. See LICENSE and COPYRIGHT.
Issues and questions: noc@akae.re or the issue tracker.
{ "name": "weather", "version": "1.0.0", "suffix": "-WEATHER", // rejected at load time if already claimed "author": "Akaere Networks", "description": "Weather information via wttr.in", "enabled": true, "timeoutSeconds": 5, // exceeded → the query fails, the server does not "entryPoint": "Weather.dll", "permissions": { "network": true, "allowedDomains": ["wttr.in"], // enforced by an HTTP handler, not advisory "cacheRead": true, "cacheWrite": true, // a namespaced slice of cache/plugins.db "envVars": [] } }