summaryrefslogtreecommitdiff
path: root/internal/config
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-22 23:51:18 +0300
committerPaul Buetow <paul@buetow.org>2026-07-22 23:51:18 +0300
commit849951be1d1a7ee9f9302006ccb187bf5b4e36f3 (patch)
tree496c924a03a9ea6212e29bb4699e268066ebad81 /internal/config
parentbf78b3abffee6d49c08ca2980156afc455994969 (diff)
feat: DTail fork — server/client feature development
Squashed development of the snonux/dtail fork's product code (internal/, cmd/) since diverging from mimecast/dtail. Major areas: - Read/output path: the former "turbo" channel-less path is now the single, default server-side read/output path for cat/grep/tail and MapReduce; the old channel-based path and its config/env toggles were removed. - MapReduce: single aggregate implementation (server + serverless) fed directly by a processor pipeline, with input-exhausted finalization via the shutdown coordinator; high-concurrency and data-race fixes. - Journal source reads (journal:unit.service) via journalctl, Linux-gated behind a journal-v1 capability. - Auth-key fast reconnect: in-memory per-user public-key cache with TTL/max-keys, registered over an authenticated session (AUTHKEY), checked before authorized_keys. - Interactive query reload (--interactive-query) with SESSION START/UPDATE generation boundaries and capability negotiation. - Client-side deadlines: --timeout / --shutdownAfter as context deadlines; follow shutdown handling. - Client logging: diagnostics-only daily log by default, opt-in payload tee via --log-payload. - Numerous correctness fixes (buffer-pool double-recycle races, EOF-sentinel leaks, glob-expansion cap, TOCTOU in CSV parsing) with accompanying unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/config')
-rw-r--r--internal/config/args.go47
-rw-r--r--internal/config/args_test.go95
-rw-r--r--internal/config/client.go41
-rw-r--r--internal/config/common.go3
-rw-r--r--internal/config/config.go13
-rw-r--r--internal/config/env.go10
-rw-r--r--internal/config/initializer.go87
-rw-r--r--internal/config/initializer_test.go260
-rw-r--r--internal/config/runtime.go18
-rw-r--r--internal/config/server.go89
10 files changed, 640 insertions, 23 deletions
diff --git a/internal/config/args.go b/internal/config/args.go
index 87ef393..f0b14e1 100644
--- a/internal/config/args.go
+++ b/internal/config/args.go
@@ -3,6 +3,7 @@ package config
import (
"encoding/base64"
"fmt"
+ "sort"
"strconv"
"strings"
@@ -18,16 +19,21 @@ type Args struct {
Arguments []string
ConfigFile string
ConnectionsPerCPU int
+ ControlTTYPath string
Discovery string
+ InteractiveQuery bool
LogDir string
Logger string
LogLevel string
+ LogPayload bool
Mode omode.Mode
+ NoAuthKey bool
NoColor bool
QueryStr string
Quiet bool
RegexInvert bool
RegexStr string
+ SSHAgentKeyIndex int
SSHAuthMethods []gossh.AuthMethod
SSHBindAddress string
SSHHostKeyCallback gossh.HostKeyCallback
@@ -50,16 +56,21 @@ func (a *Args) String() string {
sb.WriteString(fmt.Sprintf("%s:%v,", "Arguments", a.Arguments))
sb.WriteString(fmt.Sprintf("%s:%v,", "ConfigFile", a.ConfigFile))
sb.WriteString(fmt.Sprintf("%s:%v,", "ConnectionsPerCPU", a.ConnectionsPerCPU))
+ sb.WriteString(fmt.Sprintf("%s:%v,", "ControlTTYPath", a.ControlTTYPath))
sb.WriteString(fmt.Sprintf("%s:%v,", "Discovery", a.Discovery))
+ sb.WriteString(fmt.Sprintf("%s:%v,", "InteractiveQuery", a.InteractiveQuery))
sb.WriteString(fmt.Sprintf("%s:%v,", "LogDir", a.LogDir))
sb.WriteString(fmt.Sprintf("%s:%v,", "LogLevel", a.LogLevel))
+ sb.WriteString(fmt.Sprintf("%s:%v,", "LogPayload", a.LogPayload))
sb.WriteString(fmt.Sprintf("%s:%v,", "Logger", a.Logger))
sb.WriteString(fmt.Sprintf("%s:%v,", "Mode", a.Mode))
+ sb.WriteString(fmt.Sprintf("%s:%v,", "NoAuthKey", a.NoAuthKey))
sb.WriteString(fmt.Sprintf("%s:%v,", "NoColor", a.NoColor))
sb.WriteString(fmt.Sprintf("%s:%v,", "QueryStr", a.QueryStr))
sb.WriteString(fmt.Sprintf("%s:%v,", "Quiet", a.Quiet))
sb.WriteString(fmt.Sprintf("%s:%v,", "RegexInvert", a.RegexInvert))
sb.WriteString(fmt.Sprintf("%s:%v,", "RegexStr", a.RegexStr))
+ sb.WriteString(fmt.Sprintf("%s:%v,", "SSHAgentKeyIndex", a.SSHAgentKeyIndex))
sb.WriteString(fmt.Sprintf("%s:%v,", "SSHAuthMethods", a.SSHAuthMethods))
sb.WriteString(fmt.Sprintf("%s:%v,", "SSHBindAddress", a.SSHBindAddress))
sb.WriteString(fmt.Sprintf("%s:%v,", "SSHHostKeyCallback", a.SSHHostKeyCallback))
@@ -100,25 +111,53 @@ func (a *Args) SerializeOptions() string {
options["after"] = fmt.Sprintf("%d", a.LContext.AfterContext)
}
+ return serializeOptions(options)
+}
+
+func serializeOptions(options map[string]string) string {
+ if len(options) == 0 {
+ return ""
+ }
+
+ keys := make([]string, 0, len(options))
+ for k := range options {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
var sb strings.Builder
- var i int
- for k, v := range options {
+ for i, k := range keys {
if i > 0 {
sb.WriteString(":")
}
sb.WriteString(k)
sb.WriteString("=")
- sb.WriteString(v)
- i++
+ sb.WriteString(serializeOptionValue(options[k]))
}
return sb.String()
}
+func serializeOptionValue(value string) string {
+ if strings.ContainsAny(value, ":=|") || strings.HasPrefix(value, "base64%") {
+ return "base64%" + base64.StdEncoding.EncodeToString([]byte(value))
+ }
+
+ return value
+}
+
// DeserializeOptions deserializes the options, but into a map.
func DeserializeOptions(opts []string) (map[string]string, lcontext.LContext, error) {
options := make(map[string]string, len(opts))
var ltx lcontext.LContext
+ if len(opts) == 1 {
+ raw := strings.TrimSpace(opts[0])
+ if raw == "" {
+ return options, ltx, nil
+ }
+ opts = strings.Split(raw, ":")
+ }
+
for _, o := range opts {
kv := strings.SplitN(o, "=", 2)
if len(kv) != 2 {
diff --git a/internal/config/args_test.go b/internal/config/args_test.go
new file mode 100644
index 0000000..5e351a5
--- /dev/null
+++ b/internal/config/args_test.go
@@ -0,0 +1,95 @@
+package config
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/mimecast/dtail/internal/lcontext"
+)
+
+func TestSerializeOptionsUsesStableOrdering(t *testing.T) {
+ args := Args{
+ LContext: lcontextForTest(3, 1, 2),
+ Plain: true,
+ Quiet: true,
+ Serverless: true,
+ }
+
+ got := args.SerializeOptions()
+ want := "after=2:before=1:max=3:plain=true:quiet=true:serverless=true"
+ if got != want {
+ t.Fatalf("unexpected serialized options:\nwant %q\ngot %q", want, got)
+ }
+}
+
+func TestSerializeOptionsRoundTripsReservedValues(t *testing.T) {
+ unsafeValue := "a:b=c|d"
+ encodedValue := "base64%" + base64.StdEncoding.EncodeToString([]byte(unsafeValue))
+
+ got := serializeOptions(map[string]string{
+ "plain": "true",
+ "note": unsafeValue,
+ "quiet": "false",
+ })
+
+ want := strings.Join([]string{
+ "note=" + encodedValue,
+ "plain=true",
+ "quiet=false",
+ }, ":")
+ if got != want {
+ t.Fatalf("unexpected serialized options:\nwant %q\ngot %q", want, got)
+ }
+
+ options, ltx, err := DeserializeOptions(strings.Split(got, ":"))
+ if err != nil {
+ t.Fatalf("DeserializeOptions failed: %v", err)
+ }
+ if ltx != (lcontext.LContext{}) {
+ t.Fatalf("unexpected lcontext: %#v", ltx)
+ }
+ if options["note"] != unsafeValue {
+ t.Fatalf("expected note to round-trip, got %q", options["note"])
+ }
+ if options["plain"] != "true" {
+ t.Fatalf("expected plain to round-trip, got %q", options["plain"])
+ }
+ if options["quiet"] != "false" {
+ t.Fatalf("expected quiet to round-trip, got %q", options["quiet"])
+ }
+}
+
+func TestDeserializeOptionsAcceptsRawSerializedBlob(t *testing.T) {
+ unsafeValue := "a:b=c|d"
+ serialized := serializeOptions(map[string]string{
+ "note": unsafeValue,
+ "plain": "true",
+ "quiet": "false",
+ })
+
+ options, ltx, err := DeserializeOptions([]string{serialized})
+ if err != nil {
+ t.Fatalf("DeserializeOptions failed: %v", err)
+ }
+ if ltx != (lcontext.LContext{}) {
+ t.Fatalf("unexpected lcontext: %#v", ltx)
+ }
+ if options["note"] != unsafeValue {
+ t.Fatalf("expected note to round-trip, got %q", options["note"])
+ }
+ if options["plain"] != "true" {
+ t.Fatalf("expected plain to round-trip, got %q", options["plain"])
+ }
+ if options["quiet"] != "false" {
+ t.Fatalf("expected quiet to round-trip, got %q", options["quiet"])
+ }
+}
+
+func lcontextForTest(max, before, after int) lcontext.LContext {
+ return lcontext.LContext{
+ MaxCount: max,
+ BeforeContext: before,
+ AfterContext: after,
+ }
+}
diff --git a/internal/config/client.go b/internal/config/client.go
index 9f4df97..61051f7 100644
--- a/internal/config/client.go
+++ b/internal/config/client.go
@@ -1,6 +1,11 @@
package config
-import "github.com/mimecast/dtail/internal/color"
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/mimecast/dtail/internal/color"
+)
type remoteTermColors struct {
DelimiterAttr color.Attribute
@@ -104,12 +109,26 @@ type termColors struct {
type ClientConfig struct {
TermColorsEnable bool `json:",omitempty"`
TermColors termColors `json:",omitempty"`
+ AuthKeyPath string `json:",omitempty"`
+ AuthKeyDisable bool `json:",omitempty"`
+ // LogPayload opts in to teeing the full retrieved payload (the bulk
+ // dcat/dgrep/dtail output) into the daily client log file. Default false:
+ // the file keeps diagnostics/audit lines only, so a large read no longer
+ // grows the daily log by the full payload size. Payload always still goes
+ // to stdout/terminal regardless of this setting. Only affects the default
+ // "fout" logger (stdout+file); see docs for other loggers.
+ LogPayload bool `json:",omitempty"`
}
// Create a new default client configuration.
func newDefaultClientConfig() *ClientConfig {
return &ClientConfig{
TermColorsEnable: true,
+ AuthKeyPath: defaultAuthKeyPath(),
+ AuthKeyDisable: false,
+ // Default: diagnostics-only client log file. Opt in with --log-payload
+ // / Client.LogPayload to restore the legacy full-payload tee.
+ LogPayload: false,
TermColors: termColors{
Remote: remoteTermColors{
DelimiterAttr: color.AttrDim,
@@ -198,3 +217,23 @@ func newDefaultClientConfig() *ClientConfig {
},
}
}
+
+// defaultAuthKeyPath returns the default path for the SSH auth key based on
+// the user's home directory. It tries os.UserHomeDir() first, then falls back
+// to the HOME environment variable. If neither resolves to a non-empty path,
+// it returns "" so that callers can surface a clear error rather than silently
+// using a literal "~/.ssh/id_rsa" that the SSH stack cannot expand.
+func defaultAuthKeyPath() string {
+ homeDir, err := os.UserHomeDir()
+ if err != nil || homeDir == "" {
+ homeDir = os.Getenv("HOME")
+ }
+ if homeDir == "" {
+ // Return empty string; callers must check and emit a diagnostic
+ // (e.g. "set DTAIL_AUTH_KEY_PATH explicitly") rather than using a
+ // path that the SSH library will never find.
+ return ""
+ }
+
+ return filepath.Join(homeDir, ".ssh", "id_rsa")
+}
diff --git a/internal/config/common.go b/internal/config/common.go
index 7a72cfe..4e90f7e 100644
--- a/internal/config/common.go
+++ b/internal/config/common.go
@@ -4,6 +4,8 @@ package config
type CommonConfig struct {
// The SSH port number
SSHPort int
+ // SSH connection timeout in milliseconds.
+ SSHConnectTimeoutMs int `json:",omitempty"`
// Enable experimental features (mainly for dev purposes)
ExperimentalFeaturesEnable bool `json:",omitempty"`
// LogDir defines the log directory.
@@ -22,6 +24,7 @@ type CommonConfig struct {
func newDefaultCommonConfig() *CommonConfig {
return &CommonConfig{
SSHPort: DefaultSSHPort,
+ SSHConnectTimeoutMs: 2000,
ExperimentalFeaturesEnable: false,
LogDir: "log",
Logger: "stdout",
diff --git a/internal/config/config.go b/internal/config/config.go
index ee23829..0053c5b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,13 @@ package config
import "github.com/mimecast/dtail/internal/source"
const (
+ // DefaultMaxCommandFrameSize is the default maximum number of bytes that
+ // may be buffered between two ';' delimiters in the command protocol.
+ // Frames exceeding this limit cause the session to be closed immediately to
+ // prevent memory exhaustion. Individual server deployments may override this
+ // via ServerConfig.MaxCommandFrameSize.
+ DefaultMaxCommandFrameSize int = 1 << 20 // 1 MiB
+
// HealthUser is used for the health check
HealthUser string = "DTAIL-HEALTH"
// ScheduleUser is used for non-interactive scheduled mapreduce queries.
@@ -25,13 +32,13 @@ const (
DefaultHealthCheckLogger string = "none"
)
-// Client holds a DTail client configuration.
+// Client holds DTail client configuration.
var Client *ClientConfig
-// Server holds a DTail server configuration.
+// Server holds DTail server configuration.
var Server *ServerConfig
-// Common holds common configs of both both, client and server.
+// Common holds configuration common to both client and server.
var Common *CommonConfig
// Setup the DTail configuration.
diff --git a/internal/config/env.go b/internal/config/env.go
index 1ccac9c..2eff7b0 100644
--- a/internal/config/env.go
+++ b/internal/config/env.go
@@ -9,10 +9,20 @@ func Env(env string) bool {
// Hostname returns the current hostname. It can be overriden with
// DTAIL_HOSTNAME_OVERRIDE environment variable (useful for integration tests).
+// When DTAIL_INTEGRATION_TEST_RUN_MODE is set to "yes", it automatically
+// returns "integrationtest" as the hostname.
func Hostname() (string, error) {
+ // Check if we're in integration test mode
+ if Env("DTAIL_INTEGRATION_TEST_RUN_MODE") {
+ return "integrationtest", nil
+ }
+
+ // Check for manual hostname override
hostname := os.Getenv("DTAIL_HOSTNAME_OVERRIDE")
if len(hostname) > 0 {
return hostname, nil
}
+
+ // Return actual hostname
return os.Hostname()
}
diff --git a/internal/config/initializer.go b/internal/config/initializer.go
index 9c3bf64..ba62aa4 100644
--- a/internal/config/initializer.go
+++ b/internal/config/initializer.go
@@ -29,17 +29,25 @@ func (in *initializer) parseConfig(args *Args) error {
return in.parseSpecificConfig(args.ConfigFile)
}
- if homeDir, err := os.UserHomeDir(); err != nil {
- var paths []string
- paths = append(paths, fmt.Sprintf("%s/.config/dtail/dtail.conf", homeDir))
- paths = append(paths, fmt.Sprintf("%s/.dtail.conf", homeDir))
+ homeDir, err := os.UserHomeDir()
+ if err == nil && homeDir != "" {
+ // Search candidate paths in priority order. The first existing file
+ // wins: ~/.config/dtail/dtail.conf takes precedence over ~/.dtail.conf.
+ // Loading both would silently merge scalar fields (later-file wins),
+ // which is surprising and hard to debug.
+ paths := []string{
+ fmt.Sprintf("%s/.config/dtail/dtail.conf", homeDir),
+ fmt.Sprintf("%s/.dtail.conf", homeDir),
+ }
for _, configPath := range paths {
- if _, err := os.Stat(configPath); os.IsNotExist(err) {
- continue
- }
- if err := in.parseSpecificConfig(configPath); err != nil {
+ if _, err := os.Stat(configPath); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
return err
}
+ // Stop after loading the first file that exists.
+ return in.parseSpecificConfig(configPath)
}
}
@@ -88,10 +96,39 @@ func (in *initializer) processEnvVars(args *Args) {
os.Setenv("DTAIL_HOSTNAME_OVERRIDE", "integrationtest")
in.Server.MaxLineLength = 1024
}
- sshPrivateKeyPathFile := os.Getenv("DTAIL_SSH_PRIVATE_KEYFILE_PATH")
- if len(sshPrivateKeyPathFile) > 0 && args.SSHPrivateKeyFilePath == "" {
- args.SSHPrivateKeyFilePath = sshPrivateKeyPathFile
+
+ // Resolve SSH private key path from environment variables.
+ // DTAIL_AUTH_KEY_PATH is the documented alias and takes precedence.
+ // DTAIL_SSH_PRIVATE_KEYFILE_PATH is the legacy name and is only used when
+ // DTAIL_AUTH_KEY_PATH is not set, so that the documented env var always wins.
+ // Neither env var overrides an explicitly supplied CLI flag value.
+ args.SSHPrivateKeyFilePath = resolveSSHKeyPath(
+ args.SSHPrivateKeyFilePath,
+ os.Getenv("DTAIL_AUTH_KEY_PATH"),
+ os.Getenv("DTAIL_SSH_PRIVATE_KEYFILE_PATH"),
+ )
+
+ // Note: the direct-output read/aggregate path is now the one and only runtime
+ // path. The historical disable toggle (a former env var and its matching
+ // server config field) no longer exists and is not read here. Old configs
+ // that still set that JSON key, or callers that still export the old env var,
+ // keep working: unknown JSON keys are silently ignored by the lenient decoder
+ // and an unread env var has no effect.
+}
+
+// resolveSSHKeyPath returns the effective SSH private key file path, applying
+// the following precedence (highest to lowest):
+// 1. cliValue — an explicit flag value supplied by the user
+// 2. authKeyEnv — DTAIL_AUTH_KEY_PATH (the documented alias)
+// 3. legacyEnv — DTAIL_SSH_PRIVATE_KEYFILE_PATH (the legacy name)
+func resolveSSHKeyPath(cliValue, authKeyEnv, legacyEnv string) string {
+ if cliValue != "" {
+ return cliValue
+ }
+ if authKeyEnv != "" {
+ return authKeyEnv
}
+ return legacyEnv
}
func (in *initializer) setupConfig(sourceCb transformCb, args *Args,
@@ -108,12 +145,40 @@ func (in *initializer) setupConfig(sourceCb transformCb, args *Args,
if args.NoColor {
in.Client.TermColorsEnable = false
}
+ if args.NoAuthKey {
+ in.Client.AuthKeyDisable = true
+ }
+ if in.Client.AuthKeyDisable {
+ args.NoAuthKey = true
+ }
+ if args.SSHPrivateKeyFilePath == "" {
+ args.SSHPrivateKeyFilePath = in.Client.AuthKeyPath
+ }
+ if args.SSHPrivateKeyFilePath != "" {
+ in.Client.AuthKeyPath = args.SSHPrivateKeyFilePath
+ }
+ // Warn early when the auth-key path cannot be determined and auth-key is
+ // still enabled. The SSH stack does not expand '~', so a literal path
+ // would silently fail later; warning here points the operator at the fix.
+ if !in.Client.AuthKeyDisable && in.Client.AuthKeyPath == "" {
+ fmt.Fprintf(os.Stderr,
+ "WARN: cannot determine home directory; auth-key fast reconnect disabled. "+
+ "Set DTAIL_AUTH_KEY_PATH explicitly to re-enable it.\n")
+ in.Client.AuthKeyDisable = true
+ args.NoAuthKey = true
+ }
if args.LogDir != "" {
in.Common.LogDir = args.LogDir
}
if args.Logger != "" {
in.Common.Logger = args.Logger
}
+ // Opt in to teeing retrieved payload into the client log file. The flag can
+ // only turn it on; a config-file value (Client.LogPayload) is preserved when
+ // the flag is not given, since the flag defaults to false.
+ if args.LogPayload {
+ in.Client.LogPayload = true
+ }
if args.ConnectionsPerCPU == 0 {
args.ConnectionsPerCPU = DefaultConnectionsPerCPU
}
diff --git a/internal/config/initializer_test.go b/internal/config/initializer_test.go
new file mode 100644
index 0000000..12adf57
--- /dev/null
+++ b/internal/config/initializer_test.go
@@ -0,0 +1,260 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestParseConfigLoadsDefaultXDGConfig(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ configPath := filepath.Join(home, ".config", "dtail", "dtail.conf")
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
+ t.Fatalf("mkdir failed: %v", err)
+ }
+ writeTestConfig(t, configPath, `{"Common":{"LogLevel":"debug"}}`)
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+
+ if err := in.parseConfig(&Args{}); err != nil {
+ t.Fatalf("parseConfig failed: %v", err)
+ }
+ if in.Common.LogLevel != "debug" {
+ t.Fatalf("expected log level debug, got %q", in.Common.LogLevel)
+ }
+}
+
+// TestParseConfigFirstWins verifies that when both candidate config files
+// exist, the XDG path (~/.config/dtail/dtail.conf) takes precedence and the
+// second file (~/.dtail.conf) is ignored entirely — no silent merging.
+func TestParseConfigFirstWins(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ xdgPath := filepath.Join(home, ".config", "dtail", "dtail.conf")
+ if err := os.MkdirAll(filepath.Dir(xdgPath), 0o755); err != nil {
+ t.Fatalf("mkdir failed: %v", err)
+ }
+ writeTestConfig(t, xdgPath, `{"Common":{"LogLevel":"warn"}}`)
+
+ homePath := filepath.Join(home, ".dtail.conf")
+ // The second file would override LogLevel to "error" if merging occurred.
+ writeTestConfig(t, homePath, `{"Common":{"LogLevel":"error"}}`)
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+
+ if err := in.parseConfig(&Args{}); err != nil {
+ t.Fatalf("parseConfig failed: %v", err)
+ }
+ // First-wins: the XDG config must have set the level; the home config
+ // must have been skipped, so "error" must NOT appear.
+ if in.Common.LogLevel != "warn" {
+ t.Fatalf("expected log level warn (first file wins), got %q", in.Common.LogLevel)
+ }
+}
+
+// TestParseConfigFallsBackToHomeConfig verifies that when only the legacy
+// ~/.dtail.conf exists it is loaded as the effective configuration.
+func TestParseConfigFallsBackToHomeConfig(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ // Only create the fallback file; the XDG directory does not exist.
+ homePath := filepath.Join(home, ".dtail.conf")
+ writeTestConfig(t, homePath, `{"Common":{"LogLevel":"error"}}`)
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+
+ if err := in.parseConfig(&Args{}); err != nil {
+ t.Fatalf("parseConfig failed: %v", err)
+ }
+ if in.Common.LogLevel != "error" {
+ t.Fatalf("expected log level error from fallback config, got %q", in.Common.LogLevel)
+ }
+}
+
+// TestParseConfigNoConfigFile verifies that parseConfig succeeds without
+// error when neither candidate config file is present.
+func TestParseConfigNoConfigFile(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+
+ // No config files created; must return nil, not an error.
+ if err := in.parseConfig(&Args{}); err != nil {
+ t.Fatalf("expected no error when no config file exists, got: %v", err)
+ }
+}
+
+// TestResolveSSHKeyPath verifies the three-level precedence used when
+// resolving the effective SSH private key path.
+func TestResolveSSHKeyPath(t *testing.T) {
+ tests := []struct {
+ name string
+ cli string
+ authKey string
+ legacy string
+ expected string
+ }{
+ {
+ name: "cli flag wins over both env vars",
+ cli: "/cli/key",
+ authKey: "/auth/key",
+ legacy: "/legacy/key",
+ expected: "/cli/key",
+ },
+ {
+ name: "DTAIL_AUTH_KEY_PATH wins over legacy when cli is empty",
+ cli: "",
+ authKey: "/auth/key",
+ legacy: "/legacy/key",
+ expected: "/auth/key",
+ },
+ {
+ name: "DTAIL_SSH_PRIVATE_KEYFILE_PATH used when auth key env is also empty",
+ cli: "",
+ authKey: "",
+ legacy: "/legacy/key",
+ expected: "/legacy/key",
+ },
+ {
+ name: "all empty returns empty string",
+ cli: "",
+ authKey: "",
+ legacy: "",
+ expected: "",
+ },
+ {
+ name: "cli wins when only cli is set",
+ cli: "/cli/key",
+ authKey: "",
+ legacy: "",
+ expected: "/cli/key",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := resolveSSHKeyPath(tc.cli, tc.authKey, tc.legacy)
+ if got != tc.expected {
+ t.Fatalf("resolveSSHKeyPath(%q, %q, %q) = %q; want %q",
+ tc.cli, tc.authKey, tc.legacy, got, tc.expected)
+ }
+ })
+ }
+}
+
+// TestProcessEnvVarsAuthKeyPathTakesPrecedence is a negative/regression test
+// that confirms the bug described in task k6 is fixed: when both
+// DTAIL_AUTH_KEY_PATH and DTAIL_SSH_PRIVATE_KEYFILE_PATH are set,
+// DTAIL_AUTH_KEY_PATH must win.
+func TestProcessEnvVarsAuthKeyPathTakesPrecedence(t *testing.T) {
+ t.Setenv("DTAIL_AUTH_KEY_PATH", "/env/auth/key")
+ t.Setenv("DTAIL_SSH_PRIVATE_KEYFILE_PATH", "/env/legacy/key")
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+ args := &Args{}
+ in.processEnvVars(args)
+
+ if args.SSHPrivateKeyFilePath != "/env/auth/key" {
+ t.Fatalf("expected DTAIL_AUTH_KEY_PATH to win, got %q", args.SSHPrivateKeyFilePath)
+ }
+}
+
+// TestProcessEnvVarsLegacyFallback verifies that DTAIL_SSH_PRIVATE_KEYFILE_PATH
+// is still applied when DTAIL_AUTH_KEY_PATH is not set.
+func TestProcessEnvVarsLegacyFallback(t *testing.T) {
+ t.Setenv("DTAIL_AUTH_KEY_PATH", "")
+ t.Setenv("DTAIL_SSH_PRIVATE_KEYFILE_PATH", "/env/legacy/key")
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+ args := &Args{}
+ in.processEnvVars(args)
+
+ if args.SSHPrivateKeyFilePath != "/env/legacy/key" {
+ t.Fatalf("expected legacy env var to be used, got %q", args.SSHPrivateKeyFilePath)
+ }
+}
+
+// TestProcessEnvVarsCLIFlagNotOverridden verifies that an explicit CLI flag
+// value is not overridden by either environment variable.
+func TestProcessEnvVarsCLIFlagNotOverridden(t *testing.T) {
+ t.Setenv("DTAIL_AUTH_KEY_PATH", "/env/auth/key")
+ t.Setenv("DTAIL_SSH_PRIVATE_KEYFILE_PATH", "/env/legacy/key")
+
+ in := initializer{
+ Common: newDefaultCommonConfig(),
+ Server: newDefaultServerConfig(),
+ Client: newDefaultClientConfig(),
+ }
+ args := &Args{SSHPrivateKeyFilePath: "/cli/explicit/key"}
+ in.processEnvVars(args)
+
+ if args.SSHPrivateKeyFilePath != "/cli/explicit/key" {
+ t.Fatalf("expected CLI flag to be preserved, got %q", args.SSHPrivateKeyFilePath)
+ }
+}
+
+// TestDefaultAuthKeyPathNoLiteralTilde is a regression test for the bug
+// described in task l6: when neither os.UserHomeDir() nor the HOME environment
+// variable can be resolved, defaultAuthKeyPath must return "" rather than the
+// literal string "~/.ssh/id_rsa" which the SSH library cannot expand.
+func TestDefaultAuthKeyPathNoLiteralTilde(t *testing.T) {
+ // Unset HOME so that os.UserHomeDir() fails and the fallback env var is
+ // also empty. t.Setenv restores the original value after the test.
+ t.Setenv("HOME", "")
+
+ got := defaultAuthKeyPath()
+ if got == "~/.ssh/id_rsa" {
+ t.Fatal("defaultAuthKeyPath returned literal '~/.ssh/id_rsa' when HOME is empty; expected \"\"")
+ }
+ if got != "" {
+ t.Fatalf("defaultAuthKeyPath returned %q when HOME is empty; expected \"\"", got)
+ }
+}
+
+// TestDefaultAuthKeyPathWithHome verifies that when HOME is set, the returned
+// path is an absolute path constructed with filepath.Join (no literal '~').
+func TestDefaultAuthKeyPathWithHome(t *testing.T) {
+ t.Setenv("HOME", "/home/testuser")
+
+ got := defaultAuthKeyPath()
+ want := "/home/testuser/.ssh/id_rsa"
+ if got != want {
+ t.Fatalf("defaultAuthKeyPath() = %q; want %q", got, want)
+ }
+}
+
+func writeTestConfig(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write config failed: %v", err)
+ }
+}
diff --git a/internal/config/runtime.go b/internal/config/runtime.go
new file mode 100644
index 0000000..1e19265
--- /dev/null
+++ b/internal/config/runtime.go
@@ -0,0 +1,18 @@
+package config
+
+// RuntimeConfig contains the active runtime configuration for a process.
+// It is intended to be injected into components instead of relying on package globals.
+type RuntimeConfig struct {
+ Client *ClientConfig
+ Server *ServerConfig
+ Common *CommonConfig
+}
+
+// CurrentRuntime returns the currently initialized runtime configuration.
+func CurrentRuntime() RuntimeConfig {
+ return RuntimeConfig{
+ Client: Client,
+ Server: Server,
+ Common: Common,
+ }
+}
diff --git a/internal/config/server.go b/internal/config/server.go
index cb9ca2b..b967103 100644
--- a/internal/config/server.go
+++ b/internal/config/server.go
@@ -67,6 +67,50 @@ type ServerConfig struct {
Ciphers []string `json:",omitempty"`
// The allowed MAC algorithms.
MACs []string `json:",omitempty"`
+ // Enable in-memory auth-key registration and fast reconnect.
+ AuthKeyEnabled bool `json:",omitempty"`
+ // Auth-key cache entry TTL in seconds.
+ AuthKeyTTLSeconds int `json:",omitempty"`
+ // Maximum number of cached auth keys per user.
+ AuthKeyMaxPerUser int `json:",omitempty"`
+ // Retry interval for glob retries in milliseconds.
+ ReadGlobRetryIntervalMs int `json:",omitempty"`
+ // Retry interval for re-reading in tail/cat loops in milliseconds.
+ ReadRetryIntervalMs int `json:",omitempty"`
+ // Delay after output processor flush/close to allow data transmission, in milliseconds.
+ OutputTransmissionDelayMs int `json:",omitempty"`
+ // Output EOF wait base duration in milliseconds.
+ OutputEOFWaitBaseMs int `json:",omitempty"`
+ // Output EOF wait per-file duration in milliseconds.
+ OutputEOFWaitPerFileMs int `json:",omitempty"`
+ // Maximum output EOF wait duration in milliseconds.
+ OutputEOFWaitMaxMs int `json:",omitempty"`
+ // Output channel buffer size.
+ OutputChannelBufferSize int `json:",omitempty"`
+ // Output channel flush timeout in milliseconds.
+ OutputFlushTimeoutMs int `json:",omitempty"`
+ // Output channel flush poll interval in milliseconds.
+ OutputFlushPollIntervalMs int `json:",omitempty"`
+ // Output read retry interval in milliseconds when data is expected but not yet available.
+ OutputReadRetryIntervalMs int `json:",omitempty"`
+ // Maximum time to wait for output EOF acknowledgement after signaling EOF, in milliseconds.
+ OutputEOFAckTimeoutMs int `json:",omitempty"`
+ // Wait for aggregate serialization during shutdown in milliseconds.
+ ShutdownOutputSerializeWaitMs int `json:",omitempty"`
+ // Final idle recheck wait before shutdown in milliseconds.
+ ShutdownIdleRecheckWaitMs int `json:",omitempty"`
+ // Maximum size in bytes of a single command frame (bytes accumulated between
+ // ';' delimiters). Frames that grow beyond this limit are rejected and the
+ // session is closed to prevent unbounded memory exhaustion by a malicious or
+ // misbehaving client. Default is 1 MiB.
+ MaxCommandFrameSize int `json:",omitempty"`
+ // Maximum number of glob expansion targets (file paths) that a single read
+ // command is allowed to dispatch. When a glob pattern expands to more paths
+ // than this limit, the excess paths are dropped and a warning is sent to the
+ // client. This prevents an authenticated user with broad read permission from
+ // spawning unbounded goroutines and exhausting server memory/CPU.
+ // Default is 1000. Set to 0 to keep the built-in default.
+ MaxGlobTargets int `json:",omitempty"`
}
// Create a new default server configuration.
@@ -85,13 +129,42 @@ func newDefaultServerConfig() *ServerConfig {
Permissions: Permissions{
Default: defaultPermissions,
},
+ AuthKeyEnabled: true,
+ AuthKeyTTLSeconds: 86400,
+ AuthKeyMaxPerUser: 5,
+ ReadGlobRetryIntervalMs: 5000,
+ ReadRetryIntervalMs: 2000,
+ OutputTransmissionDelayMs: 50,
+ OutputEOFWaitBaseMs: 500,
+ OutputEOFWaitPerFileMs: 10,
+ OutputEOFWaitMaxMs: 2000,
+ OutputChannelBufferSize: 1000,
+ OutputFlushTimeoutMs: 2000,
+ OutputFlushPollIntervalMs: 10,
+ OutputReadRetryIntervalMs: 1,
+ OutputEOFAckTimeoutMs: 2000,
+ ShutdownOutputSerializeWaitMs: 500,
+ ShutdownIdleRecheckWaitMs: 10,
+ MaxCommandFrameSize: DefaultMaxCommandFrameSize,
+ MaxGlobTargets: 1000,
}
}
-// ServerUserPermissions retrieves the permission set of a given user.
-func ServerUserPermissions(userName string) (permissions []string, err error) {
- permissions = Server.Permissions.Default
- if p, ok := Server.Permissions.Users[userName]; ok {
+// NewDefaultServerConfigForTest returns a fresh ServerConfig populated with all
+// default values. It is intended for use in unit tests that need to inspect or
+// compare default configuration without running the full config initializer.
+func NewDefaultServerConfigForTest() *ServerConfig {
+ return newDefaultServerConfig()
+}
+
+// UserPermissions retrieves the permission set of a given user.
+func (c *ServerConfig) UserPermissions(userName string) (permissions []string, err error) {
+ if c == nil {
+ return nil, errors.New("missing server config")
+ }
+
+ permissions = c.Permissions.Default
+ if p, ok := c.Permissions.Users[userName]; ok {
permissions = p
}
if len(permissions) == 0 {
@@ -99,3 +172,11 @@ func ServerUserPermissions(userName string) (permissions []string, err error) {
}
return
}
+
+// ServerUserPermissions retrieves the permission set of a given user.
+func ServerUserPermissions(userName string) (permissions []string, err error) {
+ if Server == nil {
+ return nil, errors.New("missing server config")
+ }
+ return Server.UserPermissions(userName)
+}