Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
35 changes: 35 additions & 0 deletions PROXY_SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Agent Proxy Support

This document describes how to configure the NGINX Agent to connect to the management plane through an explicit forward proxy (EFP), via HTTP/1.1 and authentication.

---

## 1. Basic Proxy Configuration

Add a `proxy` section under the `server` block in your agent config file:

```yaml
server:
host: mgmt.example.com
port: 443
type: 1
proxy:
url: "http://proxy.example.com:3128"
timeout: 10s
```

- `url`: Proxy URL (http supported)
- `timeout`: Dial timeout for connecting to the proxy

---

## 2. Proxy Authentication

### Basic Auth
```yaml
proxy:
url: "http://proxy.example.com:3128"
auth_method: "basic"
username: "user"
password: "pass"
```
70 changes: 70 additions & 0 deletions internal/collector/otel_collector_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"log/slog"
"net"
"net/url"
"os"
"strings"
"sync"
Expand Down Expand Up @@ -231,6 +232,10 @@ func (oc *Collector) bootup(ctx context.Context) error {
return
}

if oc.config.IsCommandServerProxyConfigured() {
oc.setProxyIfNeeded(ctx)
}

appErr := oc.service.Run(ctx)
if appErr != nil {
errChan <- appErr
Expand Down Expand Up @@ -394,6 +399,10 @@ func (oc *Collector) restartCollector(ctx context.Context) {
}
oc.service = oTelCollector

if oc.config.IsCommandServerProxyConfigured() {
oc.setProxyIfNeeded(ctx)
}

var runCtx context.Context
runCtx, oc.cancel = context.WithCancel(ctx)

Expand All @@ -409,6 +418,14 @@ func (oc *Collector) restartCollector(ctx context.Context) {
}
}

func (oc *Collector) setProxyIfNeeded(ctx context.Context) {
if oc.config.Collector.Exporters.OtlpExporters != nil ||
oc.config.Collector.Exporters.PrometheusExporter != nil {
// Set proxy env vars for OTLP exporter if proxy is configured.
oc.setExporterProxyEnvVars(ctx)
}
}

func (oc *Collector) checkForNewReceivers(ctx context.Context, nginxConfigContext *model.NginxConfigContext) bool {
nginxReceiverFound, reloadCollector := oc.updateExistingNginxPlusReceiver(nginxConfigContext)

Expand Down Expand Up @@ -740,3 +757,56 @@ func escapeString(input string) string {

return output
}

func (oc *Collector) setExporterProxyEnvVars(ctx context.Context) {
proxy := oc.config.Command.Server.Proxy
proxyURL := proxy.URL
parsedProxyURL, err := url.Parse(proxyURL)
if err != nil {
slog.ErrorContext(ctx, "Malformed proxy URL; skipping Proxy setup", "url", proxyURL, "error", err)
return
}

if parsedProxyURL.Scheme == "https" {
slog.ErrorContext(ctx, "Protocol not supported, unable to configure proxy", "url", proxyURL)
return
}

auth := ""
if proxy.AuthMethod != "" && strings.TrimSpace(proxy.AuthMethod) != "" {
auth = strings.TrimSpace(proxy.AuthMethod)
}

// Use the standalone setProxyWithBasicAuth function
if auth == "" {
setProxyEnvs(ctx, proxyURL, "Setting Proxy from command.Proxy (no auth)")
return
}
authLower := strings.ToLower(auth)
if authLower == "basic" {
setProxyWithBasicAuth(ctx, proxy, parsedProxyURL)
} else {
slog.ErrorContext(ctx, "Unknown auth type for proxy; unable to configure proxy", "auth", auth, "url", proxyURL)
}
}

// setProxyEnvs sets the HTTP_PROXY and HTTPS_PROXY environment variables and logs the action.
func setProxyEnvs(ctx context.Context, proxyEnvURL, msg string) {
slog.DebugContext(ctx, msg, "url", proxyEnvURL)
if setenvErr := os.Setenv("HTTP_PROXY", proxyEnvURL); setenvErr != nil {
slog.ErrorContext(ctx, "Failed to set Proxy", "error", setenvErr)
}
}

// setProxyWithBasicAuth sets the proxy environment variables with basic auth credentials.
func setProxyWithBasicAuth(ctx context.Context, proxy *config.Proxy, parsedProxyURL *url.URL) {
username := proxy.Username
password := proxy.Password
if username == "" || password == "" {
slog.ErrorContext(ctx, "Username or password missing for basic auth")
return
}
parsedProxyURL.User = url.UserPassword(username, password)
proxyURL := parsedProxyURL.String()
setProxyEnvs(ctx, proxyURL, "Setting Proxy with basic auth")
}
129 changes: 128 additions & 1 deletion internal/collector/otel_collector_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"context"
"errors"
"net"
"net/url"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -246,7 +248,11 @@ func TestCollector_ProcessNginxConfigUpdateTopic(t *testing.T) {

conf := types.OTelConfig(t)

conf.Command = nil
conf.Command = &config.Command{
Server: &config.ServerConfig{
Proxy: &config.Proxy{},
},
}

conf.Collector.Log.Path = ""
conf.Collector.Receivers.HostMetrics = nil
Expand Down Expand Up @@ -783,6 +789,127 @@ func TestCollector_updateNginxAppProtectTcplogReceivers(t *testing.T) {
})
}

func Test_setProxyEnvs(t *testing.T) {
ctx := context.Background()
proxyURL := "http://localhost:8080"
msg := "Setting test proxy"

// Unset first to ensure clean state
_ = os.Unsetenv("HTTP_PROXY")

setProxyEnvs(ctx, proxyURL, msg)

httpProxy := os.Getenv("HTTP_PROXY")
assert.Equal(t, proxyURL, httpProxy)
}

func Test_setProxyWithBasicAuth(t *testing.T) {
ctx := context.Background()
u, _ := url.Parse("http://localhost:8080")
proxy := &config.Proxy{
URL: "http://localhost:8080",
Username: "user",
Password: "pass",
}

// Unset first to ensure clean state
_ = os.Unsetenv("HTTP_PROXY")

setProxyWithBasicAuth(ctx, proxy, u)

proxyURL := u.String()
httpProxy := os.Getenv("HTTP_PROXY")
assert.Equal(t, proxyURL, httpProxy)

// Test missing username/password
proxyMissing := &config.Proxy{URL: "http://localhost:8080"}
setProxyWithBasicAuth(ctx, proxyMissing, u) // Should not panic
}

func TestSetExporterProxyEnvVars(t *testing.T) {
ctx := context.Background()
logBuf := &bytes.Buffer{}
stub.StubLoggerWith(logBuf)

tests := []struct {
name string
proxy *config.Proxy
expectedLog string
setEnv bool
}{
{
name: "No proxy config",
proxy: nil,
expectedLog: "Proxy configuration is not setup. Unable to configure proxy for OTLP exporter",
setEnv: false,
},
{
name: "Malformed proxy URL",
proxy: &config.Proxy{URL: "://bad_url"},
expectedLog: "Malformed proxy URL; skipping Proxy setup",
setEnv: false,
},
{
name: "No auth, valid URL",
proxy: &config.Proxy{URL: "http://proxy.example.com:8080"},
expectedLog: "Setting Proxy from command.Proxy (no auth)",
setEnv: true,
},
{
name: "Basic auth, valid URL",
proxy: &config.Proxy{
URL: "http://proxy.example.com:8080",
AuthMethod: "basic",
Username: "user",
Password: "pass",
},
expectedLog: "Setting Proxy with basic auth",
setEnv: true,
},
{
name: "Unknown auth method",
proxy: &config.Proxy{URL: "http://proxy.example.com:8080", AuthMethod: "digest"},
expectedLog: "Unknown auth type for proxy; unable to configure proxy",
setEnv: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logBuf.Reset()

_ = os.Unsetenv("HTTP_PROXY")

tmpDir := t.TempDir()
cfg := types.OTelConfig(t)
cfg.Collector.Log.Path = filepath.Join(tmpDir, "otel-collector-test.log")
cfg.Command.Server.Proxy = tt.proxy

// If the proxy is nil, the production code would never call the setter functions.
// added this check to prevent the panic error in UT.
if cfg.Command.Server.Proxy == nil {
// For the nil proxy case, we expect nothing to happen.
assert.Empty(t, os.Getenv("HTTP_PROXY"))

return
}

collector, err := NewCollector(cfg)
require.NoError(t, err)

collector.setExporterProxyEnvVars(ctx)

helpers.ValidateLog(t, tt.expectedLog, logBuf)

if tt.setEnv {
assert.NotEmpty(t, os.Getenv("HTTP_PROXY"))
} else {
assert.Empty(t, os.Getenv("HTTP_PROXY"))
}
})
}
}

func TestCollector_findAvailableSyslogServers(t *testing.T) {
conf := types.OTelConfig(t)
conf.Collector.Log.Path = ""
Expand Down
Loading
Loading