diff --git a/README.md b/README.md index f7e68aa..4523563 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/config/config.go b/config/config.go index de776d4..f73d6d8 100644 --- a/config/config.go +++ b/config/config.go @@ -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", @@ -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, @@ -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" @@ -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"` diff --git a/config/defaults_other.go b/config/defaults_other.go new file mode 100644 index 0000000..352e173 --- /dev/null +++ b/config/defaults_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package config + +func runningAsRoot() bool { + return false +} diff --git a/config/defaults_unix.go b/config/defaults_unix.go new file mode 100644 index 0000000..5a2f7a9 --- /dev/null +++ b/config/defaults_unix.go @@ -0,0 +1,9 @@ +//go:build unix + +package config + +import "syscall" + +func runningAsRoot() bool { + return syscall.Geteuid() == 0 +} diff --git a/main.go b/main.go index 7ee5af6..321beda 100644 --- a/main.go +++ b/main.go @@ -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. @@ -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{ @@ -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)") @@ -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 } } @@ -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] @@ -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) } @@ -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 diff --git a/systemd/dnsplane.service b/systemd/dnsplane.service index 0262675..eda64d8 100644 --- a/systemd/dnsplane.service +++ b/systemd/dnsplane.service @@ -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) @@ -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