Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ When you do not pass any path flags, dnsplane looks for an existing `dnsplane.js

If a data file does not exist, dnsplane creates it with default contents at the configured (or overridden) path. When the records source is URL or Git, no local records file is created (records are read-only from the remote source).

**Defaults and non-root:** When the config directory is not under `/etc` (e.g. you run from your home or current directory), the default log directory is `log` next to your config (e.g. `./log` or `~/.config/dnsplane/log`), so you can run without root. The default control socket is user-specific when not running as root: `$XDG_RUNTIME_DIR/dnsplane.socket` if set, otherwise `~/.config/dnsplane/dnsplane.socket`, so each user can run their own server. When running as root or when using a system config under `/etc`, defaults use `/var/log/dnsplane` and a shared socket path.

### TUI (interactive client)

When you run `dnsplane client` (or connect over TCP), you get an interactive TUI. Main areas:
Expand Down Expand Up @@ -221,7 +223,7 @@ A systemd unit file is provided under `systemd/dnsplane.service`. It runs the bi
3. Create the config directory: `mkdir -p /etc/dnsplane`.
4. Reload and enable: `systemctl daemon-reload && systemctl enable --now dnsplane.service`.

When the service runs, it will create default `dnsplane.json` and JSON data files in `/etc/dnsplane/` if they are missing, because the unit file passes those paths. Ensure the service user (e.g. root) can write to that directory for the first start.
When the service runs, it will create default `dnsplane.json` and JSON data files in `/etc/dnsplane/` if they are missing, because the unit file passes those paths. Ensure the service user (e.g. root) can write to that directory for the first start. To run as an unprivileged user, see the comments in `systemd/dnsplane.service`: create a `dnsplane` user, set `User=`/`Group=`, add `StateDirectory=dnsplane`, and use data paths under `/var/lib/dnsplane`.

## Roadmap

Expand Down
34 changes: 32 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ func writeConfig(path string, cfg *Config) error {
}

func defaultConfig(baseDir string) *Config {
logDir := "/var/log/dnsplane"
if !isSystemConfigDir(baseDir) {
logDir = filepath.Join(baseDir, "log")
}
return &Config{
FallbackServerIP: "1.1.1.1",
FallbackServerPort: "53",
Expand All @@ -287,7 +291,7 @@ func defaultConfig(baseDir string) *Config {
ForwardPTRQueries: false,
},
Log: LogConfig{
Dir: "/var/log/dnsplane",
Dir: logDir,
Severity: "none",
Rotation: LogRotationSize,
RotationSizeMB: 100,
Expand Down Expand Up @@ -340,7 +344,11 @@ func (c *Config) applyDefaults(configDir string) {
}

if c.Log.Dir == "" {
c.Log.Dir = "/var/log/dnsplane"
if isSystemConfigDir(configDir) {
c.Log.Dir = "/var/log/dnsplane"
} else {
c.Log.Dir = filepath.Join(configDir, "log")
}
}
if c.Log.Severity == "" {
c.Log.Severity = "none"
Expand Down Expand Up @@ -377,10 +385,32 @@ func ensureAbsolutePath(configDir, value, fallbackName string) string {
return filepath.Join(configDir, value)
}

// isSystemConfigDir returns true when configDir is the system config location (e.g. /etc or /etc/dnsplane),
// so log dir and other defaults can use system paths like /var/log/dnsplane.
func isSystemConfigDir(configDir string) bool {
clean := filepath.Clean(configDir)
return clean == "/etc" || strings.HasPrefix(clean, "/etc"+string(filepath.Separator))
}

func defaultSocketPath() string {
if runningAsRoot() {
return filepath.Join(os.TempDir(), "dnsplane.socket")
}
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
return filepath.Join(xdg, "dnsplane.socket")
}
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, "dnsplane", "dnsplane.socket")
}
return filepath.Join(os.TempDir(), "dnsplane.socket")
}

// DefaultClientSocketPath returns the default UNIX socket path for the TUI client and server.
// When running as non-root it uses XDG_RUNTIME_DIR or the user config dir so each user has their own socket.
func DefaultClientSocketPath() string {
return defaultSocketPath()
}

func extractLegacyRecordSettings(data []byte) *DNSRecordSettings {
type legacy struct {
DNSRecordSettings *DNSRecordSettings `json:"DNSRecordSettings"`
Expand Down
7 changes: 7 additions & 0 deletions config/defaults_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !unix

package config

func runningAsRoot() bool {
return false
}
9 changes: 9 additions & 0 deletions config/defaults_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build unix

package config

import "syscall"

func runningAsRoot() bool {
return syscall.Geteuid() == 0
}
20 changes: 14 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import (
)

const (
defaultUnixSocketPath = "/tmp/dnsplane.socket"
defaultTCPTerminalAddr = ":8053"
defaultClientTCPPort = "8053"
// tuiBannerPrefix is sent by the server on new TUI connections; client must see this or disconnect.
Expand All @@ -56,6 +55,9 @@ var (
clientLogger *slog.Logger
asyncLogQueue *logger.AsyncLogQueue

// defaultSocketPath is the default UNIX socket for server and client (set in init from config).
defaultSocketPath string

tcpTUIListenerMu sync.Mutex
tcpTUIListener net.Listener
rootCmd = &cobra.Command{
Expand Down Expand Up @@ -120,6 +122,7 @@ func main() {

func init() {
rootCmd.AddCommand(serverCmd, clientCmd)
defaultSocketPath = config.DefaultClientSocketPath()

// Server flags
serverCmd.Flags().String("config", "", "Path to config file (default: standard search order)")
Expand All @@ -129,15 +132,15 @@ func init() {
serverCmd.Flags().String("dnsservers", "", "Path to dnsservers.json file (overrides config)")
serverCmd.Flags().String("dnsrecords", "", "Path to dnsrecords.json file (overrides config)")
serverCmd.Flags().String("cache", "", "Path to dnscache.json file (overrides config)")
serverCmd.Flags().String("server-socket", defaultUnixSocketPath, "Path to UNIX domain socket for the daemon listener")
serverCmd.Flags().String("server-socket", defaultSocketPath, "Path to UNIX domain socket for the daemon listener")
serverCmd.Flags().String("server-tcp", defaultTCPTerminalAddr, "TCP address for remote TUI clients")

// Client flags
clientCmd.Flags().String("client", "", "Socket path or address to connect to (default: "+defaultUnixSocketPath+")")
clientCmd.Flags().String("client", "", "Socket path or address to connect to (default: "+defaultSocketPath+")")
clientCmd.Flags().String("log-file", "", "Path to log file or directory for client (writes dnsplaneclient.log when set)")
clientCmd.Flags().Bool("kill", false, "Disconnect the current TUI client and take over the session")
if f := clientCmd.Flags().Lookup("client"); f != nil {
f.NoOptDefVal = defaultUnixSocketPath
f.NoOptDefVal = defaultSocketPath
}
}

Expand All @@ -158,7 +161,7 @@ func normalizeTCPAddress(addr string) string {
func runClient(cmd *cobra.Command, args []string) error {
clientTarget, _ := cmd.Flags().GetString("client")
if clientTarget == "" {
clientTarget = defaultUnixSocketPath
clientTarget = defaultSocketPath
}
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
clientTarget = args[0]
Expand Down Expand Up @@ -722,6 +725,11 @@ func startUnixSocketListener(socketPath string, log *slog.Logger) (net.Listener,
if socketPath == "" {
return nil, nil
}
if dir := filepath.Dir(socketPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create socket directory: %w", err)
}
}
if err := syscall.Unlink(socketPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("remove unix socket: %w", err)
}
Expand Down Expand Up @@ -1053,7 +1061,7 @@ func connectToInteractiveEndpoint(target string, killOther bool) {
func resolveInteractiveTarget(target string) (network, address string) {
target = strings.TrimSpace(target)
if target == "" {
return "unix", defaultUnixSocketPath
return "unix", defaultSocketPath
}
if strings.ContainsAny(target, "/\\") || strings.HasPrefix(target, "@") {
return "unix", target
Expand Down
16 changes: 13 additions & 3 deletions systemd/dnsplane.service
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
# Prepare: mkdir -p /etc/dnsplane; first run creates default config and JSON files there if missing
# Binary: install dnsplane to /usr/local/dnsplane/dnsplane
# Then: systemctl daemon-reload && systemctl enable --now dnsplane.service
#
# Non-root: To run as unprivileged user:
# 1. Create user: useradd -r -s /bin/false dnsplane
# 2. Uncomment User= and Group= below
# 3. Add StateDirectory=dnsplane (creates /var/lib/dnsplane, owned by dnsplane)
# 4. Use data paths under /var/lib/dnsplane so the service can write without root.
# Example ExecStart for non-root (config in /etc, data in /var/lib/dnsplane):
# ExecStart=/usr/local/dnsplane/dnsplane server --config /etc/dnsplane/dnsplane.json --server-socket /run/dnsplane/dnsplane.socket --dnsservers /var/lib/dnsplane/dnsservers.json --dnsrecords /var/lib/dnsplane/dnsrecords.json --cache /var/lib/dnsplane/dnscache.json
# Ensure /etc/dnsplane (and dnsplane.json) is readable by dnsplane.

[Unit]
Description=DNS Resolver (dnsplane)
Expand All @@ -11,15 +20,16 @@ Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/dnsplane/dnsplane server --config /etc/dnsplane/dnsplane.json --dnsservers /etc/dnsplane/dnsservers.json --dnsrecords /etc/dnsplane/dnsrecords.json --cache /etc/dnsplane/dnscache.json
RuntimeDirectory=dnsplane
ExecStart=/usr/local/dnsplane/dnsplane server --config /etc/dnsplane/dnsplane.json --server-socket /run/dnsplane/dnsplane.socket --dnsservers /etc/dnsplane/dnsservers.json --dnsrecords /etc/dnsplane/dnsrecords.json --cache /etc/dnsplane/dnscache.json
Restart=on-failure
RestartSec=5
# Prevent systemd from waiting forever on restart/stop (sends SIGKILL after 30s)
TimeoutStopSec=30

# Optional: run as unprivileged user (create dnsplane user and set NoNewPrivileges=yes if desired)
# Run as unprivileged user (optional): create user first, then uncomment and use data paths under /var/lib/dnsplane
# User=dnsplane
# Group=dnsplane
# StateDirectory=dnsplane

[Install]
WantedBy=multi-user.target
Loading