Disclaimer: This file was generated by AI
DNSTUI is a terminal UI application written in Go for managing DNS records across multiple providers. It follows a layered architecture with a clear separation between configuration, provider business logic, and the user interface.
┌─────────────────────────────────────────────────────┐
│ CLI (cmd/) │
│ Cobra commands + flag/env binding │
└────────────────────┬────────────────────────────────┘
│ loads
┌────────────────────▼────────────────────────────────┐
│ Config (internal/config/) │
│ YAML file · env vars · CLI flags (Viper) │
└────────────────────┬────────────────────────────────┘
│ constructs
┌────────────────────▼────────────────────────────────┐
│ Provider Registry (internal/provider/) │
│ interface · shared types · factory/registry │
└──┬─────────────┬──────────────┬────────────────┬────┘
│ │ │ │
┌──▼──┐ ┌─────▼──┐ ┌──────▼───┐ ┌──────▼────┐
│ CF │ │Techni- │ │ ClouDNS │ │Openprov- │
│ │ │ tium │ │ │ │ ider │
└─────┘ └────────┘ └──────────┘ └───────────┘
providers/
│ drives
┌────────────────────▼────────────────────────────────┐
│ TUI (internal/tui/) │
│ Bubble Tea model stack · Lipgloss │
└─────────────────────────────────────────────────────┘
dnstui/
├── main.go # Entrypoint — calls cmd.Execute()
├── dnstui.example.yaml # Annotated reference config file
├── go.mod / go.sum
│
├── cmd/ # Cobra CLI layer
│ ├── root.go # Root command, PersistentPreRunE loads config
│ ├── version.go # `dnstui version` subcommand
│ ├── providers.go # `dnstui providers` — list configured providers
│ └── search.go # `dnstui search` — launch GlobalSearch view
│
├── internal/ # Private application packages
│ ├── config/ # Configuration loading & validation
│ │ ├── config.go
│ │ └── config_test.go
│ │
│ ├── provider/ # Provider interface, shared types, registry
│ │ ├── types.go # Account, Zone, Record, RecordType
│ │ ├── provider.go # Provider interface definition
│ │ ├── registry.go # Register() / New() / NewAll() factory
│ │ └── registry_test.go
│ │
│ ├── tui/ # Bubble Tea models & views
│ │ ├── tui.go # Root model, view stack, Run() / RunWithSearch()
│ │ ├── providerlist.go # ProviderList + ZoneList views
│ │ ├── recordlist.go # RecordList view (table)
│ │ ├── recordform.go # RecordForm view (add/edit)
│ │ ├── confirmdialog.go # ConfirmDialog overlay
│ │ └── globalsearch.go # GlobalSearch view
│ │
│ ├── cache/ # (Phase 6) TTL cache for accounts & zones
│ └── log/ # (Phase 8) Structured logging setup
│
└── providers/ # One package per DNS provider implementation
├── cloudflare/ # Cloudflare API client
├── technitium/ # Technitium DNS server API client
├── cloudns/ # (Phase 7) ClouDNS API client
└── openprovider/ # (Phase 7) Openprovider API client
Sole purpose: call cmd.Execute() and exit with a non-zero code on error. No logic lives here.
Built with Cobra. The root command's PersistentPreRunE is the single location where config.Load() is called, ensuring the validated *config.Config is available to every subcommand.
Subcommands:
| Command | Description |
|---|---|
| (no subcommand) | Launch the TUI (ProviderList entry point) |
version |
Print the build version |
providers |
List registered provider types and configured accounts |
search |
Launch the TUI directly at the GlobalSearch view |
Flag → Viper binding: --config / -c sets an explicit config file path; --log-level / -l overrides log_level and is bound to Viper so it participates in the full precedence chain.
Build-time version injection:
go build -ldflags "-X github.com/scheiblingco/dnstui/cmd.Version=1.2.3" .
Owns the full configuration lifecycle: defaults → file → environment → CLI flags (increasing precedence, managed by Viper).
Key types:
type Config struct {
LogLevel string // debug | info | warn | error
Providers []ProviderConfig // ordered list of provider accounts
Cache CacheConfig
}
type ProviderConfig struct {
Name string // human-readable alias (shown in TUI)
Type string // must match a registered provider type name
Settings map[string]any // provider-specific credentials/endpoints
}
type CacheConfig struct {
TTLSeconds int // freshness window for account/zone list cache (default 300)
DiskCache bool // persist cache across sessions (default true)
}Config file discovery (in order, first match wins):
- Path given via
--configflag $HOME/.config/dnstui/dnstui.yaml$XDG_CONFIG_HOME/dnstui/dnstui.yaml./dnstui.yaml(current directory)
Environment variable mapping: Prefix DNSTUI_, dots replaced with underscores.
Examples: DNSTUI_LOG_LEVEL=debug, DNSTUI_CACHE_TTL_SECONDS=60.
Validation rules enforced by Config.Validate():
log_levelmust be one ofdebug,info,warn,error- Every provider entry must have a non-empty
nameandtype cache.ttl_secondsmust be ≥ 0
Defines the contract that all provider implementations must fulfil, plus the shared data model and the self-registration registry.
type Provider interface {
ProviderName() string // stable lowercase type key, e.g. "cloudflare"
FriendlyName() string // user-configured alias from ProviderConfig.Name
ListAccounts(ctx context.Context) ([]Account, error)
ListZones(ctx context.Context, accountID string) ([]Zone, error)
ListRecords(ctx context.Context, zoneID string) ([]Record, error)
CreateRecord(ctx context.Context, zoneID string, r Record) (Record, error)
UpdateRecord(ctx context.Context, zoneID, recordID string, r Record) (Record, error)
DeleteRecord(ctx context.Context, zoneID, recordID string) error
}All list methods must return an empty (non-nil) slice when there are no results.
| Type | Purpose |
|---|---|
Account |
Top-level account/sub-account within a provider |
Zone |
A DNS zone (domain), linked to an Account by AccountID |
Record |
A single DNS resource record |
RecordType |
Typed string constant: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR, SOA, TLSA, SSHFP, NAPTR |
SearchEntry |
A flattened account-or-domain item used by the global search index |
SearchEntryKind |
Typed string: "account" or "domain" |
Record.Extra map[string]any carries provider-specific fields that don't fit the common model (e.g. Cloudflare's proxied flag, Technitium comments). Keys are lower-snake-case strings.
Providers self-register in their package init() function:
func init() {
provider.Register("cloudflare", New)
}The registry maps type name → Constructor (func(config.ProviderConfig) (Provider, error)).
provider.NewAll(cfgs []config.ProviderConfig) is called at startup to instantiate all configured providers; it fails fast on the first construction error.
cache.go provides BuildSearchCache(ctx, providers) which walks every provider's accounts and zones and returns a flat []SearchEntry slice. The root TUI model calls this as a background tea.Cmd immediately on startup (via Model.Init()). Each entry carries its Kind ("account" or "domain"), a pre-formatted display Label, a reference to the owning Provider, and either the Account or Zone value.
When the background load completes, a CacheLoadedMsg is dispatched to the root model, which stores the entries and — if the GlobalSearch view is already open — updates it live.
Each directory is a self-contained Go package implementing the provider.Provider interface for one DNS provider. Provider packages have no cross-dependencies — they only import internal/provider (for shared types) and internal/config (for ProviderConfig).
| Package | Type key | Auth method | Status |
|---|---|---|---|
providers/cloudflare |
cloudflare |
API token or API key + email | ✅ Complete |
providers/technitium |
technitium |
API key, HTTP header | ✅ Complete |
providers/cloudns |
cloudns |
auth-id + password (or sub-auth-id for sub-users) | ✅ Complete |
providers/openprovider |
openprovider |
static token or username + password | ✅ Complete |
Provider-specific settings are decoded from ProviderConfig.Settings (a map[string]any) into a typed struct at construction time using github.com/go-viper/mapstructure/v2.
Five files:
| File | Purpose |
|---|---|
cloudflare.go |
Settings struct, New() constructor, ProviderName()/FriendlyName(), init() registration |
apitypes.go |
CF API response types: cfResponse[T], cfAccount, cfZone, cfRecord, cfRecordRequest, apiErrors() |
client.go |
newRequest(), doRequest() (3-attempt backoff on 5xx), getAllPages[T](), doJSON[T]() |
records.go |
All six Provider interface methods |
mapping.go |
toRecord() (CF → shared), toRequest() (shared → CF) |
Auth: Authorization: Bearer <token> (preferred) or legacy X-Auth-Key + X-Auth-Email.
Pagination: all list endpoints are fully consumed via getAllPages (100 results/page).
Cloudflare-specific extras stored in Record.Extra:
proxied(bool) — CDN proxy statusproxiable(bool) — whether the record type supports proxyingcomment(string) — optional record notedata(string, JSON) — raw structured data for SRV/CAA/TLSA/SSHFP/NAPTR records
TTL convention: CF TTL 1 ("automatic") maps to provider TTL 0 and vice versa.
base_url setting can override the API base URL — used by integration tests (httptest.NewServer).
Single file technitium.go containing all logic.
| Section | Notes |
|---|---|
| Auth | API token appended as ?token=<key> to every request URL |
| Accounts | Synthesised from GET /api/user/session/get — Technitium has no sub-account concept |
| Zones | GET /api/zones/list — returns all zone types |
| Records | GET /api/zones/records/get?zone=<name> |
| Create | GET /api/zones/records/add with type-specific query params |
| Update | GET /api/zones/records/update — passes oldValue for overwrite targeting |
| Delete | GET /api/zones/records/delete |
| Record ID | Composite string "name\x00type\x00value" (null-byte delimited) — round-trips through update/delete |
| Extras | comment (string), disabled (bool), weight+port (SRV), caa_flags+caa_tag (CAA) |
Built on Bubble Tea. Navigation follows a model stack: each view is a tea.Model pushed onto Model.stack; Esc pops back to the previous view. Two entry points:
tui.Run(providers)— starts atProviderListtui.RunWithSearch(providers)— starts atGlobalSearch(used bydnstui search)
All provider API calls are dispatched as tea.Cmd (non-blocking). A spinner component is shown while loading.
Shared messages (defined in tui.go):
| Message | Direction |
|---|---|
PushMsg{Model} |
Child pushes new view onto stack |
PopMsg{} |
Child signals "go back" |
ErrorMsg{Err} |
Any view surfaces an error to the status bar |
StatusMsg{Text} |
Any view surfaces a success note to the status bar |
AccountsLoadedMsg |
Async accounts response |
ZonesLoadedMsg |
Async zones response |
RecordsLoadedMsg |
Async records response |
RecordSavedMsg |
Create/update completed |
RecordDeletedMsg |
Delete completed |
CacheLoadedMsg |
Startup search-cache background load completed |
Views:
| File | View | Description |
|---|---|---|
providerlist.go |
ProviderList |
Filterable list of all configured providers; loading spinner while fetching accounts |
providerlist.go |
ZoneList |
Filterable list of zones; Tab cycles accounts for multi-account providers |
recordlist.go |
RecordList |
Table of records (n new, e edit, d delete, r refresh); delegates CRUD to form/dialog |
recordform.go |
RecordForm |
Per-field text inputs for add/edit; ctrl+s to save; client-side validation before submit |
confirmdialog.go |
ConfirmDialog |
Generic yes/no overlay; executes any tea.Cmd on confirmation |
globalsearch.go |
GlobalSearch |
Ctrl+K modal showing all cached accounts and domains across providers; filters as you type; Enter navigates to the selected account's ZoneList or domain's RecordList |
Key bindings (global): ctrl+c quit | ctrl+k open global search | esc back | r refresh | n new | e edit | d delete | tab/↑↓ navigate | / filter.
In-memory TTL cache for Account and Zone lists (keyed by provider + account ID). When DiskCache is enabled, the cache is serialised to $XDG_CACHE_HOME/dnstui/cache.json on exit and reloaded on startup. Cache can be manually invalidated with the r keybind in the TUI.
Thin wrapper around Go's standard log/slog. Configures the handler (text or JSON) and level at startup from Config.LogLevel. All packages receive a *slog.Logger via function arguments — no package-level global logger.
CLI flags > environment variables > config file > built-in defaults
All four layers are managed by a single *viper.Viper instance created in cmd/root.go and passed to config.Load().
- Create
providers/<name>/<name>.go. - Define a settings struct and implement all methods of
provider.Provider. - Register in
init():func init() { provider.Register("<name>", New) }
- Blank-import the package in
cmd/root.go(ormain.go) to triggerinit():import _ "github.com/scheiblingco/dnstui/providers/<name>"
- Add an example stanza to
dnstui.example.yaml.
| Module | Purpose |
|---|---|
github.com/spf13/cobra |
CLI command/flag parsing |
github.com/spf13/viper |
Config file + env var + flag merging |
github.com/charmbracelet/bubbletea |
TUI event loop & model framework |
github.com/charmbracelet/bubbles |
Prebuilt TUI components |
github.com/charmbracelet/lipgloss |
Terminal styling |
github.com/go-viper/mapstructure/v2 |
Settings map → typed struct decoding |
log/slog (stdlib) |
Structured logging |
| Phase | Scope | Status |
|---|---|---|
| 1 | Project scaffold, module init, config package, Cobra entrypoint | ✅ Complete |
| 2 | Provider interface, shared types, registry/factory | ✅ Complete |
| 3 | Cloudflare provider | ✅ Complete |
| 4 | Technitium provider | ✅ Complete |
| 5 | TUI (Bubble Tea) — all views | ✅ Complete |
| 6 | Caching (TTL + disk) | ✅ Complete |
| 7 | ClouDNS and Openprovider | ✅ Complete |
| 8 | Error handling, logging, input validation | ✅ Complete |
| 9 | Unit + integration tests, documentation | ✅ Complete |