Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +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: set proxy_url above, or use HTTPS_PROXY / HTTP_PROXY / NO_PROXY
# env vars for env-level config.
```

### `[server]` section
Expand Down Expand Up @@ -329,6 +337,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. 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`.

## Related
Expand Down
33 changes: 33 additions & 0 deletions cmd/hermes-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
Expand Down Expand Up @@ -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
Expand All @@ -723,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)
Expand Down Expand Up @@ -809,6 +841,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
Expand Down
4 changes: 2 additions & 2 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
136 changes: 136 additions & 0 deletions internal/logger/rotating.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions internal/wire/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading