Skip to content

Explicit forward proxy support for HTTP with Basic Auth #1201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add this to the official NGINX docs https://github.com/nginx/documentation


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"
```
69 changes: 69 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be renamed to just setProxy ?

Suggested change
func (oc *Collector) setProxyIfNeeded(ctx context.Context) {
func (oc *Collector) setProxy(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,55 @@ 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)
}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could assert.NotPanics be used here ?

}

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