diff --git a/.gitignore b/.gitignore
index e66eb7121f..c2a6a252ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -119,3 +119,4 @@ website/components/node_modules
.buildcache/
.releaser/
+*.log
diff --git a/changelog/17841.txt b/changelog/17841.txt
new file mode 100644
index 0000000000..444df41fc8
--- /dev/null
+++ b/changelog/17841.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+logging: Vault Agent supports logging to a specified file path via environment variable, CLI or config
+```
\ No newline at end of file
diff --git a/command/agent.go b/command/agent.go
index b90665683c..9c7032c77d 100644
--- a/command/agent.go
+++ b/command/agent.go
@@ -41,11 +41,11 @@ import (
"github.com/hashicorp/vault/command/agent/sink/inmem"
"github.com/hashicorp/vault/command/agent/template"
"github.com/hashicorp/vault/command/agent/winsvc"
+ "github.com/hashicorp/vault/helper/logging"
"github.com/hashicorp/vault/helper/metricsutil"
"github.com/hashicorp/vault/internalshared/configutil"
"github.com/hashicorp/vault/internalshared/listenerutil"
"github.com/hashicorp/vault/sdk/helper/consts"
- "github.com/hashicorp/vault/sdk/helper/logging"
"github.com/hashicorp/vault/sdk/helper/useragent"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/sdk/version"
@@ -61,6 +61,12 @@ var (
_ cli.CommandAutocomplete = (*AgentCommand)(nil)
)
+const (
+ // flagNameAgentExitAfterAuth is used as an Agent specific flag to indicate
+ // that agent should exit after a single successful auth
+ flagNameAgentExitAfterAuth = "exit-after-auth"
+)
+
type AgentCommand struct {
*BaseCommand
@@ -80,6 +86,7 @@ type AgentCommand struct {
flagConfigs []string
flagLogLevel string
+ flagLogFile string
flagExitAfterAuth bool
flagTestVerifyOnly bool
@@ -124,17 +131,24 @@ func (c *AgentCommand) Flags() *FlagSets {
})
f.StringVar(&StringVar{
- Name: "log-level",
+ Name: flagNameLogLevel,
Target: &c.flagLogLevel,
Default: "info",
- EnvVar: "VAULT_LOG_LEVEL",
+ EnvVar: EnvVaultLogLevel,
Completion: complete.PredictSet("trace", "debug", "info", "warn", "error"),
Usage: "Log verbosity level. Supported values (in order of detail) are " +
"\"trace\", \"debug\", \"info\", \"warn\", and \"error\".",
})
+ f.StringVar(&StringVar{
+ Name: flagNameLogFile,
+ Target: &c.flagLogFile,
+ EnvVar: EnvVaultLogFile,
+ Usage: "Path to the log file that Vault should use for logging",
+ })
+
f.BoolVar(&BoolVar{
- Name: "exit-after-auth",
+ Name: flagNameAgentExitAfterAuth,
Target: &c.flagExitAfterAuth,
Default: false,
Usage: "If set to true, the agent will exit with code 0 after a single " +
@@ -193,27 +207,6 @@ func (c *AgentCommand) Run(args []string) int {
if c.flagCombineLogs {
c.logWriter = os.Stdout
}
- var level log.Level
- c.flagLogLevel = strings.ToLower(strings.TrimSpace(c.flagLogLevel))
- switch c.flagLogLevel {
- case "trace":
- level = log.Trace
- case "debug":
- level = log.Debug
- case "notice", "info", "":
- level = log.Info
- case "warn", "warning":
- level = log.Warn
- case "err", "error":
- level = log.Error
- default:
- c.UI.Error(fmt.Sprintf("Unknown log level: %s", c.flagLogLevel))
- return 1
- }
-
- if c.logger == nil {
- c.logger = logging.NewVaultLoggerWithWriter(c.logWriter, level)
- }
// Validation
if len(c.flagConfigs) != 1 {
@@ -221,7 +214,7 @@ func (c *AgentCommand) Run(args []string) int {
return 1
}
- // Load the configuration
+ // Load the configuration file
config, err := agentConfig.LoadConfig(c.flagConfigs[0])
if err != nil {
c.UI.Error(fmt.Sprintf("Error loading configuration from %s: %s", c.flagConfigs[0], err))
@@ -235,6 +228,7 @@ func (c *AgentCommand) Run(args []string) int {
"-config flag."))
return 1
}
+
if config.AutoAuth == nil && config.Cache == nil {
c.UI.Error("No auto_auth or cache block found in config file")
return 1
@@ -243,62 +237,29 @@ func (c *AgentCommand) Run(args []string) int {
c.UI.Info("No auto_auth block found in config file, not starting automatic authentication feature")
}
- exitAfterAuth := config.ExitAfterAuth
- f.Visit(func(fl *flag.Flag) {
- if fl.Name == "exit-after-auth" {
- exitAfterAuth = c.flagExitAfterAuth
- }
- })
+ config = c.aggregateConfig(f, config)
- c.setStringFlag(f, config.Vault.Address, &StringVar{
- Name: flagNameAddress,
- Target: &c.flagAddress,
- Default: "https://127.0.0.1:8200",
- EnvVar: api.EnvVaultAddress,
- })
- config.Vault.Address = c.flagAddress
- c.setStringFlag(f, config.Vault.CACert, &StringVar{
- Name: flagNameCACert,
- Target: &c.flagCACert,
- Default: "",
- EnvVar: api.EnvVaultCACert,
- })
- config.Vault.CACert = c.flagCACert
- c.setStringFlag(f, config.Vault.CAPath, &StringVar{
- Name: flagNameCAPath,
- Target: &c.flagCAPath,
- Default: "",
- EnvVar: api.EnvVaultCAPath,
- })
- config.Vault.CAPath = c.flagCAPath
- c.setStringFlag(f, config.Vault.ClientCert, &StringVar{
- Name: flagNameClientCert,
- Target: &c.flagClientCert,
- Default: "",
- EnvVar: api.EnvVaultClientCert,
- })
- config.Vault.ClientCert = c.flagClientCert
- c.setStringFlag(f, config.Vault.ClientKey, &StringVar{
- Name: flagNameClientKey,
- Target: &c.flagClientKey,
- Default: "",
- EnvVar: api.EnvVaultClientKey,
- })
- config.Vault.ClientKey = c.flagClientKey
- c.setBoolFlag(f, config.Vault.TLSSkipVerify, &BoolVar{
- Name: flagNameTLSSkipVerify,
- Target: &c.flagTLSSkipVerify,
- Default: false,
- EnvVar: api.EnvVaultSkipVerify,
- })
- config.Vault.TLSSkipVerify = c.flagTLSSkipVerify
- c.setStringFlag(f, config.Vault.TLSServerName, &StringVar{
- Name: flagTLSServerName,
- Target: &c.flagTLSServerName,
- Default: "",
- EnvVar: api.EnvVaultTLSServerName,
- })
- config.Vault.TLSServerName = c.flagTLSServerName
+ // Build the logger using level, format and path
+ logLevel, err := logging.ParseLogLevel(config.LogLevel)
+ if err != nil {
+ c.UI.Error(err.Error())
+ return 1
+ }
+
+ logFormat, err := logging.ParseLogFormat(config.LogFormat)
+ if err != nil {
+ c.UI.Error(err.Error())
+ return 1
+ }
+
+ logCfg := logging.NewLogConfig("agent", logLevel, logFormat, config.LogFile)
+ l, err := logging.Setup(logCfg, c.logWriter)
+ if err != nil {
+ c.UI.Error(err.Error())
+ return 1
+ }
+
+ c.logger = l
infoKeys := make([]string, 0, 10)
info := make(map[string]string)
@@ -855,16 +816,16 @@ func (c *AgentCommand) Run(args []string) int {
ss := sink.NewSinkServer(&sink.SinkServerConfig{
Logger: c.logger.Named("sink.server"),
Client: ahClient,
- ExitAfterAuth: exitAfterAuth,
+ ExitAfterAuth: config.ExitAfterAuth,
})
ts := template.NewServer(&template.ServerConfig{
Logger: c.logger.Named("template.server"),
- LogLevel: level,
+ LogLevel: logLevel,
LogWriter: c.logWriter,
AgentConfig: config,
Namespace: templateNamespace,
- ExitAfterAuth: exitAfterAuth,
+ ExitAfterAuth: config.ExitAfterAuth,
})
g.Add(func() error {
@@ -963,6 +924,84 @@ func (c *AgentCommand) Run(args []string) int {
return 0
}
+// aggregateConfig ensures that the config object accurately reflects the desired
+// settings as configured by the user. It applies the relevant config setting based
+// on the precedence (env var overrides file config, cli overrides env var).
+// It mutates the config object supplied and returns the updated object.
+func (c *AgentCommand) aggregateConfig(f *FlagSets, config *agentConfig.Config) *agentConfig.Config {
+ f.Visit(func(fl *flag.Flag) {
+ if fl.Name == flagNameAgentExitAfterAuth {
+ config.ExitAfterAuth = c.flagExitAfterAuth
+ }
+ })
+
+ c.setStringFlag(f, config.LogFile, &StringVar{
+ Name: flagNameLogFile,
+ EnvVar: EnvVaultLogFile,
+ Target: &c.flagLogFile,
+ })
+ config.LogFile = c.flagLogFile
+
+ c.setStringFlag(f, config.LogLevel, &StringVar{
+ Name: flagNameLogLevel,
+ EnvVar: EnvVaultLogLevel,
+ Target: &c.flagLogLevel,
+ })
+ config.LogLevel = c.flagLogLevel
+
+ c.setStringFlag(f, config.Vault.Address, &StringVar{
+ Name: flagNameAddress,
+ Target: &c.flagAddress,
+ Default: "https://127.0.0.1:8200",
+ EnvVar: api.EnvVaultAddress,
+ })
+ config.Vault.Address = c.flagAddress
+ c.setStringFlag(f, config.Vault.CACert, &StringVar{
+ Name: flagNameCACert,
+ Target: &c.flagCACert,
+ Default: "",
+ EnvVar: api.EnvVaultCACert,
+ })
+ config.Vault.CACert = c.flagCACert
+ c.setStringFlag(f, config.Vault.CAPath, &StringVar{
+ Name: flagNameCAPath,
+ Target: &c.flagCAPath,
+ Default: "",
+ EnvVar: api.EnvVaultCAPath,
+ })
+ config.Vault.CAPath = c.flagCAPath
+ c.setStringFlag(f, config.Vault.ClientCert, &StringVar{
+ Name: flagNameClientCert,
+ Target: &c.flagClientCert,
+ Default: "",
+ EnvVar: api.EnvVaultClientCert,
+ })
+ config.Vault.ClientCert = c.flagClientCert
+ c.setStringFlag(f, config.Vault.ClientKey, &StringVar{
+ Name: flagNameClientKey,
+ Target: &c.flagClientKey,
+ Default: "",
+ EnvVar: api.EnvVaultClientKey,
+ })
+ config.Vault.ClientKey = c.flagClientKey
+ c.setBoolFlag(f, config.Vault.TLSSkipVerify, &BoolVar{
+ Name: flagNameTLSSkipVerify,
+ Target: &c.flagTLSSkipVerify,
+ Default: false,
+ EnvVar: api.EnvVaultSkipVerify,
+ })
+ config.Vault.TLSSkipVerify = c.flagTLSSkipVerify
+ c.setStringFlag(f, config.Vault.TLSServerName, &StringVar{
+ Name: flagTLSServerName,
+ Target: &c.flagTLSServerName,
+ Default: "",
+ EnvVar: api.EnvVaultTLSServerName,
+ })
+ config.Vault.TLSServerName = c.flagTLSServerName
+
+ return config
+}
+
// verifyRequestHeader wraps an http.Handler inside a Handler that checks for
// the request header that is used for SSRF protection.
func verifyRequestHeader(handler http.Handler) http.Handler {
diff --git a/command/agent/config/config.go b/command/agent/config/config.go
index b32772564b..3d8b96d3e0 100644
--- a/command/agent/config/config.go
+++ b/command/agent/config/config.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
- "io/ioutil"
"net"
"os"
"strings"
@@ -38,6 +37,7 @@ type Config struct {
DisableKeepAlivesCaching bool `hcl:"-"`
DisableKeepAlivesTemplating bool `hcl:"-"`
DisableKeepAlivesAutoAuth bool `hcl:"-"`
+ LogFile string `hcl:"log_file"`
}
const (
@@ -173,7 +173,7 @@ func LoadConfig(path string) (*Config, error) {
}
// Read the file
- d, err := ioutil.ReadFile(path)
+ d, err := os.ReadFile(path)
if err != nil {
return nil, err
}
diff --git a/command/agent/config/config_test.go b/command/agent/config/config_test.go
index 16816f85e7..35a2ff8f15 100644
--- a/command/agent/config/config_test.go
+++ b/command/agent/config/config_test.go
@@ -230,6 +230,7 @@ func TestLoadConfigFile(t *testing.T) {
NumRetries: 12,
},
},
+ LogFile: "/var/log/vault/vault-agent.log",
}
config.Prune()
diff --git a/command/agent/config/test-fixtures/config-embedded-type.hcl b/command/agent/config/test-fixtures/config-embedded-type.hcl
index 919bfd9077..4e6dc41f46 100644
--- a/command/agent/config/test-fixtures/config-embedded-type.hcl
+++ b/command/agent/config/test-fixtures/config-embedded-type.hcl
@@ -1,4 +1,5 @@
pid_file = "./pidfile"
+log_file = "/var/log/vault/vault-agent.log"
auto_auth {
method "aws" {
diff --git a/command/agent/config/test-fixtures/config.hcl b/command/agent/config/test-fixtures/config.hcl
index b02170736a..ecfb88ae05 100644
--- a/command/agent/config/test-fixtures/config.hcl
+++ b/command/agent/config/test-fixtures/config.hcl
@@ -1,4 +1,5 @@
pid_file = "./pidfile"
+log_file = "/var/log/vault/vault-agent.log"
auto_auth {
method {
diff --git a/command/agent_test.go b/command/agent_test.go
index c5138144ef..e11f452a49 100644
--- a/command/agent_test.go
+++ b/command/agent_test.go
@@ -20,6 +20,7 @@ import (
"github.com/hashicorp/vault/api"
credAppRole "github.com/hashicorp/vault/builtin/credential/approle"
"github.com/hashicorp/vault/command/agent"
+ agentConfig "github.com/hashicorp/vault/command/agent/config"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/helper/logging"
@@ -27,9 +28,26 @@ import (
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
"github.com/mitchellh/cli"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+const (
+ BasicHclConfig = `
+log_file = "/foo/bar/juan.log"
+vault {
+ address = "http://127.0.0.1:8200"
+ retry {
+ num_retries = 5
+ }
+}
+
+listener "tcp" {
+ address = "127.0.0.1:8100"
+ tls_disable = true
+}`
+)
+
func testAgentCommand(tb testing.TB, logger hclog.Logger) (*cli.MockUi, *AgentCommand) {
tb.Helper()
@@ -1245,6 +1263,27 @@ func makeTempFile(t *testing.T, name, contents string) string {
return path
}
+func populateTempFile(t *testing.T, name, contents string) *os.File {
+ t.Helper()
+
+ file, err := os.CreateTemp(t.TempDir(), name)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = file.WriteString(contents)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = file.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return file
+}
+
// handler makes 500 errors happen for reads on /v1/secret.
// Definitely not thread-safe, do not use t.Parallel with this.
type handler struct {
@@ -2211,6 +2250,105 @@ cache {}
wg.Wait()
}
+func TestAgent_LogFile_EnvVarOverridesConfig(t *testing.T) {
+ // Create basic config
+ configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig)
+ cfg, err := agentConfig.LoadConfig(configFile.Name())
+ if err != nil {
+ t.Fatal("Cannot load config to test update/merge", err)
+ }
+
+ // Sanity check that the config value is the current value
+ assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile)
+
+ // Make sure the env var is configured
+ oldEnvVarLogFile := os.Getenv(EnvVaultLogFile)
+ os.Setenv(EnvVaultLogFile, "/squiggle/logs.txt")
+ if oldEnvVarLogFile == "" {
+ defer os.Unsetenv(EnvVaultLogFile)
+ } else {
+ defer os.Setenv(EnvVaultLogFile, oldEnvVarLogFile)
+ }
+
+ // Initialize the command and parse any flags
+ cmd := &AgentCommand{BaseCommand: &BaseCommand{}}
+ f := cmd.Flags()
+ err = f.Parse([]string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Update the config based on the inputs.
+ cfg = cmd.aggregateConfig(f, cfg)
+
+ assert.NotEqual(t, "/foo/bar/juan.log", cfg.LogFile)
+ assert.Equal(t, "/squiggle/logs.txt", cfg.LogFile)
+}
+
+func TestAgent_LogFile_CliOverridesEnvVar(t *testing.T) {
+ // Create basic config
+ configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig)
+ cfg, err := agentConfig.LoadConfig(configFile.Name())
+ if err != nil {
+ t.Fatal("Cannot load config to test update/merge", err)
+ }
+
+ // Sanity check that the config value is the current value
+ assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile)
+
+ // Make sure the env var is configured
+ oldEnvVarLogFile := os.Getenv(EnvVaultLogFile)
+ os.Setenv(EnvVaultLogFile, "/squiggle/logs.txt")
+ if oldEnvVarLogFile == "" {
+ defer os.Unsetenv(EnvVaultLogFile)
+ } else {
+ defer os.Setenv(EnvVaultLogFile, oldEnvVarLogFile)
+ }
+
+ // Initialize the command and parse any flags
+ cmd := &AgentCommand{BaseCommand: &BaseCommand{}}
+ f := cmd.Flags()
+ // Simulate the flag being specified
+ err = f.Parse([]string{"-log-file=/foo/bar/test.log"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Update the config based on the inputs.
+ cfg = cmd.aggregateConfig(f, cfg)
+
+ assert.NotEqual(t, "/foo/bar/juan.log", cfg.LogFile)
+ assert.NotEqual(t, "/squiggle/logs.txt", cfg.LogFile)
+ assert.Equal(t, "/foo/bar/test.log", cfg.LogFile)
+}
+
+func TestAgent_LogFile_Config(t *testing.T) {
+ // Sanity check, remove any env var
+ os.Unsetenv(EnvVaultLogFile)
+
+ configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig)
+
+ cfg, err := agentConfig.LoadConfig(configFile.Name())
+ if err != nil {
+ t.Fatal("Cannot load config to test update/merge", err)
+ }
+
+ // Sanity check that the config value is the current value
+ assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile, "sanity check on log config failed")
+
+ // Parse the cli flags (but we pass in an empty slice)
+ cmd := &AgentCommand{BaseCommand: &BaseCommand{}}
+ f := cmd.Flags()
+ err = f.Parse([]string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cfg = cmd.aggregateConfig(f, cfg)
+
+ assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile, "actual config check")
+}
+
// Get a randomly assigned port and then free it again before returning it.
// There is still a race when trying to use it, but should work better
// than a static port.
diff --git a/command/commands.go b/command/commands.go
index 9a64191d29..4e5500b12b 100644
--- a/command/commands.go
+++ b/command/commands.go
@@ -82,6 +82,11 @@ const (
EnvVaultLicensePath = "VAULT_LICENSE_PATH"
// EnvVaultDetailed is to output detailed information (e.g., ListResponseWithInfo).
EnvVaultDetailed = `VAULT_DETAILED`
+ // EnvVaultLogFile is used to specify the path to the log file that Vault should use for logging
+ EnvVaultLogFile = "VAULT_LOG_FILE"
+ // EnvVaultLogLevel is used to specify the log level applied to logging
+ // Supported log levels: Trace, Debug, Error, Warn, Info
+ EnvVaultLogLevel = "VAULT_LOG_LEVEL"
// DisableSSCTokens is an env var used to disable index bearing
// token functionality
@@ -136,6 +141,11 @@ const (
flagNameUserLockoutDisable = "user-lockout-disable"
// flagNameDisableRedirects is used to prevent the client from honoring a single redirect as a response to a request
flagNameDisableRedirects = "disable-redirects"
+ // flagNameLogFile is used to specify the path to the log file that Vault should use for logging
+ flagNameLogFile = "log-file"
+ // flagNameLogLevel is used to specify the log level applied to logging
+ // Supported log levels: Trace, Debug, Error, Warn, Info
+ flagNameLogLevel = "log-level"
)
var (
diff --git a/helper/logging/logfile.go b/helper/logging/logfile.go
new file mode 100644
index 0000000000..b1fd46cbaf
--- /dev/null
+++ b/helper/logging/logfile.go
@@ -0,0 +1,56 @@
+package logging
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+type LogFile struct {
+ // Name of the log file
+ fileName string
+
+ // Path to the log file
+ logPath string
+
+ // fileInfo is the pointer to the current file being written to
+ fileInfo *os.File
+
+ // acquire is the mutex utilized to ensure we have no concurrency issues
+ acquire sync.Mutex
+}
+
+func NewLogFile(logPath string, fileName string) *LogFile {
+ return &LogFile{
+ fileName: strings.TrimSpace(fileName),
+ logPath: strings.TrimSpace(logPath),
+ }
+}
+
+// Write is used to implement io.Writer
+func (l *LogFile) Write(b []byte) (n int, err error) {
+ l.acquire.Lock()
+ defer l.acquire.Unlock()
+ // Create a new file if we have no file to write to
+ if l.fileInfo == nil {
+ if err := l.openNew(); err != nil {
+ return 0, err
+ }
+ }
+
+ return l.fileInfo.Write(b)
+}
+
+func (l *LogFile) openNew() error {
+ newFilePath := filepath.Join(l.logPath, l.fileName)
+
+ // Try to open an existing file or create a new one if it doesn't exist.
+ filePointer, err := os.OpenFile(newFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
+ if err != nil {
+ return err
+ }
+
+ l.fileInfo = filePointer
+ return nil
+}
diff --git a/helper/logging/logfile_test.go b/helper/logging/logfile_test.go
new file mode 100644
index 0000000000..442fc7c7c4
--- /dev/null
+++ b/helper/logging/logfile_test.go
@@ -0,0 +1,22 @@
+package logging
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLogFile_openNew(t *testing.T) {
+ logFile := NewLogFile(t.TempDir(), "vault-agent.log")
+ err := logFile.openNew()
+ require.NoError(t, err)
+
+ msg := "[INFO] Something"
+ _, err = logFile.Write([]byte(msg))
+ require.NoError(t, err)
+
+ content, err := os.ReadFile(logFile.fileInfo.Name())
+ require.NoError(t, err)
+ require.Contains(t, string(content), msg)
+}
diff --git a/helper/logging/logger.go b/helper/logging/logger.go
new file mode 100644
index 0000000000..ec42ef0e11
--- /dev/null
+++ b/helper/logging/logger.go
@@ -0,0 +1,135 @@
+package logging
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+
+ log "github.com/hashicorp/go-hclog"
+)
+
+const (
+ UnspecifiedFormat LogFormat = iota
+ StandardFormat
+ JSONFormat
+)
+
+type LogFormat int
+
+// LogConfig should be used to supply configuration when creating a new Vault logger
+type LogConfig struct {
+ name string
+ logLevel log.Level
+ logFormat LogFormat
+ logFilePath string
+}
+
+func NewLogConfig(name string, logLevel log.Level, logFormat LogFormat, logFilePath string) LogConfig {
+ return LogConfig{
+ name: name,
+ logLevel: logLevel,
+ logFormat: logFormat,
+ logFilePath: strings.TrimSpace(logFilePath),
+ }
+}
+
+func (c LogConfig) IsFormatJson() bool {
+ return c.logFormat == JSONFormat
+}
+
+// Stringer implementation
+func (lf LogFormat) String() string {
+ switch lf {
+ case UnspecifiedFormat:
+ return "unspecified"
+ case StandardFormat:
+ return "standard"
+ case JSONFormat:
+ return "json"
+ }
+
+ // unreachable
+ return "unknown"
+}
+
+// noErrorWriter is a wrapper to suppress errors when writing to w.
+type noErrorWriter struct {
+ w io.Writer
+}
+
+func (w noErrorWriter) Write(p []byte) (n int, err error) {
+ _, _ = w.w.Write(p)
+ // We purposely return n == len(p) as if write was successful
+ return len(p), nil
+}
+
+// Setup creates a new logger with the specified configuration and writer
+func Setup(config LogConfig, w io.Writer) (log.InterceptLogger, error) {
+ // Validate the log level
+ if config.logLevel.String() == "unknown" {
+ return nil, fmt.Errorf("invalid log level: %v", config.logLevel)
+ }
+
+ // If out is os.Stdout and Vault is being run as a Windows Service, writes will
+ // fail silently, which may inadvertently prevent writes to other writers.
+ // noErrorWriter is used as a wrapper to suppress any errors when writing to out.
+ writers := []io.Writer{noErrorWriter{w: w}}
+
+ if config.logFilePath != "" {
+ dir, fileName := filepath.Split(config.logFilePath)
+ if fileName == "" {
+ fileName = "vault-agent.log"
+ }
+ logFile := NewLogFile(dir, fileName)
+ if err := logFile.openNew(); err != nil {
+ return nil, fmt.Errorf("failed to set up file logging: %w", err)
+ }
+ writers = append(writers, logFile)
+ }
+
+ logger := log.NewInterceptLogger(&log.LoggerOptions{
+ Name: config.name,
+ Level: config.logLevel,
+ Output: io.MultiWriter(writers...),
+ JSONFormat: config.IsFormatJson(),
+ })
+ return logger, nil
+}
+
+// ParseLogFormat parses the log format from the provided string.
+func ParseLogFormat(format string) (LogFormat, error) {
+ switch strings.ToLower(strings.TrimSpace(format)) {
+ case "":
+ return UnspecifiedFormat, nil
+ case "standard":
+ return StandardFormat, nil
+ case "json":
+ return JSONFormat, nil
+ default:
+ return UnspecifiedFormat, fmt.Errorf("unknown log format: %s", format)
+ }
+}
+
+func ParseLogLevel(logLevel string) (log.Level, error) {
+ var result log.Level
+ logLevel = strings.ToLower(strings.TrimSpace(logLevel))
+
+ switch logLevel {
+ case "trace":
+ result = log.Trace
+ case "debug":
+ result = log.Debug
+ case "notice", "info", "":
+ result = log.Info
+ case "warn", "warning":
+ result = log.Warn
+ case "err", "error":
+ result = log.Error
+ default:
+ return -1, errors.New(fmt.Sprintf("unknown log level: %s", logLevel))
+ }
+
+ return result, nil
+}
diff --git a/helper/logging/logger_test.go b/helper/logging/logger_test.go
new file mode 100644
index 0000000000..c300175f3e
--- /dev/null
+++ b/helper/logging/logger_test.go
@@ -0,0 +1,128 @@
+package logging
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+
+ log "github.com/hashicorp/go-hclog"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLogger_SetupBasic(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Info, StandardFormat, t.TempDir()+"test.log")
+
+ logger, err := Setup(cfg, nil)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+}
+
+func TestLogger_SetupInvalidLogLevel(t *testing.T) {
+ cfg := NewLogConfig("test-system", 999, StandardFormat, t.TempDir()+"test.log")
+
+ _, err := Setup(cfg, nil)
+ assert.Containsf(t, err.Error(), "invalid log level", "expected error %s", err)
+}
+
+func TestLogger_SetupLoggerErrorLevel(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Error, StandardFormat, t.TempDir()+"test.log")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+
+ logger.Error("test error msg")
+ logger.Info("test info msg")
+
+ output := buf.String()
+
+ require.Contains(t, output, "[ERROR] test-system: test error msg")
+ require.NotContains(t, output, "[INFO] test-system: test info msg")
+}
+
+func TestLogger_SetupLoggerDebugLevel(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Debug, StandardFormat, t.TempDir()+"test.log")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+
+ logger.Info("test info msg")
+ logger.Debug("test debug msg")
+
+ output := buf.String()
+
+ require.Contains(t, output, "[INFO] test-system: test info msg")
+ require.Contains(t, output, "[DEBUG] test-system: test debug msg")
+}
+
+func TestLogger_SetupLoggerWithName(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Debug, StandardFormat, t.TempDir()+"test.log")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+
+ logger.Warn("test warn msg")
+
+ require.Contains(t, buf.String(), "[WARN] test-system: test warn msg")
+}
+
+func TestLogger_SetupLoggerWithJSON(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Debug, JSONFormat, t.TempDir()+"test.log")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+
+ logger.Warn("test warn msg")
+
+ var jsonOutput map[string]string
+ err = json.Unmarshal(buf.Bytes(), &jsonOutput)
+ require.NoError(t, err)
+ require.Contains(t, jsonOutput, "@level")
+ require.Equal(t, jsonOutput["@level"], "warn")
+ require.Contains(t, jsonOutput, "@message")
+ require.Equal(t, jsonOutput["@message"], "test warn msg")
+}
+
+func TestLogger_SetupLoggerWithValidLogPath(t *testing.T) {
+ tmpDir := t.TempDir()
+ cfg := NewLogConfig("test-system", log.Info, StandardFormat, tmpDir+"/")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.NoError(t, err)
+ require.NotNil(t, logger)
+}
+
+func TestLogger_SetupLoggerWithInValidLogPath(t *testing.T) {
+ cfg := NewLogConfig("test-system", log.Info, StandardFormat, "nonexistentdir/")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.Error(t, err)
+ require.True(t, errors.Is(err, os.ErrNotExist))
+ require.Nil(t, logger)
+}
+
+func TestLogger_SetupLoggerWithInValidLogPathPermission(t *testing.T) {
+ tmpDir := "/tmp/" + t.Name()
+
+ os.Mkdir(tmpDir, 0o000)
+ defer os.RemoveAll(tmpDir)
+ cfg := NewLogConfig("test-system", log.Info, StandardFormat, tmpDir+"/")
+ var buf bytes.Buffer
+
+ logger, err := Setup(cfg, &buf)
+ require.Error(t, err)
+ require.True(t, errors.Is(err, os.ErrPermission))
+ require.Nil(t, logger)
+}
diff --git a/website/content/docs/agent/index.mdx b/website/content/docs/agent/index.mdx
index d41d9bdfeb..e087878fa8 100644
--- a/website/content/docs/agent/index.mdx
+++ b/website/content/docs/agent/index.mdx
@@ -127,6 +127,12 @@ See the [caching](/docs/agent/caching#api) page for details on the cache API.
## Configuration
+### Command Options
+
+- `-log-file` `(string: "")` - If specified, should contain the full file path to use for outputting log files from Vault.
+
+### Configuration File Options
+
These are the currently-available general configuration option:
- `vault` ([vault][vault]: ) - Specifies the remote Vault server the Agent connects to.
@@ -373,4 +379,4 @@ template {
[listener]: /docs/agent#listener-stanza
[listener_main]: /docs/configuration/listener/tcp
[winsvc]: /docs/agent/winsvc
-[telemetry]: /docs/configuration/telemetry
+[telemetry]: /docs/configuration/telemetry
\ No newline at end of file
diff --git a/website/content/docs/agent/winsvc.mdx b/website/content/docs/agent/winsvc.mdx
index 7436c8b965..f67d7f9616 100644
--- a/website/content/docs/agent/winsvc.mdx
+++ b/website/content/docs/agent/winsvc.mdx
@@ -35,7 +35,7 @@ of Vault Agent as a service, using "Vault Agent" as the display name, and starti
The `binPath` argument should include the fully qualified path to the Vault executable, as well as any arguments required.
```shell-session
-PS C:\Windows\system32> sc.exe create VaultAgent binPath= "C:\vault\vault.exe agent -config=C:\vault\agent-config.hcl" displayName= "Vault Agent" start= auto
+PS C:\Windows\system32> sc.exe create VaultAgent binPath="C:\vault\vault.exe agent -config=C:\vault\agent-config.hcl" displayName="Vault Agent" start=auto
[SC] CreateService SUCCESS
```
diff --git a/website/content/docs/commands/index.mdx b/website/content/docs/commands/index.mdx
index 1a5282c134..6be9cb004c 100644
--- a/website/content/docs/commands/index.mdx
+++ b/website/content/docs/commands/index.mdx
@@ -331,6 +331,10 @@ precedence over [#VAULT_LICENSE_PATH](#vault_license_path) and
[Enterprise, Server only] Specify a path to a license on disk to use for this node.
This takes precedence over [license_path in config](/docs/configuration#license_path).
+### `VAULT_LOG_FILE`
+
+(Agent only) If provided, specifies the full path to a log file Vault should use to write its logs.
+
### `VAULT_MAX_RETRIES`
Maximum number of retries when certain error codes are encountered. The default
@@ -434,4 +438,4 @@ list of available flags, run:
```shell-session
$ vault -h
-```
+```
\ No newline at end of file