From 77a23825d373fb137a225bbfb20e5200b089f996 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Wed, 24 Jun 2026 16:13:50 +0700 Subject: [PATCH 1/3] docs: add HTTP proxy support info to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that hermes-node respects HTTPS_PROXY/HTTP_PROXY env vars via gorilla/websocket's DefaultDialer — no config changes needed. Includes FAQ entry on Basic auth and NTLM workaround. Signed-off-by: Blasius Patrick --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 5b32555..7d58288 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,10 @@ log_level = "debug" backoff_initial = "1s" # default; Go duration, e.g. "500ms", "5s" backoff_max = "60s" # default; maximum delay between retries backoff_factor = 2.0 # default; multiplier per retry + +# Proxy: hermes-node respects HTTPS_PROXY / HTTP_PROXY / NO_PROXY +# env vars automatically. No config field needed — just set them +# in the shell before running `hermes-node run`. ``` ### `[server]` section @@ -329,6 +333,8 @@ Quick summary: - **Q: Can I reload config without restarting?** A: Send `SIGHUP` to the daemon process to reload `log_level`. Other changes require a restart. +- **Q: Does the node support HTTP proxies?** A: Yes. The WebSocket client respects the standard `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`, and `NO_PROXY` environment variables automatically. For Basic auth, include credentials in the URL: `http://user:password@proxy:port`. For NTLM/Kerberos proxies, use a local authenticating proxy bridge (e.g. `cntlm`). + - **Q: What does `--version` show?** A: The version, Go version, commit SHA, and build date. Example: `hermes-node v0.1.0 go1.26.3 abc12345 2026-06-22`. ## Related From 5ae6613ae71b9c93026eca7ad7338f186e8999ea Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Thu, 25 Jun 2026 12:21:53 +0700 Subject: [PATCH 2/3] feat: add proxy_url config field for built-in proxy support Adds a proxy_url field to [node] in config.toml so operators can configure an HTTP(S) proxy without relying on environment variables. - config.go: add ProxyURL field to NodeConfig - client.go: add ProxyURL to DialOptions, wire into gorilla/websocket dialer via http.ProxyURL - main.go: pass cfg.Node.ProxyURL in dialer options - main.go (validate): validate proxy_url when set - README.md: document proxy_url alongside existing env-var approach Signed-off-by: Blasius Patrick --- README.md | 14 +++++++++----- cmd/hermes-node/main.go | 17 +++++++++++++++++ internal/config/config.go | 1 + internal/wire/client.go | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7d58288..234f693 100644 --- a/README.md +++ b/README.md @@ -229,14 +229,18 @@ log_path = "/home/user/.hermes-nodes/audit.log" # One of: debug, info, warn, error log_level = "debug" -# Reconnect backoff (defaults shown) +# Proxy: optional HTTP(S) proxy for the WebSocket connection. +# When set, overrides HTTPS_PROXY / HTTP_PROXY env vars. +proxy_url = "http://proxy.corp.example:8080" + +# Reconnect backoff: fine-tune the exponential backoff for +# transient network drops. The defaults work for most setups. backoff_initial = "1s" # default; Go duration, e.g. "500ms", "5s" backoff_max = "60s" # default; maximum delay between retries backoff_factor = 2.0 # default; multiplier per retry -# Proxy: hermes-node respects HTTPS_PROXY / HTTP_PROXY / NO_PROXY -# env vars automatically. No config field needed — just set them -# in the shell before running `hermes-node run`. +# Proxy: set proxy_url above, or use HTTPS_PROXY / HTTP_PROXY / NO_PROXY +# env vars for env-level config. ``` ### `[server]` section @@ -333,7 +337,7 @@ Quick summary: - **Q: Can I reload config without restarting?** A: Send `SIGHUP` to the daemon process to reload `log_level`. Other changes require a restart. -- **Q: Does the node support HTTP proxies?** A: Yes. The WebSocket client respects the standard `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`, and `NO_PROXY` environment variables automatically. For Basic auth, include credentials in the URL: `http://user:password@proxy:port`. For NTLM/Kerberos proxies, use a local authenticating proxy bridge (e.g. `cntlm`). +- **Q: Does the node support HTTP proxies?** A: Yes. Set `proxy_url` in `config.toml` under `[node]`, or use the standard `HTTPS_PROXY`/`HTTP_PROXY` env vars. For Basic auth, include credentials in the URL: `http://user:password@proxy:port`. For NTLM/Kerberos proxies, use a local authenticating proxy bridge (e.g. `cntlm`). - **Q: What does `--version` show?** A: The version, Go version, commit SHA, and build date. Example: `hermes-node v0.1.0 go1.26.3 abc12345 2026-06-22`. diff --git a/cmd/hermes-node/main.go b/cmd/hermes-node/main.go index 59eb06b..80730c7 100644 --- a/cmd/hermes-node/main.go +++ b/cmd/hermes-node/main.go @@ -32,6 +32,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -699,6 +700,21 @@ func runValidate(args []string, configPath string, stdout, stderr io.Writer) int } // If no TLS settings are configured, that's fine — no check needed. + // 5. Proxy URL — validate the URL when set. + if cfg.Node.ProxyURL != "" { + proxyURL, err := url.Parse(cfg.Node.ProxyURL) + if err != nil { + fmt.Fprintf(stdout, " [FAIL] proxy_url: %v\n", err) + failed++ + } else if proxyURL.Scheme != "http" && proxyURL.Scheme != "https" { + fmt.Fprintf(stdout, " [FAIL] proxy_url: scheme %q must be http or https\n", proxyURL.Scheme) + failed++ + } else { + fmt.Fprintf(stdout, " [OK] proxy_url: %s\n", cfg.Node.ProxyURL) + passed++ + } + } + if failed == 0 { fmt.Fprintf(stdout, "\nhermes-node: config is valid (%d checks passed).\n", passed) return 0 @@ -809,6 +825,7 @@ func runRun(ctx context.Context, configPath string, stdout, stderr io.Writer) in Arch: runtime.GOARCH, Capabilities: []string{"exec", "read", "write"}, TLSConfig: tlsCfg, + ProxyURL: cfg.Node.ProxyURL, }) }, // Setup is invoked once per (re)connect. We build a fresh diff --git a/internal/config/config.go b/internal/config/config.go index 995bf77..c562f38 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type NodeConfig struct { AllowedPaths []string `toml:"allowed_paths"` LogPath string `toml:"log_path"` LogLevel string `toml:"log_level"` + ProxyURL string `toml:"proxy_url"` BackoffInitial string `toml:"backoff_initial"` BackoffMax string `toml:"backoff_max"` BackoffFactor float64 `toml:"backoff_factor"` diff --git a/internal/wire/client.go b/internal/wire/client.go index 2fa4318..af7538d 100644 --- a/internal/wire/client.go +++ b/internal/wire/client.go @@ -10,6 +10,7 @@ import ( "crypto/tls" "errors" "fmt" + "net/http" "net/url" "time" @@ -97,6 +98,13 @@ type DialOptions struct { // handed over. A future maintainer should not construct a // per-call closure here. TLSConfig *tls.Config + + // ProxyURL is an optional HTTP(S) proxy URL for the WebSocket + // dialer. When set, it overrides the HTTP_PROXY / HTTPS_PROXY + // environment variables. Leave empty to use the default + // proxy-from-environment behaviour (ProxyFromEnvironment). + // Example: "http://proxy.corp.example:8080". + ProxyURL string } // withDefaults returns a copy of opts with zero-valued fields filled @@ -166,6 +174,13 @@ func Connect(ctx context.Context, opts DialOptions) (*Client, error) { if opts.TLSConfig != nil { dialer.TLSClientConfig = opts.TLSConfig } + if opts.ProxyURL != "" { + proxyURL, err := url.Parse(opts.ProxyURL) + if err != nil { + return nil, fmt.Errorf("wire: parse proxy_url: %w", err) + } + dialer.Proxy = http.ProxyURL(proxyURL) + } conn, _, err := dialer.DialContext(wsCtx, opts.ServerURL, nil) if err != nil { return nil, fmt.Errorf("wire: dial %s: %w", opts.ServerURL, err) From 0dfc8e2e0d320994ba463a0ead1d3d7eb6414a28 Mon Sep 17 00:00:00 2001 From: Blasius Patrick Date: Thu, 25 Jun 2026 17:22:41 +0700 Subject: [PATCH 3/3] feat: auto-rotate audit.log and daemon.log at 10 MiB, keep 5 - audit.log: changed DefaultMaxBytes from 50 MiB to 10 MiB - daemon.log: added RotatingFile writer in internal/logger that rotates when the file exceeds 10 MiB, keeps 5 rotated copies - runRun: when running as inner daemon (HERMES_NODE_INNER set), replaces os.Stdout/os.Stderr with a rotating file writer so daemon.log auto-rotates without unbounded disk growth Signed-off-by: Blasius Patrick --- cmd/hermes-node/main.go | 16 +++++ internal/audit/audit.go | 4 +- internal/logger/rotating.go | 136 ++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 internal/logger/rotating.go diff --git a/cmd/hermes-node/main.go b/cmd/hermes-node/main.go index 80730c7..f8829e2 100644 --- a/cmd/hermes-node/main.go +++ b/cmd/hermes-node/main.go @@ -739,6 +739,22 @@ func runRun(ctx context.Context, configPath string, stdout, stderr io.Writer) in } logLevel, _ := logger.ParseLevel(cfg.Node.LogLevel) + + // When running as the inner daemon (HERMES_NODE_INNER set), replace + // stdout/stderr with a rotating file so daemon.log auto-rotates. + if os.Getenv("HERMES_NODE_INNER") != "" { + dlogPath := filepath.Join(getConfigDir(configPath), "daemon.log") + rf, err := logger.NewRotatingFile(dlogPath, 10*1024*1024, 5) + if err == nil { + stdout = rf + stderr = rf + defer rf.Close() + } + // If opening the rotating file fails, fall through with the + // original stdout/stderr (the parent already set them to the + // raw daemon.log fd, so logging still works — just no rotation). + } + log := logger.NewWithWriters(logLevel, stdout, stderr) auditLog, err := audit.New(cfg.Node.LogPath) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index c68a3b8..98eafba 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -19,8 +19,8 @@ import ( ) const ( - // DefaultMaxBytes is the rotation threshold: 50 MiB. - DefaultMaxBytes int64 = 50 * 1024 * 1024 + // DefaultMaxBytes is the rotation threshold: 10 MiB. + DefaultMaxBytes int64 = 10 * 1024 * 1024 // DefaultKeep is how many rotated files are retained. DefaultKeep = 5 ) diff --git a/internal/logger/rotating.go b/internal/logger/rotating.go new file mode 100644 index 0000000..ed685b8 --- /dev/null +++ b/internal/logger/rotating.go @@ -0,0 +1,136 @@ +// Package logger: rotating file writer with size-based rotation. +// +// RotatingFile is an io.WriteCloser that rotates the active log file +// when it exceeds a configured max size. Rotated files are numbered +// .1, .2, ... up to keep count; older files are removed. +package logger + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +const ( + // DefaultMaxBytes is the rotation threshold: 10 MiB. + DefaultMaxBytes int64 = 10 * 1024 * 1024 + // DefaultKeep is how many rotated files are retained. + DefaultKeep = 5 +) + +// RotatingFile is an io.WriteCloser that writes to a file and +// automatically rotates it when it exceeds maxBytes. At most +// keep rotated files (.1, .2, ...) are retained. +type RotatingFile struct { + mu sync.Mutex + path string + file *os.File + maxBytes int64 + keep int +} + +// NewRotatingFile opens or creates path and returns a RotatingFile +// that rotates when the file exceeds maxBytes. keep controls how +// many historic .N files are retained. If maxBytes <= 0 it defaults +// to DefaultMaxBytes; if keep <= 0 it defaults to DefaultKeep. +func NewRotatingFile(path string, maxBytes int64, keep int) (*RotatingFile, error) { + if path == "" { + return nil, fmt.Errorf("rotating: path is required") + } + if maxBytes <= 0 { + maxBytes = DefaultMaxBytes + } + if keep <= 0 { + keep = DefaultKeep + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("rotating: mkdir %s: %w", filepath.Dir(path), err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("rotating: open %s: %w", path, err) + } + return &RotatingFile{path: path, file: f, maxBytes: maxBytes, keep: keep}, nil +} + +// Write appends p to the active file. If writing would push the +// file past maxBytes the file is rotated first. +func (w *RotatingFile) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.file == nil { + return 0, fmt.Errorf("rotating: writer is closed") + } + + if err := w.maybeRotateLocked(); err != nil { + return 0, err + } + return w.file.Write(p) +} + +// Close closes the underlying file. Safe to call multiple times. +func (w *RotatingFile) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +// Path returns the active file path. +func (w *RotatingFile) Path() string { return w.path } + +// maybeRotateLocked checks the current file size and rotates if it +// has reached maxBytes. Caller must hold w.mu. +func (w *RotatingFile) maybeRotateLocked() error { + info, err := w.file.Stat() + if err != nil { + return fmt.Errorf("rotating: stat: %w", err) + } + if info.Size() < w.maxBytes { + return nil + } + return w.rotateLocked() +} + +// rotateLocked shifts the current file out of the way. The active +// log is renamed to .1, the previous .1 to .2, and so on. Files +// beyond .keep are removed. Finally a fresh active file is opened. +// Caller holds w.mu. +func (w *RotatingFile) rotateLocked() error { + // Close the active file so we can rename it. + if err := w.file.Close(); err != nil { + return fmt.Errorf("rotating: close before rotate: %w", err) + } + w.file = nil + + // Walk existing rotations from oldest to newest, shifting up + // and dropping anything beyond the keep window. + for i := w.keep; i >= 1; i-- { + older := w.path + "." + fmt.Sprintf("%d", i+1) + newer := w.path + "." + fmt.Sprintf("%d", i) + if i == w.keep { + // Drop the oldest. + _ = os.Remove(older) + continue + } + if _, err := os.Stat(newer); err == nil { + _ = os.Rename(newer, older) + } + } + + // Move the active log to .1, then reopen fresh. + _ = os.Rename(w.path, w.path+".1") + + f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("rotating: reopen %s: %w", w.path, err) + } + w.file = f + return nil +}