Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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"
```
124 changes: 112 additions & 12 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,8 @@ func (oc *Collector) bootup(ctx context.Context) error {
return
}

oc.setProxyIfNeeded(ctx)

appErr := oc.service.Run(ctx)
if appErr != nil {
errChan <- appErr
Expand Down Expand Up @@ -380,35 +383,68 @@ func (oc *Collector) updateHeadersSetterExtension(
}

func (oc *Collector) restartCollector(ctx context.Context) {
err := oc.Close(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to shutdown OTel Collector", "error", err)
if !oc.canRestartCollector(ctx) {
return
}

settings := OTelCollectorSettings(oc.config)
oTelCollector, err := otelcol.NewCollector(settings)
if err != nil {
slog.ErrorContext(ctx, "Failed to create OTel Collector", "error", err)
if !oc.tryCloseCollector(ctx) {
return
}
oc.service = oTelCollector

if !oc.tryCreateCollector(ctx) {
return
}
oc.setProxyIfNeeded(ctx)
var runCtx context.Context
runCtx, oc.cancel = context.WithCancel(ctx)

if !oc.stopped {
slog.ErrorContext(ctx, "Unable to restart OTel collector, failed to stop collector")
return
}

slog.InfoContext(ctx, "Restarting OTel collector")
bootErr := oc.bootup(runCtx)
if bootErr != nil {
slog.ErrorContext(runCtx, "Unable to start OTel Collector", "error", bootErr)
}
}

func (oc *Collector) canRestartCollector(ctx context.Context) bool {
if oc == nil || oc.config == nil || oc.config.Command == nil || oc.config.Collector == nil {
slog.ErrorContext(ctx, "Collector or required config is nil; cannot restart collector")
return false
}

return true
}

func (oc *Collector) tryCloseCollector(ctx context.Context) bool {
err := oc.Close(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to shutdown OTel Collector", "error", err)
return false
}

return true
}

func (oc *Collector) tryCreateCollector(ctx context.Context) bool {
settings := OTelCollectorSettings(oc.config)
oTelCollector, err := otelcol.NewCollector(settings)
if err != nil {
slog.ErrorContext(ctx, "Failed to create OTel Collector", "error", err)
return false
}
oc.service = oTelCollector

return true
}

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 +776,67 @@ func escapeString(input string) string {

return output
}

func (oc *Collector) setExporterProxyEnvVars(ctx context.Context) {
// Validate proxy fields
if oc.config.Command.Server.Proxy == nil {
slog.InfoContext(ctx, "Proxy configuration is not setup; skipping Proxy setup")
return
}

proxy := oc.config.Command.Server.Proxy
if proxy.URL == "" {
slog.WarnContext(ctx, "Proxy URL is empty; skipping Proxy setup")
return
}
proxyURL := proxy.URL
u, err := url.Parse(proxyURL)
if err != nil {
slog.ErrorContext(ctx, "Malformed proxy URL; skipping Proxy setup", "url", proxyURL, "error", err)
return
}

auth := ""
if oc.config.Command.Server.Proxy.AuthMethod != "" &&
strings.TrimSpace(oc.config.Command.Server.Proxy.AuthMethod) != "" {
auth = strings.TrimSpace(oc.config.Command.Server.Proxy.AuthMethod)
slog.DebugContext(ctx, "auth string", "auth", auth)
}

// Use the standalone setProxyWithBasicAuth function
if auth == "" {
setProxyEnvs(ctx, proxyURL, "Setting Proxy from command.Proxy (no auth)")
return
}
authLower := strings.ToLower(auth)
slog.DebugContext(ctx, "auth To Lower string", "authlower", authLower)
if authLower == "basic" {
setProxyWithBasicAuth(ctx, proxy, u)
} else {
slog.ErrorContext(ctx, "Unknown auth type for proxy; Aborting Proxy Setup", "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)
}
if setenvErr := os.Setenv("HTTPS_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, u *url.URL) {
username := proxy.Username
password := proxy.Password
if username == "" || password == "" {
slog.ErrorContext(ctx, "Username or password missing for basic auth")
return
}
u.User = url.UserPassword(username, password)
proxyURL := u.String()
setProxyEnvs(ctx, proxyURL, "Setting Proxy with basic auth")
}
125 changes: 125 additions & 0 deletions 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 @@ -783,6 +785,129 @@ 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")
_ = os.Unsetenv("HTTPS_PROXY")

setProxyEnvs(ctx, proxyURL, msg)

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

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")
_ = os.Unsetenv("HTTPS_PROXY")

setProxyWithBasicAuth(ctx, proxy, u)

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

// 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; skipping Proxy setup",
setEnv: false,
},
{
name: "Empty proxy URL",
proxy: &config.Proxy{URL: ""},
expectedLog: "Proxy URL is empty; skipping Proxy setup",
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; Aborting Proxy Setup",
setEnv: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logBuf.Reset()
tmpDir := t.TempDir()
cfg := types.OTelConfig(t)
cfg.Collector.Log.Path = filepath.Join(tmpDir, "otel-collector-test.log")
cfg.Command.Server.Proxy = tt.proxy
collector, err := NewCollector(cfg)
require.NoError(t, err)
collector.setExporterProxyEnvVars(ctx)
helpers.ValidateLog(t, tt.expectedLog, logBuf)
if tt.setEnv {
// Check that HTTP_PROXY and HTTPS_PROXY are set
httpProxy := os.Getenv("HTTP_PROXY")
httpsProxy := os.Getenv("HTTPS_PROXY")
assert.NotEmpty(t, httpProxy)
assert.NotEmpty(t, httpsProxy)
} else {
// Unset for next test
os.Unsetenv("HTTP_PROXY")
os.Unsetenv("HTTPS_PROXY")
}
})
}
}

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