diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-22 23:51:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-22 23:51:18 +0300 |
| commit | 849951be1d1a7ee9f9302006ccb187bf5b4e36f3 (patch) | |
| tree | 496c924a03a9ea6212e29bb4699e268066ebad81 /internal/config | |
| parent | bf78b3abffee6d49c08ca2980156afc455994969 (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.go | 47 | ||||
| -rw-r--r-- | internal/config/args_test.go | 95 | ||||
| -rw-r--r-- | internal/config/client.go | 41 | ||||
| -rw-r--r-- | internal/config/common.go | 3 | ||||
| -rw-r--r-- | internal/config/config.go | 13 | ||||
| -rw-r--r-- | internal/config/env.go | 10 | ||||
| -rw-r--r-- | internal/config/initializer.go | 87 | ||||
| -rw-r--r-- | internal/config/initializer_test.go | 260 | ||||
| -rw-r--r-- | internal/config/runtime.go | 18 | ||||
| -rw-r--r-- | internal/config/server.go | 89 |
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) +} |
