| Crates.io | satsuki |
| lib.rs | satsuki |
| version | 0.1.1 |
| created_at | 2025-11-27 05:42:57.584145+00 |
| updated_at | 2025-11-27 07:39:01.884979+00 |
| description | A Rust-based web frontend for delegating and managing subdomains under a configured base domain using PowerDNS. Users can register a subdomain, authenticate using Basic Auth, and manage DNS records through a simple API. Simple Web UI included. |
| homepage | https://github.com/metastable-void/satsuki |
| repository | https://github.com/metastable-void/satsuki |
| max_upload_size | |
| id | 1953140 |
| size | 356,718 |
A Rust-based web frontend for delegating and managing subdomains under a configured base domain using PowerDNS.
Users can register a subdomain, authenticate using Basic Auth, and manage DNS records through both a JSON API and the bundled React/Vite frontend under frontend/.
This project contains:
Rust, axum, tokio)satsuki-pdns-frontend) for embedding or standalone use# with Rustup
cargo install satsuki
Or download pre-built binaries from Releases.
Users select a subdomain (e.g., alice) → the system provisions:
localStorageAuthorization: Basic …Users can read/write DNS RRsets for their zone only. The UI automatically normalizes relative owners, decodes IDNs for display, and punycodes them again on save. NS at the apex is protected and controlled only via NS-mode endpoints.
Two modes:
Only SQLite contains:
All DNS data stays in PowerDNS.
Rust (server)
axum web serversqlx SQLite backendreqwest PowerDNS API clientargon2 password hashingthiserror, anyhow for errorsregex + custom validationPowerDNS
satsuki-pdns-frontend uses a flexible builder pattern that is configured from the CLI.
satsuki-pdns-frontend \
--base-domain example.com \
--db-path ./data/users.sqlite \
--listen 0.0.0.0:8080 \
--base-pdns-url http://127.0.0.1:8081/api/v1 \
--base-pdns-key secret123 \
--base-pdns-server-id localhost \
--sub-pdns-url http://127.0.0.1:8082/api/v1 \
--sub-pdns-key otherkey456 \
--sub-pdns-server-id localhost \
--internal-ns ns1.example.net. \
--internal-ns ns2.example.net.
Notes:
--base-domain example.com (without trailing dot).User-provided subdomain labels (e.g., alice in alice.example.com) must satisfy:
[a-z0-9-] only---These rules avoid ambiguous DNS behavior and ensure safety.
A small set of infrastructure-friendly labels (e.g. www, mail, ftp, smtp, email) plus the RFC 2606/6761 special-use names (example, invalid, localhost, test) are blocked by default.
Override or extend this list through AppConfig::disallowed_subdomains if you need different policies.
All API endpoints return JSON.
GET /healthReturns {"status":"ok"} so load balancers and the bundled frontend can verify that the process is alive. This endpoint never touches the database or PowerDNS.
POST /api/signupRegisters a new subdomain. The payload must pass validate_subdomain_name, cannot appear in the disallowed list, and the password is Argon2-hashed before storage.
{
"subdomain": "alice",
"password": "supers3cret"
}
When the request succeeds:
Failures during steps (2)–(4) trigger best-effort cleanup of both PDNS instances. Duplicate subdomains return HTTP 409.
POST /api/signinChecks credentials and updates last_login_at when successful. Response body is {"ok": true} on success and 401 on failures (no session cookies are issued—the caller stores Basic Auth credentials).
{
"subdomain": "alice",
"password": "supers3cret"
}
GET /api/subdomain/check?name=<label>Validates the label and reports availability:
{ "available": true }
Reserved labels (e.g. www, mail, localhost, …​) are treated as unavailable even if they are not in the database.
GET /api/aboutReturns basic metadata for the deployment:
{ "base_domain": "example.com" }
GET /api/subdomain/listFetches the NS RRsets from the base PowerDNS zone and groups them by owner name (including the apex entry). Example response:
[
{
"name": "example.com.",
"records": ["ns1.example.net.", "ns2.example.net."]
},
{
"name": "custom.example.com.",
"records": ["ns1.custom-dns.com.", "ns2.custom-dns.com."]
}
]
GET /api/subdomain/soaReturns the parent-zone SOA line used by the frontend’s BIND-style helper:
{ "soa": "ns1.example.net. hostmaster.example.net. 2024010101 7200 900 1209600 300" }
GET /metricsExports Prometheus text metrics, currently satsuki_subdomains_total, which counts unique delegated subdomains (i.e., non-apex NS RRsets in the parent zone):
satsuki_subdomains_total 42
All authenticated endpoints require:
Authorization: Basic base64("subdomain:password")
GET /api/zoneReturns every RRset for the user’s zone except the apex NS RRset, which is managed by the NS-mode endpoints. Example:
[
{
"name": "www.alice.example.com.",
"rrtype": "A",
"ttl": 300,
"content": "203.0.113.5",
"priority": null
}
]
PUT /api/zoneReplaces the submitted RRsets. Records are grouped by (name, rrtype) and each group must share the same TTL. Apex NS and SOA changes are rejected to keep the NS-mode flow authoritative.
{
"records": [
{
"name": "www.alice.example.com.",
"rrtype": "A",
"ttl": 600,
"content": "203.0.113.5",
"priority": null
}
]
}
POST /api/ns-mode/internalReplaces the parent-zone delegation with the configured internal NS values and clears any stored external NS details in the database. Use this to “bring the zone home” after previously pointing it to third-party nameservers.
POST /api/ns-mode/externalSwitches the parent-zone delegation to user-provided nameservers. The payload must contain 1–6 FQDNs that end with a dot:
{
"ns": ["ns1.custom-dns.com.", "ns2.custom-dns.com."]
}
The accepted NS list is stored in SQLite so the UI can reflect the user’s current configuration.
GET /api/profileReturns the logged-in user’s metadata:
{
"subdomain": "alice",
"external_ns": false,
"external_ns1": null,
"external_ns2": null,
"external_ns3": null,
"external_ns4": null,
"external_ns5": null,
"external_ns6": null
}
POST /api/password/changeAllows a logged-in user to rotate their password without re-registering. Requires the current password and a new secret (minimum 8 characters):
{
"current_password": "supers3cret",
"new_password": "evenB3tter!"
}
Invalid current passwords return 401; successful changes return {"ok": true}.
migrations/0001_init.sql:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subdomain TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
external_ns INTEGER NOT NULL DEFAULT 0,
external_ns1 TEXT,
external_ns2 TEXT,
external_ns3 TEXT,
external_ns4 TEXT,
external_ns5 TEXT,
external_ns6 TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
sqlx-cli (optional for migrations)frontend/) (Optional)npm install
npm run dev # serves frontend from ./frontend
npm run build # emits static assets into ./dist
cargo run -- --base-domain example.com --db-path ./dev.sqlite ...
With sqlx-cli:
sqlx migrate run
Or rely on:
sqlx::migrate!().run(&pool)
which runs automatically on startup.
example.com.)/servers/{id}/zones/...)POST /servers/{id}/zones to create user zonesPATCH /servers/{id}/zones/{zone} to modify RRsetsApache-2.0 or MPL-2.0.