feat(elbv2): NLB L4 data plane on nginx stream + UI parity (#295)

* feat(elbv2): NLB L4 data plane on nginx stream (mulga-siv-952)

Network load balancers render an nginx `stream` config (TCP/UDP/TLS/
TCP_UDP) instead of HAProxy, which cannot load-balance UDP in any
release. The gateway tags GetLBConfig with Engine=nginx; the LB agent
dispatches to the nginx engine (config/pid/cert paths + reload) and
skips HAProxy stats polling. ALB stays on HAProxy. nginx is added to
the microvm image.

* docs(elbv2): note NLB runs nginx stream, ALB runs HAProxy (mulga-siv-952)

* fix(elbv2): load nginx stream module for NLB data plane

Alpine's `nginx` apk is built without the stream module — it ships
separately as `nginx-mod-stream`. Without it, the generated NLB config
fails `nginx -t` with [emerg] unknown directive "stream", so the agent
never applies it. Add the package to the microvm image and emit a
`load_module .../ngx_stream_module.so;` directive at the top of the
generated config.

* fix(elbv2-ui): allow single-subnet LB + full NLB protocols in listener edit

Create-LB form required min 2 subnets while the CLI/backend accept 1;
relax to min(1) to match. Listener add/edit only offered HTTP/HTTPS —
add the NLB protocol set (TCP, UDP, TLS, TCP_UDP), driven by LB type,
with cert/ssl-policy fields gated on secure protocols (HTTPS/TLS).

* feat(elbv2): active target health checks for nginx NLBs

nginx stream has no active upstream probing, so NLB targets never
advanced past initial — DescribeTargetHealth stayed initial forever and
WaitForTargetsHealthy timed out for every NLB. GetLBConfig now delivers
per-target HealthTarget specs (daemon-computed server name, probe
address, protocol/path) for network LBs; the lb-agent probes each target
every tick (TCP dial or HTTP/HTTPS GET) and reports UP/DOWN via the
existing heartbeat path, so the daemon's threshold hysteresis advances
targets to healthy. ALBs are unchanged (HAProxy stats).

Adds an Internal_NLB_UDP e2e suite gating on WaitForTargetsHealthy plus a
UDP datagram round-trip through the VIP.
This commit is contained in:
Josh Siviour
2026-06-05 00:11:51 +10:00
committed by GitHub
parent d044a4c222
commit 554c5d96c7
30 changed files with 1402 additions and 490 deletions
+1 -1
View File
@@ -602,7 +602,7 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
## ELBv2 (Application & Network Load Balancer)
The data plane uses a system-managed LB VM running HAProxy, launched automatically during `create-load-balancer`.
The data plane uses a system-managed LB VM, launched automatically during `create-load-balancer`. Application Load Balancers run **HAProxy** (L7: rules, fixed-response, redirect, HTTP/HTTPS). Network Load Balancers run **nginx `stream`** (L4: TCP, UDP, TLS, TCP_UDP) — HAProxy cannot load-balance UDP. The agent selects the engine from the `Engine` field on the config-delivery response.
### ELBv2 — Load Balancers
+4 -1
View File
@@ -19,7 +19,10 @@ INITTAB="$BUILD_DIR/inittab"
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
ALPINE_PACKAGES="busybox linux-virt haproxy iproute2 ca-certificates"
# haproxy: ALB (L7) data plane. nginx + nginx-mod-stream: NLB (L4) data plane —
# the `stream` module (a separate Alpine package) load-balances TCP/UDP/TLS,
# which haproxy cannot do for UDP.
ALPINE_PACKAGES="busybox linux-virt haproxy nginx nginx-mod-stream iproute2 ca-certificates"
echo "[build-microvm-image] repo root: $REPO_ROOT"
echo "[build-microvm-image] build dir: $BUILD_DIR"
+23 -5
View File
@@ -32,12 +32,30 @@ type GetLBConfigInput struct {
LBID *string `locationName:"LBID" type:"string"`
}
// GetLBConfigOutput returns the pre-computed HAProxy config and its hash, plus
// any TLS certificate files the config's `ssl crt` directives reference.
// GetLBConfigOutput returns the pre-computed data-plane config and its hash,
// plus any TLS certificate files the config references. Engine selects the
// data plane the agent runs: "haproxy" (ALB) or "nginx" (NLB). HealthTargets
// is populated for NLBs only: nginx `stream` has no active upstream probing,
// so the agent probes these and reports the results via heartbeat.
type GetLBConfigOutput struct {
ConfigText *string `type:"string"`
ConfigHash *string `type:"string"`
CertFiles []*CertFile `locationName:"CertFiles" type:"list"`
ConfigText *string `type:"string"`
ConfigHash *string `type:"string"`
CertFiles []*CertFile `locationName:"CertFiles" type:"list"`
Engine *string `type:"string"`
HealthTargets []*HealthTarget `locationName:"HealthTargets" type:"list"`
}
// HealthTarget is one backend the nginx agent must actively health-check.
// ServerName is daemon-computed (sanitizeName("srv", target.Id)) so it matches
// the name the health checker keys on — the agent echoes it back verbatim and
// needs no ELBv2 naming logic. Address is the ip:port to probe; Protocol is
// the target group's health-check protocol (TCP/HTTP/HTTPS); Path is the
// HTTP(S) health-check path.
type HealthTarget struct {
ServerName *string `locationName:"ServerName" type:"string"`
Address *string `locationName:"Address" type:"string"`
Protocol *string `locationName:"Protocol" type:"string"`
Path *string `locationName:"Path" type:"string"`
}
// CertFile is one TLS certificate PEM delivered to the LB agent. Path is the
+21 -84
View File
@@ -62,43 +62,7 @@ backend {{.Name}}
{{end}}
`
const haproxyTCPConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
global
log stdout format short daemon
stats socket {{.SocketPath}} mode 660 level admin
defaults
mode tcp
log global
option tcplog
timeout connect 5s
timeout client 60s
timeout server 60s
{{range .Frontends}}
frontend {{.Name}}
bind *:{{.Port}}{{if .CertPath}} ssl crt {{.CertPath}}{{end}}
default_backend {{.DefaultBackend}}
{{end}}
{{range .Backends}}
backend {{.Name}}
balance roundrobin
{{- if .TCPCheck}}
option tcp-check
{{- end}}
{{- if .HTTPCheck}}
option httpchk GET {{.HealthCheckPath}}
http-check expect status {{.HealthCheckMatcher}}
{{- end}}
{{- range .Servers}}
server {{.Name}} {{.Addr}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.Fall}} rise {{.Rise}}
{{- end}}
{{end}}
`
var (
haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
haproxyTCPTmpl = template.Must(template.New("haproxy-tcp").Parse(haproxyTCPConfigTemplate))
)
var haproxyHTTPTmpl = template.Must(template.New("haproxy").Parse(haproxyConfigTemplate))
// HAProxyConfig holds the full configuration for rendering the HAProxy template.
type HAProxyConfig struct {
@@ -145,8 +109,6 @@ type HAProxyBackend struct {
Name string
HealthCheckPath string
HealthCheckMatcher string
TCPCheck bool
HTTPCheck bool
Servers []HAProxyServer
FixedResponse *HAProxyFixedResponse
Redirect *HAProxyRedirect
@@ -196,15 +158,16 @@ func GenerateHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord,
// ARN is absent from certPEMByArn renders without `ssl crt` (HAProxy would fail
// to bind, surfacing the missing material).
func GenerateHAProxyConfigWithCerts(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
// NLBs (L4) render an nginx `stream` config — HAProxy load-balances no UDP.
if lb.Type == LoadBalancerTypeNetwork {
return generateNLBConfig(lb, listeners, tgByArn, bindAddr, certPEMByArn)
return generateNLBStreamConfig(lb, listeners, tgByArn, bindAddr, certPEMByArn)
}
return generateALBConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
}
// generateALBConfig builds an HTTP-mode HAProxy config for ALBs.
func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, false, certPEMByArn)
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, rulesByListener, bindAddr, certPEMByArn)
if err != nil {
return "", nil, err
}
@@ -217,25 +180,10 @@ func generateALBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgBy
return buf.String(), cfg.CertFiles, nil
}
// generateNLBConfig builds a TCP-mode HAProxy config for NLBs.
func generateNLBConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, bindAddr string, certPEMByArn map[string]string) (string, map[string]string, error) {
cfg, err := buildHAProxyConfig(lb, listeners, tgByArn, nil, bindAddr, true, certPEMByArn)
if err != nil {
return "", nil, err
}
var buf strings.Builder
if err := haproxyTCPTmpl.Execute(&buf, cfg); err != nil {
return "", nil, fmt.Errorf("execute haproxy tcp template: %w", err)
}
return buf.String(), cfg.CertFiles, nil
}
// buildHAProxyConfig constructs the HAProxyConfig struct shared by ALB and NLB
// generation. For ALBs, rulesByListener provides per-listener rules sorted by
// priority; nil/empty for NLBs.
func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, isTCP bool, certPEMByArn map[string]string) (HAProxyConfig, error) {
// buildHAProxyConfig constructs the HAProxyConfig struct for an ALB (L7).
// rulesByListener provides per-listener rules sorted by priority. NLBs use the
// nginx data plane (generateNLBStreamConfig), not this builder.
func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, rulesByListener map[string][]*RuleRecord, bindAddr string, certPEMByArn map[string]string) (HAProxyConfig, error) {
socketDir := filepath.Join(os.TempDir(), "spinifex-haproxy")
// Use "lb" prefix for both ALB and NLB — must match the agent's socket path.
socketPath := filepath.Join(socketDir, fmt.Sprintf("lb-%s.sock", lb.LoadBalancerID))
@@ -255,19 +203,10 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
return
}
backend := HAProxyBackend{Name: backendName}
if isTCP {
switch tg.HealthCheck.Protocol {
case ProtocolHTTP, ProtocolHTTPS:
backend.HTTPCheck = true
backend.HealthCheckPath = tg.HealthCheck.Path
backend.HealthCheckMatcher = tg.HealthCheck.Matcher
default:
backend.TCPCheck = true
}
} else {
backend.HealthCheckPath = tg.HealthCheck.Path
backend.HealthCheckMatcher = tg.HealthCheck.Matcher
backend := HAProxyBackend{
Name: backendName,
HealthCheckPath: tg.HealthCheck.Path,
HealthCheckMatcher: tg.HealthCheck.Matcher,
}
for _, target := range tg.Targets {
@@ -355,18 +294,16 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
}
}
if !isTCP {
for _, r := range rulesByListener[l.ListenerArn] {
backendName, err := registerRuleBackend(r, l, addBackend, addFixedResponseBackend, addRedirectBackend)
if err != nil {
return HAProxyConfig{}, err
}
rule, err := buildHAProxyRule(r, backendName)
if err != nil {
return HAProxyConfig{}, err
}
frontend.Rules = append(frontend.Rules, rule)
for _, r := range rulesByListener[l.ListenerArn] {
backendName, err := registerRuleBackend(r, l, addBackend, addFixedResponseBackend, addRedirectBackend)
if err != nil {
return HAProxyConfig{}, err
}
rule, err := buildHAProxyRule(r, backendName)
if err != nil {
return HAProxyConfig{}, err
}
frontend.Rules = append(frontend.Rules, rule)
}
cfg.Frontends = append(cfg.Frontends, frontend)
-244
View File
@@ -585,250 +585,6 @@ func TestHAProxyManager_RemoveConfig_Idempotent(t *testing.T) {
require.NoError(t, err)
}
// --- NLB TCP Config Generation Tests ---
func TestGenerateNLBConfig_TCPHealthCheck(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-nlb001",
Name: "my-nlb",
Type: LoadBalancerTypeNetwork,
}
tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-tg/tg-tcp001"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/net/my-nlb/lb-nlb001/lst-tcp001",
Protocol: ProtocolTCP,
Port: 5432,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArn},
},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn: {
TargetGroupArn: tgArn,
Name: "tcp-tg",
Protocol: ProtocolTCP,
HealthCheck: HealthCheckConfig{
Protocol: ProtocolTCP,
Port: "traffic-port",
IntervalSeconds: 30,
TimeoutSeconds: 10,
HealthyThreshold: 3,
UnhealthyThreshold: 3,
},
Targets: []Target{
{Id: "i-tcp001", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
{Id: "i-tcp002", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.1.5")
require.NoError(t, err)
// Verify TCP mode
assert.Contains(t, config, "mode tcp")
assert.Contains(t, config, "option tcplog")
assert.NotContains(t, config, "mode http")
assert.NotContains(t, config, "option httplog")
// Verify socket name uses generic lb- prefix (matches agent expectation)
assert.Contains(t, config, "lb-lb-nlb001.sock")
// Verify TCP health check
assert.Contains(t, config, "option tcp-check")
assert.NotContains(t, config, "option httpchk")
// Verify frontend/backend
assert.Contains(t, config, "bind *:5432")
assert.Contains(t, config, "10.0.1.10:5432")
assert.Contains(t, config, "10.0.1.11:5432")
assert.Contains(t, config, "check inter 30s fall 3 rise 3")
}
func TestGenerateNLBConfig_HTTPHealthCheckOnTCPTargetGroup(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-nlb002",
Name: "nlb-http-hc",
Type: LoadBalancerTypeNetwork,
}
tgArn := "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/tcp-http-hc/tg-tcp002"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:lst-nlb-http",
Protocol: ProtocolTCP,
Port: 8080,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArn},
},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn: {
TargetGroupArn: tgArn,
Name: "tcp-http-hc",
Protocol: ProtocolTCP,
HealthCheck: HealthCheckConfig{
Protocol: ProtocolHTTP,
Port: "traffic-port",
Path: "/healthz",
Matcher: "200-299",
IntervalSeconds: 10,
TimeoutSeconds: 5,
HealthyThreshold: 2,
UnhealthyThreshold: 3,
},
Targets: []Target{
{Id: "i-http-hc1", Port: 8080, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.2.1")
require.NoError(t, err)
// TCP mode but HTTP health check
assert.Contains(t, config, "mode tcp")
assert.Contains(t, config, "option httpchk GET /healthz")
assert.Contains(t, config, "http-check expect status 200-299")
assert.NotContains(t, config, "option tcp-check")
assert.Contains(t, config, "10.0.2.10:8080")
assert.Contains(t, config, "check inter 10s fall 3 rise 2")
}
func TestGenerateNLBConfig_MixedBackends(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-nlb003",
Name: "nlb-mixed",
Type: LoadBalancerTypeNetwork,
}
tgArnTCP := "arn:tg-tcp-mixed"
tgArnHTTP := "arn:tg-http-mixed"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:lst-tcp-mixed",
Protocol: ProtocolTCP,
Port: 5432,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArnTCP},
},
},
{
ListenerArn: "arn:lst-http-mixed",
Protocol: ProtocolTCP,
Port: 8080,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArnHTTP},
},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArnTCP: {
Protocol: ProtocolTCP,
HealthCheck: HealthCheckConfig{
Protocol: ProtocolTCP,
IntervalSeconds: 30,
HealthyThreshold: 3,
UnhealthyThreshold: 3,
},
Targets: []Target{
{Id: "i-db1", Port: 5432, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy},
},
},
tgArnHTTP: {
Protocol: ProtocolTCP,
HealthCheck: HealthCheckConfig{
Protocol: ProtocolHTTP,
Path: "/ready",
Matcher: "200",
IntervalSeconds: 15,
HealthyThreshold: 2,
UnhealthyThreshold: 2,
},
Targets: []Target{
{Id: "i-app1", Port: 8080, PrivateIP: "10.0.3.20", HealthState: TargetHealthHealthy},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "10.0.3.1")
require.NoError(t, err)
// Overall TCP mode
assert.Contains(t, config, "mode tcp")
// First backend: TCP health check
assert.Contains(t, config, "option tcp-check")
// Second backend: HTTP health check
assert.Contains(t, config, "option httpchk GET /ready")
assert.Contains(t, config, "http-check expect status 200")
// Both servers present
assert.Contains(t, config, "10.0.3.10:5432")
assert.Contains(t, config, "10.0.3.20:8080")
}
func TestGenerateNLBConfig_NoListeners(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-nlb-empty",
Type: LoadBalancerTypeNetwork,
}
config, err := GenerateHAProxyConfig(lb, nil, nil, nil, "0.0.0.0")
require.NoError(t, err)
assert.Contains(t, config, "mode tcp")
assert.Contains(t, config, "option tcplog")
assert.NotContains(t, config, "frontend")
assert.NotContains(t, config, "backend")
}
func TestGenerateNLBConfig_SkipsDrainingTargets(t *testing.T) {
lb := &LoadBalancerRecord{
LoadBalancerID: "lb-nlb-drain",
Type: LoadBalancerTypeNetwork,
}
tgArn := "arn:tg-nlb-drain"
listeners := []*ListenerRecord{
{
ListenerArn: "arn:lst-nlb-drain",
Protocol: ProtocolTCP,
Port: 3306,
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tgArn}},
},
}
tgByArn := map[string]*TargetGroupRecord{
tgArn: {
Protocol: ProtocolTCP,
HealthCheck: DefaultNLBHealthCheck(),
Targets: []Target{
{Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
{Id: "i-draining", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
{Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
},
},
}
config, err := GenerateHAProxyConfig(lb, listeners, tgByArn, nil, "0.0.0.0")
require.NoError(t, err)
assert.Contains(t, config, "10.0.0.1:3306")
assert.NotContains(t, config, "10.0.0.2") // draining
assert.NotContains(t, config, "i-no-ip") // no IP
}
func TestGenerateALBConfig_StillWorks(t *testing.T) {
// Regression: ALB (no Type set) should still generate HTTP-mode config
lb := &LoadBalancerRecord{
+237
View File
@@ -0,0 +1,237 @@
package handlers_elbv2
import (
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
"github.com/mulgadc/spinifex/spinifex/lbagent"
)
// NLB (L4) data plane. HAProxy load-balances no UDP in any release, so NLB
// listeners (TCP/UDP/TLS/TCP_UDP) render an nginx `stream` config instead;
// ALBs stay on HAProxy. See docs/development/feature/nlb-nginx-l4-dataplane.md.
//
// Health checks are passive only (max_fails / fail_timeout) — OSS nginx stream
// has no active upstream probing.
const nginxStreamConfigTemplate = `# Auto-generated by spinifex ELBv2 (NLB) — do not edit
load_module /usr/lib/nginx/modules/ngx_stream_module.so;
user root;
worker_processes auto;
error_log stderr;
pid {{.PIDPath}};
events {
worker_connections 1024;
}
stream {
{{- range .Upstreams}}
upstream {{.Name}} {
{{- range .Servers}}
server {{.Addr}}:{{.Port}} max_fails={{.MaxFails}} fail_timeout={{.FailTimeout}}s;
{{- else}}
server 127.0.0.1:1 down;
{{- end}}
}
{{end}}
{{- range .Servers}}
server {
listen {{.Port}}{{if .UDP}} udp{{end}}{{if .SSL}} ssl{{end}};
{{- if .SSL}}
ssl_certificate {{.CertPath}};
ssl_certificate_key {{.CertPath}};
{{- end}}
{{- if .UDP}}
proxy_responses 1;
proxy_timeout 10s;
{{- end}}
proxy_pass {{.Upstream}};
}
{{end}}}
`
var nginxStreamTmpl = template.Must(
template.New("nginx-stream").Parse(nginxStreamConfigTemplate),
)
// nginxConfig is the render model for the NLB nginx `stream` config.
type nginxConfig struct {
PIDPath string
Upstreams []nginxUpstream
Servers []nginxServer
}
// nginxUpstream is one target group's backend pool. An empty pool renders a
// single permanently-down placeholder so nginx (which requires >=1 server)
// still validates.
type nginxUpstream struct {
Name string
Servers []nginxUpstreamServer
}
type nginxUpstreamServer struct {
Addr string
Port int64
MaxFails int64
FailTimeout int64
}
// nginxServer is one listener's `server` block. TCP_UDP listeners expand to two
// blocks on the same port (one TCP, one UDP).
type nginxServer struct {
Port int64
UDP bool
SSL bool
CertPath string
Upstream string
}
// engineForType reports the data-plane engine for a load balancer type: nginx
// for NLBs (L4), HAProxy for everything else (ALB / L7).
func engineForType(lbType string) string {
if lbType == LoadBalancerTypeNetwork {
return EngineNginx
}
return EngineHAProxy
}
// generateNLBStreamConfig builds an nginx `stream` config for an NLB. Secure
// (TLS) listeners terminate TLS at nginx and forward plaintext to the target;
// the returned certFiles map carries each cert path → combined PEM for delivery
// to the LB agent. bindAddr is unused (nginx listens on all addresses, matching
// the HAProxy `bind *` behaviour).
func generateNLBStreamConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgByArn map[string]*TargetGroupRecord, _ string, certPEMByArn map[string]string) (string, map[string]string, error) {
cfg := nginxConfig{PIDPath: lbagent.NginxPIDPath}
var certFiles map[string]string
upstreamAdded := make(map[string]struct{})
addUpstream := func(tgArn string) string {
name := sanitizeName("us", tgArn)
if _, ok := upstreamAdded[name]; ok {
return name
}
us := nginxUpstream{Name: name}
if tg, ok := tgByArn[tgArn]; ok {
for _, t := range tg.Targets {
if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
continue
}
us.Servers = append(us.Servers, nginxUpstreamServer{
Addr: t.PrivateIP,
Port: t.Port,
MaxFails: tg.HealthCheck.UnhealthyThreshold,
FailTimeout: tg.HealthCheck.IntervalSeconds,
})
}
}
cfg.Upstreams = append(cfg.Upstreams, us)
upstreamAdded[name] = struct{}{}
return name
}
for _, l := range listeners {
if len(l.DefaultActions) == 0 {
continue
}
da := l.DefaultActions[0]
// NLB default actions are forward-only (no redirect / fixed-response).
if da.Type != ActionTypeForward || da.TargetGroupArn == "" {
continue
}
upstream := addUpstream(da.TargetGroupArn)
var certPath string
if protocolRequiresCert(l.Protocol) {
pem, _ := resolveFrontendCert(lb, l, certPEMByArn)
if pem != "" {
certPath = nginxCertPath(lb, l)
if certFiles == nil {
certFiles = make(map[string]string)
}
certFiles[certPath] = pem
}
}
switch l.Protocol {
case ProtocolUDP:
cfg.Servers = append(cfg.Servers, nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
case ProtocolTCPUDP:
cfg.Servers = append(cfg.Servers,
nginxServer{Port: l.Port, Upstream: upstream},
nginxServer{Port: l.Port, UDP: true, Upstream: upstream})
default: // TCP, TLS
cfg.Servers = append(cfg.Servers, nginxServer{
Port: l.Port,
SSL: certPath != "",
CertPath: certPath,
Upstream: upstream,
})
}
}
var buf strings.Builder
if err := nginxStreamTmpl.Execute(&buf, cfg); err != nil {
return "", nil, fmt.Errorf("execute nginx stream template: %w", err)
}
return buf.String(), certFiles, nil
}
// nginxCertPath is the stable absolute PEM path for a TLS listener under the
// agent's nginx cert dir. Daemon-rendered and agent-written paths must match.
func nginxCertPath(lb *LoadBalancerRecord, l *ListenerRecord) string {
return filepath.Join(lbagent.NginxCertDir, fmt.Sprintf("lb-%s-%s.pem", lb.LoadBalancerID, l.ListenerID))
}
// buildNLBHealthTargets computes the active health-check targets for an NLB's
// backends. nginx `stream` has no active upstream probing, so the agent probes
// these and reports the results via heartbeat. ServerName matches the health
// checker's key (sanitizeName("srv", target.Id)); the probe port resolves
// "traffic-port" to the target's port. Sorted by server name for deterministic
// delivery. The tgByArn map is the same set used to render the upstreams.
func buildNLBHealthTargets(tgByArn map[string]*TargetGroupRecord) []HealthTargetSpec {
var out []HealthTargetSpec
for _, tg := range tgByArn {
for _, t := range tg.Targets {
if t.PrivateIP == "" || t.HealthState == TargetHealthDraining {
continue
}
out = append(out, HealthTargetSpec{
ServerName: sanitizeName("srv", t.Id),
Address: fmt.Sprintf("%s:%d", t.PrivateIP, nlbHealthCheckPort(tg, t)),
Protocol: nlbHealthCheckProtocol(tg),
Path: tg.HealthCheck.Path,
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ServerName < out[j].ServerName })
return out
}
// nlbHealthCheckProtocol returns the target group's health-check protocol,
// defaulting to the NLB default (TCP) when unset.
func nlbHealthCheckProtocol(tg *TargetGroupRecord) string {
if tg.HealthCheck.Protocol != "" {
return tg.HealthCheck.Protocol
}
return DefaultNLBHealthCheckProtocol
}
// nlbHealthCheckPort resolves the probe port: an explicit numeric health-check
// port wins, otherwise "traffic-port" maps to the target's port (or the target
// group's default port when the target has none).
func nlbHealthCheckPort(tg *TargetGroupRecord, t Target) int64 {
if p := tg.HealthCheck.Port; p != "" && p != DefaultHealthCheckPort {
if n, err := strconv.ParseInt(p, 10, 64); err == nil {
return n
}
}
if t.Port != 0 {
return t.Port
}
return tg.Port
}
+188
View File
@@ -0,0 +1,188 @@
package handlers_elbv2
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func nlbHealthCheck() HealthCheckConfig {
return HealthCheckConfig{
Protocol: ProtocolTCP,
Port: "traffic-port",
IntervalSeconds: 30,
TimeoutSeconds: 10,
HealthyThreshold: 3,
UnhealthyThreshold: 3,
}
}
func tcpListener(arn, listenerID, protocol string, port int64, tgArn string) *ListenerRecord {
return &ListenerRecord{
ListenerArn: arn,
ListenerID: listenerID,
Protocol: protocol,
Port: port,
DefaultActions: []ListenerAction{
{Type: ActionTypeForward, TargetGroupArn: tgArn},
},
}
}
func nlbTG(arn string, targets ...Target) *TargetGroupRecord {
return &TargetGroupRecord{
TargetGroupArn: arn,
Protocol: ProtocolTCP,
HealthCheck: nlbHealthCheck(),
Targets: targets,
}
}
func TestGenerateNLBStream_TCP(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-tcp", Type: LoadBalancerTypeNetwork}
tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
listeners := []*ListenerRecord{tcpListener("arn:lst-tcp", "lst-tcp", ProtocolTCP, 5432, tgArn)}
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn,
Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
),
}
config, certs, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.1.5", nil)
require.NoError(t, err)
assert.Empty(t, certs)
assert.Contains(t, config, "load_module /usr/lib/nginx/modules/ngx_stream_module.so;") // stream is a separate Alpine module
assert.Contains(t, config, "stream {")
assert.NotContains(t, config, "mode tcp") // not HAProxy
assert.Contains(t, config, "listen 5432;")
assert.NotContains(t, config, "listen 5432 udp")
assert.Contains(t, config, "server 10.0.1.10:5432 max_fails=3 fail_timeout=30s;")
assert.Contains(t, config, "server 10.0.1.11:5432 max_fails=3 fail_timeout=30s;")
assert.Contains(t, config, "proxy_pass us_tg-db;")
}
func TestGenerateNLBStream_UDP(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-udp", Type: LoadBalancerTypeNetwork}
tgArn := "arn:tg-dns"
listeners := []*ListenerRecord{tcpListener("arn:lst-udp", "lst-udp", ProtocolUDP, 53, tgArn)}
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn, Target{Id: "i-dns", Port: 53, PrivateIP: "10.0.2.10", HealthState: TargetHealthHealthy}),
}
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.2.1", nil)
require.NoError(t, err)
assert.Contains(t, config, "listen 53 udp;")
assert.Contains(t, config, "proxy_responses 1;")
assert.Contains(t, config, "10.0.2.10:53")
}
func TestGenerateNLBStream_TLS(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-tls", Type: LoadBalancerTypeNetwork}
tgArn := "arn:tg-tls"
lst := tcpListener("arn:lst-tls", "lst-tls", ProtocolTLS, 443, tgArn)
lst.Certificates = []ListenerCertificate{{CertificateArn: "arn:cert-1", IsDefault: true}}
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn, Target{Id: "i-tls", Port: 8443, PrivateIP: "10.0.3.10", HealthState: TargetHealthHealthy}),
}
certPEM := map[string]string{"arn:cert-1": "LEAF\nCHAIN\nKEY\n"}
config, certs, err := GenerateHAProxyConfigWithCerts(lb, []*ListenerRecord{lst}, tgByArn, nil, "10.0.3.1", certPEM)
require.NoError(t, err)
wantPath := "/etc/nginx/certs/lb-lb-tls-lst-tls.pem"
assert.Contains(t, config, "listen 443 ssl;")
assert.Contains(t, config, "ssl_certificate "+wantPath+";")
assert.Contains(t, config, "ssl_certificate_key "+wantPath+";")
require.Contains(t, certs, wantPath)
assert.Equal(t, "LEAF\nCHAIN\nKEY\n", certs[wantPath])
}
func TestGenerateNLBStream_TCPUDP(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-both", Type: LoadBalancerTypeNetwork}
tgArn := "arn:tg-both"
listeners := []*ListenerRecord{tcpListener("arn:lst-both", "lst-both", ProtocolTCPUDP, 5000, tgArn)}
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn, Target{Id: "i-both", Port: 5000, PrivateIP: "10.0.4.10", HealthState: TargetHealthHealthy}),
}
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "10.0.4.1", nil)
require.NoError(t, err)
// Two server blocks on the same port: one TCP, one UDP.
assert.Contains(t, config, "listen 5000;")
assert.Contains(t, config, "listen 5000 udp;")
assert.Equal(t, 2, strings.Count(config, "listen 5000"))
}
func TestGenerateNLBStream_SkipsDrainingAndNoIP(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-drain", Type: LoadBalancerTypeNetwork}
tgArn := "arn:tg-drain"
listeners := []*ListenerRecord{tcpListener("arn:lst-drain", "lst-drain", ProtocolTCP, 3306, tgArn)}
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn,
Target{Id: "i-active", Port: 3306, PrivateIP: "10.0.0.1", HealthState: TargetHealthHealthy},
Target{Id: "i-drain", Port: 3306, PrivateIP: "10.0.0.2", HealthState: TargetHealthDraining},
Target{Id: "i-no-ip", Port: 3306, PrivateIP: "", HealthState: TargetHealthInitial},
),
}
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
require.NoError(t, err)
assert.Contains(t, config, "10.0.0.1:3306")
assert.NotContains(t, config, "10.0.0.2")
}
func TestGenerateNLBStream_EmptyUpstreamPlaceholder(t *testing.T) {
lb := &LoadBalancerRecord{LoadBalancerID: "lb-empty", Type: LoadBalancerTypeNetwork}
tgArn := "arn:tg-empty"
listeners := []*ListenerRecord{tcpListener("arn:lst-empty", "lst-empty", ProtocolTCP, 80, tgArn)}
tgByArn := map[string]*TargetGroupRecord{tgArn: nlbTG(tgArn)}
config, _, err := GenerateHAProxyConfigWithCerts(lb, listeners, tgByArn, nil, "0.0.0.0", nil)
require.NoError(t, err)
// nginx requires >=1 server per upstream; an empty TG renders a down placeholder.
assert.Contains(t, config, "server 127.0.0.1:1 down;")
}
func TestBuildNLBHealthTargets_Resolution(t *testing.T) {
tgArn := "arn:aws:elasticloadbalancing:us-east-1:1:targetgroup/db/tg-db"
tgByArn := map[string]*TargetGroupRecord{
tgArn: nlbTG(tgArn,
Target{Id: "i-2", Port: 5432, PrivateIP: "10.0.1.11", HealthState: TargetHealthHealthy},
Target{Id: "i-1", Port: 5432, PrivateIP: "10.0.1.10", HealthState: TargetHealthHealthy},
Target{Id: "i-drain", Port: 5432, PrivateIP: "10.0.1.12", HealthState: TargetHealthDraining},
Target{Id: "i-no-ip", Port: 5432, PrivateIP: "", HealthState: TargetHealthInitial},
),
}
got := buildNLBHealthTargets(tgByArn)
require.Len(t, got, 2) // draining + no-IP excluded
// Sorted by server name for deterministic delivery.
assert.Equal(t, sanitizeName("srv", "i-1"), got[0].ServerName)
assert.Equal(t, sanitizeName("srv", "i-2"), got[1].ServerName)
assert.Equal(t, "10.0.1.10:5432", got[0].Address) // traffic-port -> target port
assert.Equal(t, ProtocolTCP, got[0].Protocol)
}
func TestBuildNLBHealthTargets_ExplicitHCPortAndHTTP(t *testing.T) {
tgArn := "arn:tg-http"
tg := nlbTG(tgArn, Target{Id: "i-1", Port: 8080, PrivateIP: "10.0.0.9", HealthState: TargetHealthHealthy})
tg.HealthCheck.Protocol = ProtocolHTTP
tg.HealthCheck.Port = "9000"
tg.HealthCheck.Path = "/healthz"
tgByArn := map[string]*TargetGroupRecord{tgArn: tg}
got := buildNLBHealthTargets(tgByArn)
require.Len(t, got, 1)
assert.Equal(t, "10.0.0.9:9000", got[0].Address) // explicit numeric HC port wins
assert.Equal(t, ProtocolHTTP, got[0].Protocol)
assert.Equal(t, "/healthz", got[0].Path)
}
+30 -3
View File
@@ -608,6 +608,13 @@ func (s *ELBv2ServiceImpl) updateStoredConfig(lb *LoadBalancerRecord) error {
lb.ConfigText = configContent
lb.ConfigHash = hash
lb.CertFiles = certFiles
// NLBs run nginx, which has no active upstream probing — the agent
// probes these targets and reports health. ALBs report via HAProxy stats.
if lb.Type == LoadBalancerTypeNetwork {
lb.HealthTargets = buildNLBHealthTargets(tgByArn)
} else {
lb.HealthTargets = nil
}
if err := s.store.PutLoadBalancer(lb); err != nil {
slog.Error("updateStoredConfig: failed to persist LB", "lbId", lb.LoadBalancerID, "err", err)
@@ -828,12 +835,32 @@ func (s *ELBv2ServiceImpl) GetLBConfig(input *GetLBConfigInput, accountID string
}
return &GetLBConfigOutput{
ConfigText: aws.String(lb.ConfigText),
ConfigHash: aws.String(lb.ConfigHash),
CertFiles: certFilesToSDK(lb.CertFiles),
ConfigText: aws.String(lb.ConfigText),
ConfigHash: aws.String(lb.ConfigHash),
CertFiles: certFilesToSDK(lb.CertFiles),
Engine: aws.String(engineForType(lb.Type)),
HealthTargets: healthTargetsToSDK(lb.HealthTargets),
}, nil
}
// healthTargetsToSDK converts the stored health-target specs into the SDK
// HealthTarget slice delivered to the nginx agent.
func healthTargetsToSDK(specs []HealthTargetSpec) []*HealthTarget {
if len(specs) == 0 {
return nil
}
out := make([]*HealthTarget, 0, len(specs))
for i := range specs {
out = append(out, &HealthTarget{
ServerName: aws.String(specs[i].ServerName),
Address: aws.String(specs[i].Address),
Protocol: aws.String(specs[i].Protocol),
Path: aws.String(specs[i].Path),
})
}
return out
}
// certFilesToSDK converts the stored path→PEM map into the sorted CertFile
// slice delivered to the agent. Sorted for deterministic output.
func certFilesToSDK(certFiles map[string]string) []*CertFile {
@@ -1,6 +1,7 @@
package handlers_elbv2
import (
"encoding/xml"
"sort"
"strings"
"sync"
@@ -2048,6 +2049,91 @@ func TestGetLBConfig_ReturnsStoredConfig(t *testing.T) {
assert.Equal(t, "deadbeef", *out.ConfigHash)
}
func TestGetLBConfig_DeliversHealthTargetsForNLB(t *testing.T) {
svc := setupTestService(t)
lb := &LoadBalancerRecord{
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/nlb-hc/lb-nlbhc",
LoadBalancerID: "lb-nlbhc",
Name: "nlb-hc",
Type: LoadBalancerTypeNetwork,
State: StateActive,
ConfigText: "stream {}\n",
ConfigHash: "hc1",
AccountID: testAccountID,
HealthTargets: []HealthTargetSpec{
{ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolTCP},
},
}
require.NoError(t, svc.store.PutLoadBalancer(lb))
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-nlbhc")}, testAccountID)
require.NoError(t, err)
assert.Equal(t, EngineNginx, *out.Engine)
require.Len(t, out.HealthTargets, 1)
assert.Equal(t, "srv_i-1", *out.HealthTargets[0].ServerName)
assert.Equal(t, "10.0.0.1:80", *out.HealthTargets[0].Address)
assert.Equal(t, ProtocolTCP, *out.HealthTargets[0].Protocol)
}
func TestGetLBConfig_HealthTargetsWireShape(t *testing.T) {
svc := setupTestService(t)
require.NoError(t, svc.store.PutLoadBalancer(&LoadBalancerRecord{
LoadBalancerID: "lb-wire",
Type: LoadBalancerTypeNetwork,
AccountID: testAccountID,
ConfigText: "stream {}\n",
ConfigHash: "w1",
HealthTargets: []HealthTargetSpec{
{ServerName: "srv_i-1", Address: "10.0.0.1:80", Protocol: ProtocolHTTP, Path: "/healthz"},
},
}))
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-wire")}, testAccountID)
require.NoError(t, err)
// Confirm the marshalled member shape matches what the lb-agent parses
// (GetLBConfigResult>HealthTargets>member>{ServerName,Address,Protocol,Path}).
payload := utils.GenerateIAMXMLPayload("GetLBConfig", *out)
xmlBytes, err := utils.MarshalToXML(payload)
require.NoError(t, err)
var parsed struct {
Members []struct {
ServerName string `xml:"ServerName"`
Address string `xml:"Address"`
Protocol string `xml:"Protocol"`
Path string `xml:"Path"`
} `xml:"GetLBConfigResult>HealthTargets>member"`
}
require.NoError(t, xml.Unmarshal(xmlBytes, &parsed))
require.Len(t, parsed.Members, 1)
assert.Equal(t, "srv_i-1", parsed.Members[0].ServerName)
assert.Equal(t, "10.0.0.1:80", parsed.Members[0].Address)
assert.Equal(t, ProtocolHTTP, parsed.Members[0].Protocol)
assert.Equal(t, "/healthz", parsed.Members[0].Path)
}
func TestGetLBConfig_NoHealthTargetsForALB(t *testing.T) {
svc := setupTestService(t)
lb := &LoadBalancerRecord{
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb-hc/lb-albhc",
LoadBalancerID: "lb-albhc",
Name: "alb-hc",
Type: LoadBalancerTypeApplication,
State: StateActive,
ConfigText: "global\n",
ConfigHash: "hc2",
AccountID: testAccountID,
}
require.NoError(t, svc.store.PutLoadBalancer(lb))
out, err := svc.GetLBConfig(&GetLBConfigInput{LBID: aws.String("lb-albhc")}, testAccountID)
require.NoError(t, err)
assert.Equal(t, EngineHAProxy, *out.Engine)
assert.Empty(t, out.HealthTargets) // ALBs report health via HAProxy stats
}
func TestGetLBConfig_MissingLBID(t *testing.T) {
svc := setupTestService(t)
+43 -25
View File
@@ -40,6 +40,12 @@ const (
ProtocolTLS = "TLS"
ProtocolTCPUDP = "TCP_UDP"
// Data-plane engines. ALBs (L7) run HAProxy; NLBs (L4) run nginx `stream`
// because HAProxy cannot load-balance UDP. The agent learns which engine to
// run from the GetLBConfig response.
EngineHAProxy = "haproxy"
EngineNginx = "nginx"
// DefaultSslPolicy is applied to a secure listener when the caller does
// not specify an SslPolicy.
DefaultSslPolicy = "ELBSecurityPolicy-2016-08"
@@ -94,31 +100,43 @@ const (
// LoadBalancerRecord represents a stored load balancer (ALB or NLB).
type LoadBalancerRecord struct {
LoadBalancerArn string `json:"load_balancer_arn"`
LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
DNSName string `json:"dns_name"`
Name string `json:"name"`
Scheme string `json:"scheme"` // "internet-facing" or "internal"
Type string `json:"type"` // Always "application"
State string `json:"state"` // "provisioning", "active", "failed"
VpcId string `json:"vpc_id"`
SecurityGroups []string `json:"security_groups"`
Subnets []string `json:"subnets"`
AvailZones []AvailZoneInfo `json:"availability_zones"`
ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText + cert material
CertFiles map[string]string `json:"cert_files,omitempty"` // Absolute path → combined PEM (cert+chain+key), delivered with config
LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
NodeID string `json:"node_id"` // Daemon node running this ALB
IPAddressType string `json:"ip_address_type"` // "ipv4"
Attributes map[string]string `json:"attributes,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
LoadBalancerArn string `json:"load_balancer_arn"`
LoadBalancerID string `json:"load_balancer_id"` // Short ID (hex suffix)
DNSName string `json:"dns_name"`
Name string `json:"name"`
Scheme string `json:"scheme"` // "internet-facing" or "internal"
Type string `json:"type"` // Always "application"
State string `json:"state"` // "provisioning", "active", "failed"
VpcId string `json:"vpc_id"`
SecurityGroups []string `json:"security_groups"`
Subnets []string `json:"subnets"`
AvailZones []AvailZoneInfo `json:"availability_zones"`
ENIs []string `json:"enis,omitempty"` // ENI IDs created for this ALB (internal)
InstanceID string `json:"instance_id,omitempty"` // ALB VM instance ID (system-managed)
VPCIP string `json:"vpc_ip,omitempty"` // VPC private IP of the ALB VM
ConfigText string `json:"config_text,omitempty"` // Pre-computed HAProxy config
ConfigHash string `json:"config_hash,omitempty"` // SHA256 of ConfigText + cert material
CertFiles map[string]string `json:"cert_files,omitempty"` // Absolute path → combined PEM (cert+chain+key), delivered with config
HealthTargets []HealthTargetSpec `json:"health_targets,omitempty"` // NLB only: backends the nginx agent actively probes (HAProxy reports its own)
LastHeartbeat time.Time `json:"last_heartbeat"` // Last agent heartbeat timestamp
HostPorts map[int]int `json:"host_ports,omitempty"` // Dev mode: guest port → host port forwarding
NodeID string `json:"node_id"` // Daemon node running this ALB
IPAddressType string `json:"ip_address_type"` // "ipv4"
Attributes map[string]string `json:"attributes,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
AccountID string `json:"account_id"`
CreatedAt time.Time `json:"created_at"`
}
// HealthTargetSpec is a backend the nginx agent actively health-checks. It is
// computed when the NLB config is stored and delivered to the agent via
// GetLBConfig. ServerName matches the health checker's key
// (sanitizeName("srv", target.Id)).
type HealthTargetSpec struct {
ServerName string `json:"server_name"`
Address string `json:"address"` // ip:port to probe
Protocol string `json:"protocol"` // TCP | HTTP | HTTPS
Path string `json:"path,omitempty"`
}
// AvailZoneInfo tracks subnet-to-AZ mapping for a load balancer.
+111 -34
View File
@@ -37,10 +37,27 @@ const (
// rejects any delivered path that escapes it.
CertDir = "/etc/haproxy/certs"
// Nginx paths for the NLB (L4) data plane. NLBs render an nginx `stream`
// config because HAProxy cannot load-balance UDP; ALBs keep using the
// HAProxy paths above. NginxCertDir holds TLS PEM files for TLS listeners
// (referenced by both ssl_certificate and ssl_certificate_key).
NginxConfigPath = "/etc/nginx/nginx.conf"
NginxPIDPath = "/run/nginx.pid"
NginxCertDir = "/etc/nginx/certs"
// pollInterval is how often the agent sends a heartbeat.
pollInterval = 5 * time.Second
)
// Data-plane engines, duplicated from handlers/elbv2 to avoid an import cycle
// (handlers/elbv2 imports this package for path constants). The agent runs
// HAProxy for ALBs and nginx `stream` for NLBs, selected by the GetLBConfig
// response.
const (
EngineHAProxy = "haproxy"
EngineNginx = "nginx"
)
// Agent manages HAProxy configuration inside an LB VM by polling the gateway.
type Agent struct {
lbID string
@@ -56,12 +73,17 @@ type Agent struct {
client *http.Client
localConfigHash string
engine string // data-plane engine of the last applied config
healthTargets []HealthTarget // active probe targets (nginx/NLB only)
stopCh chan struct{}
// For testing: override the reload function.
reloadFn func(configPath, pidPath string) error
// For testing: override the reload functions (HAProxy / nginx).
reloadFn func(configPath, pidPath string) error
reloadNginxFn func(configPath, pidPath string) error
// For testing: override the stats query function.
statsFn func(socketPath string) ([]ServerStatus, error)
// For testing: override the active health prober (nginx/NLB).
probeFn func(targets []HealthTarget) []ServerStatus
}
// New creates a new LB agent for the given load balancer.
@@ -93,19 +115,21 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
}
return &Agent{
lbID: lbID,
gatewayURL: strings.TrimRight(gatewayURL, "/"),
region: region,
configPath: DefaultConfigPath,
pidPath: DefaultPIDPath,
certDir: CertDir,
socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
accessKey: accessKey,
secretKey: secretKey,
client: client,
stopCh: make(chan struct{}),
reloadFn: reloadHAProxy,
statsFn: queryHAProxyStats,
lbID: lbID,
gatewayURL: strings.TrimRight(gatewayURL, "/"),
region: region,
configPath: DefaultConfigPath,
pidPath: DefaultPIDPath,
certDir: CertDir,
socketPath: fmt.Sprintf("/tmp/spinifex-haproxy/lb-%s.sock", lbID),
accessKey: accessKey,
secretKey: secretKey,
client: client,
stopCh: make(chan struct{}),
reloadFn: reloadHAProxy,
reloadNginxFn: reloadNginx,
statsFn: queryHAProxyStats,
probeFn: probeHealthTargets,
}, nil
}
@@ -141,9 +165,17 @@ func (a *Agent) Stop() {
// tick runs one heartbeat cycle: send health, check config hash, fetch if changed.
func (a *Agent) tick() {
servers, err := a.statsFn(a.socketPath)
if err != nil {
slog.Warn("HAProxy stats unavailable", "err", err)
// nginx (NLB) has no per-server stats socket — the agent actively probes
// the delivered health targets instead of reading HAProxy stats.
var servers []ServerStatus
if a.engine == EngineNginx {
servers = a.probeFn(a.healthTargets)
} else {
var statsErr error
servers, statsErr = a.statsFn(a.socketPath)
if statsErr != nil {
slog.Warn("HAProxy stats unavailable", "err", statsErr)
}
}
resp, err := a.sendHeartbeat(servers)
@@ -179,10 +211,21 @@ type certFile struct {
// configResponse is the parsed XML response from GetLBConfig.
type configResponse struct {
XMLName xml.Name `xml:"GetLBConfigResponse"`
ConfigText string `xml:"GetLBConfigResult>ConfigText"`
ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
CertFiles []certFile `xml:"GetLBConfigResult>CertFiles>member"`
XMLName xml.Name `xml:"GetLBConfigResponse"`
ConfigText string `xml:"GetLBConfigResult>ConfigText"`
ConfigHash string `xml:"GetLBConfigResult>ConfigHash"`
Engine string `xml:"GetLBConfigResult>Engine"`
CertFiles []certFile `xml:"GetLBConfigResult>CertFiles>member"`
HealthTargets []HealthTarget `xml:"GetLBConfigResult>HealthTargets>member"`
}
// enginePaths returns the data-plane file paths and reload function for the
// given engine, defaulting to HAProxy when the gateway omits Engine.
func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string, reload func(string, string) error) {
if engine == EngineNginx {
return NginxConfigPath, NginxPIDPath, NginxCertDir, a.reloadNginxFn
}
return a.configPath, a.pidPath, a.certDir, a.reloadFn
}
// sendHeartbeat sends a heartbeat with health report to the gateway.
@@ -232,22 +275,31 @@ func (a *Agent) fetchAndApplyConfig() error {
return fmt.Errorf("empty config returned")
}
// Cert files must land before the config that references them via
// `ssl crt`, otherwise HAProxy fails to start/reload on a missing path.
if err := a.writeCertFiles(resp.CertFiles); err != nil {
engine := resp.Engine
if engine == "" {
engine = EngineHAProxy
}
configPath, pidPath, certDir, reload := a.enginePaths(engine)
// Cert files must land before the config that references them (via
// `ssl crt` / `ssl_certificate`), otherwise the engine fails to
// start/reload on a missing path.
if err := a.writeCertFiles(certDir, resp.CertFiles); err != nil {
return fmt.Errorf("write cert files: %w", err)
}
if err := WriteConfig(a.configPath, resp.ConfigText); err != nil {
if err := WriteConfig(configPath, resp.ConfigText); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := a.reloadFn(a.configPath, a.pidPath); err != nil {
return fmt.Errorf("reload haproxy: %w", err)
if err := reload(configPath, pidPath); err != nil {
return fmt.Errorf("reload %s: %w", engine, err)
}
a.engine = engine
a.healthTargets = resp.HealthTargets
a.localConfigHash = resp.ConfigHash
slog.Info("Config applied", "hash", resp.ConfigHash)
slog.Info("Config applied", "engine", engine, "hash", resp.ConfigHash, "healthTargets", len(resp.HealthTargets))
return nil
}
@@ -285,21 +337,21 @@ func (a *Agent) signedPost(params url.Values) ([]byte, error) {
}
// writeCertFiles writes each delivered TLS certificate PEM to its path 0600,
// creating the cert dir as needed. Every path is constrained to a.certDir — a
// creating certDir as needed. Every path is constrained to certDir — a
// delivered path that escapes it (traversal, absolute elsewhere) is rejected
// rather than written, so a malformed/hostile response can't drop files across
// the filesystem.
func (a *Agent) writeCertFiles(certs []certFile) error {
func (a *Agent) writeCertFiles(certDir string, certs []certFile) error {
if len(certs) == 0 {
return nil
}
if err := os.MkdirAll(a.certDir, 0o750); err != nil {
if err := os.MkdirAll(certDir, 0o750); err != nil {
return fmt.Errorf("create cert dir: %w", err)
}
for _, c := range certs {
clean := filepath.Clean(c.Path)
if clean != filepath.Join(a.certDir, filepath.Base(clean)) {
return fmt.Errorf("cert path %q escapes %s", c.Path, a.certDir)
if clean != filepath.Join(certDir, filepath.Base(clean)) {
return fmt.Errorf("cert path %q escapes %s", c.Path, certDir)
}
if c.PEM == "" {
return fmt.Errorf("cert %q has empty PEM", c.Path)
@@ -357,6 +409,31 @@ func reloadHAProxy(configPath, pidPath string) error {
return nil
}
// reloadNginx validates then starts or reloads nginx for the NLB data plane.
// `nginx -t` fails closed before any reload, mirroring HAProxy's
// validate-before-bind guarantee (a bad config never replaces a good one).
func reloadNginx(configPath, pidPath string) error {
test := exec.Command("nginx", "-t", "-c", configPath)
if out, err := test.CombinedOutput(); err != nil {
return fmt.Errorf("nginx -t: %s: %w", strings.TrimSpace(string(out)), err)
}
var cmd *exec.Cmd
if readPID(pidPath) > 0 {
// Master running: signal a graceful reload.
cmd = exec.Command("nginx", "-s", "reload", "-c", configPath)
} else {
// Fresh start (nginx daemonizes; pid path comes from the config).
cmd = exec.Command("nginx", "-c", configPath)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nginx: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// readPID reads the HAProxy PID from the PID file. Returns 0 if unavailable.
func readPID(pidPath string) int {
data, err := os.ReadFile(pidPath)
+4 -4
View File
@@ -22,7 +22,7 @@ func TestWriteCertFiles_WritesPEM0600(t *testing.T) {
a := newCertTestAgent(t)
path := filepath.Join(a.certDir, "lb-1-lst.pem")
err := a.writeCertFiles([]certFile{{Path: path, PEM: "LEAF\nKEY\n"}})
err := a.writeCertFiles(a.certDir, []certFile{{Path: path, PEM: "LEAF\nKEY\n"}})
require.NoError(t, err)
data, err := os.ReadFile(path)
@@ -38,7 +38,7 @@ func TestWriteCertFiles_WritesPEM0600(t *testing.T) {
func TestWriteCertFiles_Empty(t *testing.T) {
a := newCertTestAgent(t)
assert.NoError(t, a.writeCertFiles(nil))
assert.NoError(t, a.writeCertFiles(a.certDir, nil))
}
func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
@@ -49,7 +49,7 @@ func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
filepath.Join(a.certDir, "sub/dir/x.pem"),
}
for _, p := range cases {
err := a.writeCertFiles([]certFile{{Path: p, PEM: "X"}})
err := a.writeCertFiles(a.certDir, []certFile{{Path: p, PEM: "X"}})
require.Error(t, err, "path %q must be rejected", p)
assert.Contains(t, err.Error(), "escapes")
}
@@ -57,7 +57,7 @@ func TestWriteCertFiles_RejectsTraversal(t *testing.T) {
func TestWriteCertFiles_RejectsEmptyPEM(t *testing.T) {
a := newCertTestAgent(t)
err := a.writeCertFiles([]certFile{{Path: filepath.Join(a.certDir, "x.pem"), PEM: ""}})
err := a.writeCertFiles(a.certDir, []certFile{{Path: filepath.Join(a.certDir, "x.pem"), PEM: ""}})
require.Error(t, err)
assert.Contains(t, err.Error(), "empty PEM")
}
+94
View File
@@ -353,6 +353,100 @@ func TestHeartbeat_StatsError(t *testing.T) {
agent.tick()
}
func TestEnginePaths_Nginx(t *testing.T) {
agent := newTestAgent(t, "http://example.invalid")
var nginxCalled, haproxyCalled bool
agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
cfg, pid, certDir, reload := agent.enginePaths(EngineNginx)
if cfg != NginxConfigPath || pid != NginxPIDPath || certDir != NginxCertDir {
t.Errorf("nginx paths = %q,%q,%q want %q,%q,%q", cfg, pid, certDir, NginxConfigPath, NginxPIDPath, NginxCertDir)
}
_ = reload("", "")
if !nginxCalled || haproxyCalled {
t.Errorf("nginx engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
}
}
func TestEnginePaths_HAProxyDefault(t *testing.T) {
agent := newTestAgent(t, "http://example.invalid")
var nginxCalled, haproxyCalled bool
agent.reloadNginxFn = func(_, _ string) error { nginxCalled = true; return nil }
agent.reloadFn = func(_, _ string) error { haproxyCalled = true; return nil }
// Both explicit haproxy and an empty engine fall through to HAProxy.
for _, engine := range []string{EngineHAProxy, ""} {
cfg, pid, certDir, reload := agent.enginePaths(engine)
if cfg != agent.configPath || pid != agent.pidPath || certDir != agent.certDir {
t.Errorf("engine %q paths = %q,%q,%q want haproxy defaults", engine, cfg, pid, certDir)
}
_ = reload("", "")
}
if nginxCalled || !haproxyCalled {
t.Errorf("haproxy/empty engine routed to wrong reload (nginx=%v haproxy=%v)", nginxCalled, haproxyCalled)
}
}
func TestTick_NginxSkipsStats(t *testing.T) {
gw := fakeGateway(t, "h1", "", "active")
defer gw.Close()
agent := newTestAgent(t, gw.URL)
agent.localConfigHash = "h1"
agent.engine = EngineNginx // stats poll is HAProxy-only
statsCalled := false
agent.statsFn = func(_ string) ([]ServerStatus, error) {
statsCalled = true
return nil, nil
}
agent.tick()
if statsCalled {
t.Error("nginx engine must not poll HAProxy stats")
}
}
func TestTick_NginxProbesHealthTargets(t *testing.T) {
var receivedServer, receivedStatus string
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
if r.FormValue("Action") == "LBAgentHeartbeat" {
receivedServer = r.FormValue("Servers.member.1.Server")
receivedStatus = r.FormValue("Servers.member.1.Status")
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
return
}
http.Error(w, "unexpected", http.StatusBadRequest)
}))
defer gw.Close()
agent := newTestAgent(t, gw.URL)
agent.localConfigHash = "h1" // match to avoid config fetch
agent.engine = EngineNginx
agent.healthTargets = []HealthTarget{{ServerName: "srv_i-web1", Address: "10.0.0.5:80", Protocol: "TCP"}}
statsCalled := false
agent.statsFn = func(_ string) ([]ServerStatus, error) { statsCalled = true; return nil, nil }
probedTargets := 0
agent.probeFn = func(targets []HealthTarget) []ServerStatus {
probedTargets = len(targets)
return []ServerStatus{{Server: "srv_i-web1", Status: "UP"}}
}
agent.tick()
if statsCalled {
t.Error("nginx engine must not poll HAProxy stats")
}
if probedTargets != 1 {
t.Errorf("probeFn saw %d targets, want 1", probedTargets)
}
if receivedServer != "srv_i-web1" || receivedStatus != "UP" {
t.Errorf("heartbeat server/status = %q/%q, want srv_i-web1/UP", receivedServer, receivedStatus)
}
}
func TestHeartbeat_EmptyConfigHash(t *testing.T) {
// Gateway returns empty config hash (no config stored yet)
gw := fakeGateway(t, "", "", "provisioning")
+94
View File
@@ -0,0 +1,94 @@
package lbagent
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
)
// HealthTarget is one backend the agent must actively health-check, delivered
// by GetLBConfig for the nginx (NLB) data plane. OSS nginx `stream` has no
// active upstream probing, so the agent probes each target every tick and
// reports the result. ServerName is the daemon-computed name the health checker
// keys on — echoed back verbatim so the agent needs no ELBv2 naming logic.
type HealthTarget struct {
ServerName string `xml:"ServerName"`
Address string `xml:"Address"` // ip:port to probe
Protocol string `xml:"Protocol"` // TCP | HTTP | HTTPS
Path string `xml:"Path"` // HTTP(S) path
}
// probeTimeout bounds each individual target probe.
const probeTimeout = 3 * time.Second
// probeHealthTargets actively probes every delivered health target and returns
// one ServerStatus per target (Status "UP"/"DOWN"). The Server field carries
// the daemon-supplied ServerName so the daemon health checker matches it
// directly. An empty target list yields a nil report (the daemon early-returns
// on empty reports, leaving health untouched).
func probeHealthTargets(targets []HealthTarget) []ServerStatus {
if len(targets) == 0 {
return nil
}
out := make([]ServerStatus, 0, len(targets))
for _, t := range targets {
status := "DOWN"
if probeOne(t) {
status = "UP"
}
out = append(out, ServerStatus{Server: t.ServerName, Status: status})
}
return out
}
// probeOne performs a single health check against one target. TCP succeeds on a
// completed dial; HTTP/HTTPS succeeds on a response status < 400 (HTTPS skips
// certificate verification, matching how AWS NLB HTTPS health checks treat the
// target's self-signed cert).
func probeOne(t HealthTarget) bool {
switch strings.ToUpper(t.Protocol) {
case "HTTP", "HTTPS":
return probeHTTP(t)
default: // TCP and anything else falls back to a connect probe
conn, err := net.DialTimeout("tcp", t.Address, probeTimeout)
if err != nil {
slog.Debug("TCP probe failed", "addr", t.Address, "err", err)
return false
}
_ = conn.Close()
return true
}
}
// probeHTTP issues a GET to the target's health-check path and reports whether
// the response status is below 400.
func probeHTTP(t HealthTarget) bool {
scheme := "http"
client := &http.Client{Timeout: probeTimeout}
if strings.EqualFold(t.Protocol, "HTTPS") {
scheme = "https"
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // target uses a self-signed cert; NLB HTTPS checks don't verify it
}
}
path := t.Path
if path == "" {
path = "/"
} else if !strings.HasPrefix(path, "/") {
path = "/" + path
}
url := fmt.Sprintf("%s://%s%s", scheme, t.Address, path)
resp, err := client.Get(url) //nolint:noctx // short fixed-timeout probe
if err != nil {
slog.Debug("HTTP probe failed", "url", url, "err", err)
return false
}
defer resp.Body.Close()
return resp.StatusCode < 400
}
+96
View File
@@ -0,0 +1,96 @@
package lbagent
import (
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestProbeHealthTargets_Empty(t *testing.T) {
if got := probeHealthTargets(nil); got != nil {
t.Errorf("empty targets = %v, want nil (daemon early-returns on empty report)", got)
}
}
func TestProbeOne_TCP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
up := HealthTarget{ServerName: "srv_i-up", Address: ln.Addr().String(), Protocol: "TCP"}
if !probeOne(up) {
t.Error("open TCP port probed DOWN, want UP")
}
// Closed port: pick the listener's port after closing it.
addr := ln.Addr().String()
ln.Close()
down := HealthTarget{ServerName: "srv_i-down", Address: addr, Protocol: "TCP"}
if probeOne(down) {
t.Error("closed TCP port probed UP, want DOWN")
}
}
func TestProbeOne_HTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
addr := srv.Listener.Addr().String()
up := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/healthz"}
if !probeOne(up) {
t.Error("200 health path probed DOWN, want UP")
}
down := HealthTarget{ServerName: "srv_i-h", Address: addr, Protocol: "HTTP", Path: "/bad"}
if probeOne(down) {
t.Error("500 health path probed UP, want DOWN")
}
}
func TestProbeHealthTargets_ReportsServerName(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
out := probeHealthTargets([]HealthTarget{
{ServerName: "srv_i-abc", Address: ln.Addr().String(), Protocol: "TCP"},
})
if len(out) != 1 {
t.Fatalf("got %d statuses, want 1", len(out))
}
if out[0].Server != "srv_i-abc" {
t.Errorf("Server = %q, want %q (daemon health checker keys on it)", out[0].Server, "srv_i-abc")
}
if out[0].Status != "UP" {
t.Errorf("Status = %q, want UP", out[0].Status)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Ar as e,Cr as t,Dr as n,Or as r,kr as i,wr as a}from"./index.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=n().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=r({key:e().min(1,`Tag key is required`).max(128),value:e().max(256)}),d=r({protocol:t([`HTTP`,`TCP`]),path:e().min(1,`Path is required`).max(1024),port:e().min(1),intervalSeconds:n().int().min(5).max(300),timeoutSeconds:n().int().min(2).max(120),healthyThresholdCount:n().int().min(2).max(10),unhealthyThresholdCount:n().int().min(2).max(10),matcher:e().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=r({name:c,protocol:t([`HTTP`,`HTTPS`,`TCP`,`UDP`,`TLS`,`TCP_UDP`]),port:l,vpcId:e().min(1,`VPC is required`),healthCheck:d,tags:a(u)}),p=`ELBSecurityPolicy-2016-08`,m=[`application`,`network`],h=[`HTTP`,`HTTPS`],g=[`TCP`,`UDP`,`TLS`,`TCP_UDP`],_=[...h,...g];function v(e){return e===`network`?g:h}function y(e){return e===`network`?`TCP`:`HTTP`}function b(e){return e===`HTTPS`||e===`TLS`}var x=r({protocol:t([`HTTP`,`HTTPS`]),port:l,defaultTargetGroupArn:e().min(1,`Target group is required`),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.protocol===`HTTPS`&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS`})}),S=/^[!-~]+$/,C=r({field:t([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:e().max(40).optional(),rawValues:e().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!S.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),w=r({priority:n().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:a(C).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:e().min(1,`Target group is required`)}),T=r({name:s,type:t(m),scheme:t([`internet-facing`,`internal`]),vpcId:e().min(1,`VPC is required`),subnetIds:a(e()).min(2,`At least 2 subnets are required`),securityGroupIds:a(e()),tags:a(u),listener:r({protocol:t(_),port:l,targetGroupMode:t([`new`,`existing`]),existingTargetGroupArn:e().optional(),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.targetGroupMode===`existing`&&!e.existingTargetGroupArn&&t.addIssue({code:`custom`,path:[`existingTargetGroupArn`],message:`Target group is required`}),b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS/TLS`})})});r({targets:a(r({instanceId:e().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),i(e(),e());export{f as a,v as c,w as i,x as n,y as o,T as r,b as s,p as t};
import{Ar as e,Cr as t,Dr as n,Or as r,kr as i,wr as a}from"./index.js";var o=/^(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])$/,s=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`).refine(e=>!e.startsWith(`internal-`),`Name cannot start with 'internal-'`),c=e().min(1,`Name is required`).max(32,`Name must be 32 characters or less`).regex(o,`Name may contain only letters, digits, and hyphens; must start and end with alphanumeric`),l=n().int(`Port must be a whole number`).min(1,`Port must be at least 1`).max(65535,`Port must be at most 65535`),u=r({key:e().min(1,`Tag key is required`).max(128),value:e().max(256)}),d=r({protocol:t([`HTTP`,`TCP`]),path:e().min(1,`Path is required`).max(1024),port:e().min(1),intervalSeconds:n().int().min(5).max(300),timeoutSeconds:n().int().min(2).max(120),healthyThresholdCount:n().int().min(2).max(10),unhealthyThresholdCount:n().int().min(2).max(10),matcher:e().regex(/^\d{3}(?:[-,]\d{3})*$/,`Matcher must be HTTP codes like 200 or 200-299 or 200,201`)}),f=r({name:c,protocol:t([`HTTP`,`HTTPS`,`TCP`,`UDP`,`TLS`,`TCP_UDP`]),port:l,vpcId:e().min(1,`VPC is required`),healthCheck:d,tags:a(u)}),p=`ELBSecurityPolicy-2016-08`,m=[`application`,`network`],h=[`HTTP`,`HTTPS`],g=[`TCP`,`UDP`,`TLS`,`TCP_UDP`],_=[...h,...g];function v(e){return e===`network`?g:h}function y(e){return e===`network`?`TCP`:`HTTP`}function b(e){return e===`HTTPS`||e===`TLS`}var x=r({protocol:t(_),port:l,defaultTargetGroupArn:e().min(1,`Target group is required`),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS and TLS`})}),S=/^[!-~]+$/,C=r({field:t([`host-header`,`path-pattern`,`http-header`,`http-request-method`,`source-ip`,`query-string`]),httpHeaderName:e().max(40).optional(),rawValues:e().min(1,`At least one value is required`).max(640,`Too many values`)}).superRefine((e,t)=>{e.field===`http-header`&&!e.httpHeaderName&&t.addIssue({code:`custom`,path:[`httpHeaderName`],message:`Header name is required`});let n=e.rawValues.split(/[\n,]/).map(e=>e.trim()).filter(e=>e.length>0);if(n.length===0||n.length>5){t.addIssue({code:`custom`,path:[`rawValues`],message:`Enter 1 to 5 values, comma or newline separated`});return}for(let e of n)if(!S.test(e)){t.addIssue({code:`custom`,path:[`rawValues`],message:`Value "${e}" contains whitespace or non-ASCII characters`});return}}),w=r({priority:n().int(`Priority must be a whole number`).min(1,`Priority must be at least 1`).max(5e4,`Priority must be at most 50000`),conditions:a(C).min(1,`At least one condition is required`).max(5,`Up to 5 conditions per rule`),forwardTargetGroupArn:e().min(1,`Target group is required`)}),T=r({name:s,type:t(m),scheme:t([`internet-facing`,`internal`]),vpcId:e().min(1,`VPC is required`),subnetIds:a(e()).min(1,`At least 1 subnet is required`),securityGroupIds:a(e()),tags:a(u),listener:r({protocol:t(_),port:l,targetGroupMode:t([`new`,`existing`]),existingTargetGroupArn:e().optional(),certificateArn:e().optional(),sslPolicy:e().optional()}).superRefine((e,t)=>{e.targetGroupMode===`existing`&&!e.existingTargetGroupArn&&t.addIssue({code:`custom`,path:[`existingTargetGroupArn`],message:`Target group is required`}),b(e.protocol)&&!e.certificateArn&&t.addIssue({code:`custom`,path:[`certificateArn`],message:`A certificate is required for HTTPS/TLS`})})});r({targets:a(r({instanceId:e().min(1,`Instance is required`),port:l.optional()})).min(1,`At least one target is required`)}),i(e(),e());export{f as a,v as c,w as i,x as n,y as o,T as r,b as s,p as t};
@@ -12,7 +12,10 @@ import {
createTestQueryClient,
renderWithClient,
} from "@/test/elbv2-integration"
import type { CreateListenerFormData } from "@/types/elbv2"
import {
ALL_LISTENER_PROTOCOLS,
type CreateListenerFormData,
} from "@/types/elbv2"
import { ListenerForm } from "./listener-form"
@@ -63,6 +66,7 @@ function Harness({
<ListenerForm
certificates={CERTS}
form={form}
protocols={ALL_LISTENER_PROTOCOLS}
sslPolicies={POLICIES}
targetGroups={targetGroups}
/>
@@ -128,6 +132,7 @@ describe("ListenerForm", () => {
<ListenerForm
certificates={CERTS}
form={form}
protocols={ALL_LISTENER_PROTOCOLS}
sslPolicies={POLICIES}
targetGroups={TGS}
/>
@@ -18,10 +18,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { type CreateListenerFormData, DEFAULT_SSL_POLICY } from "@/types/elbv2"
import {
type CreateListenerFormData,
DEFAULT_SSL_POLICY,
isSecureProtocol,
type ListenerProtocolValue,
} from "@/types/elbv2"
interface ListenerFormProps {
form: UseFormReturn<CreateListenerFormData>
protocols: readonly ListenerProtocolValue[]
targetGroups: TargetGroup[]
certificates?: CertificateSummary[]
sslPolicies?: SslPolicy[]
@@ -37,6 +43,7 @@ function certLabel(cert: CertificateSummary): string {
export function ListenerForm({
form,
protocols,
targetGroups,
certificates = [],
sslPolicies = [],
@@ -53,19 +60,28 @@ export function ListenerForm({
const protocol = useWatch({ control, name: "protocol" })
const isHttps = protocol === "HTTPS"
const isSecure = isSecureProtocol(protocol)
// Side-effects of a protocol switch, applied after the field's own onChange:
// adjust the default port (only when still on the other protocol's default)
// and toggle the TLS fields. Moving to HTTP clears cert + policy so the
// server doesn't reject them.
const applyProtocolDefaults = (next: "HTTP" | "HTTPS" | null) => {
// bump the default port (only when still on the other protocol's default) and
// toggle the TLS fields. A secure protocol (HTTPS/TLS) keeps the certificate;
// an SSL policy is HTTPS-only. Moving to an insecure protocol clears cert +
// policy so the server doesn't reject them.
const applyProtocolDefaults = (next: ListenerProtocolValue | null) => {
if (!next) {
return
}
const current = getValues("port")
if (next === "HTTPS") {
if (isSecureProtocol(next)) {
if (current === HTTP_PORT) {
setValue("port", HTTPS_PORT)
}
if (!getValues("sslPolicy")) {
setValue("sslPolicy", DEFAULT_SSL_POLICY)
if (next === "HTTPS") {
if (!getValues("sslPolicy")) {
setValue("sslPolicy", DEFAULT_SSL_POLICY)
}
} else {
setValue("sslPolicy", undefined)
}
} else {
if (current === HTTPS_PORT) {
@@ -87,7 +103,7 @@ export function ListenerForm({
name="protocol"
render={({ field }) => (
<Select
onValueChange={(next: "HTTP" | "HTTPS" | null) => {
onValueChange={(next: ListenerProtocolValue | null) => {
field.onChange(next)
applyProtocolDefaults(next)
}}
@@ -97,8 +113,11 @@ export function ListenerForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="HTTPS">HTTPS</SelectItem>
{protocols.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
)}
@@ -113,14 +132,14 @@ export function ListenerForm({
aria-invalid={!!errors.port}
id="listener-port"
inputMode="numeric"
placeholder={isHttps ? "443" : "80"}
placeholder={isSecure ? "443" : "80"}
type="number"
{...register("port", { valueAsNumber: true })}
/>
<FieldError errors={[errors.port]} />
</Field>
{isHttps && (
{isSecure && (
<>
<Field>
<FieldTitle>
@@ -173,32 +192,34 @@ export function ListenerForm({
</div>
</Field>
<Field>
<FieldTitle>
<label htmlFor="listener-ssl-policy">Security policy</label>
</FieldTitle>
<Controller
control={control}
name="sslPolicy"
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value ?? DEFAULT_SSL_POLICY}
>
<SelectTrigger className="w-full" id="listener-ssl-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sslPolicies.map((policy) => (
<SelectItem key={policy.Name} value={policy.Name ?? ""}>
{policy.Name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
{isHttps && (
<Field>
<FieldTitle>
<label htmlFor="listener-ssl-policy">Security policy</label>
</FieldTitle>
<Controller
control={control}
name="sslPolicy"
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value ?? DEFAULT_SSL_POLICY}
>
<SelectTrigger className="w-full" id="listener-ssl-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sslPolicies.map((policy) => (
<SelectItem key={policy.Name} value={policy.Name ?? ""}>
{policy.Name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
)}
<CertificateImportDialog
onImported={(arn) =>
@@ -67,9 +67,16 @@ describe("ListenersTab", () => {
it("renders empty state when no listeners", () => {
const queryClient = seedClient({})
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
expect(screen.getByText("No listeners configured.")).toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Add listener" }),
@@ -100,9 +107,16 @@ describe("ListenersTab", () => {
],
},
})
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
expect(screen.getByText("forward → my-tg")).toBeInTheDocument()
expect(screen.getByText("HTTP")).toBeInTheDocument()
expect(screen.getByText("80")).toBeInTheDocument()
@@ -123,9 +137,16 @@ describe("ListenersTab", () => {
})
mockSend.mockResolvedValue({})
const user = userEvent.setup()
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
await user.click(screen.getByLabelText("Delete listener HTTP:80"))
expect(screen.getByText(/delete listener http:80/i)).toBeInTheDocument()
const before = mockSend.mock.calls.length
@@ -159,9 +180,16 @@ describe("ListenersTab", () => {
},
})
const user = userEvent.setup()
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
await user.click(screen.getByRole("button", { name: "Add listener" }))
await user.click(screen.getByLabelText("Default target group"))
await expect(screen.findByText(/tg-in-vpc/)).resolves.toBeInTheDocument()
@@ -195,9 +223,16 @@ describe("ListenersTab", () => {
})
mockSend.mockResolvedValue({})
const user = userEvent.setup()
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
await user.click(screen.getByLabelText("Edit listener HTTP:80"))
await expect(
screen.findByRole("alertdialog", { name: /edit listener/i }),
@@ -236,9 +271,16 @@ describe("ListenersTab", () => {
})
mockSend.mockResolvedValue({})
const user = userEvent.setup()
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
await user.click(screen.getByRole("button", { name: "Add listener" }))
await user.click(screen.getByLabelText("Default target group"))
await user.click(await screen.findByText(/tg-a/))
@@ -292,9 +334,16 @@ describe("ListenersTab", () => {
})
mockSend.mockResolvedValue({})
const user = userEvent.setup()
render(<ListenersTab loadBalancerArn={LB_ARN} vpcId="vpc-aaa" />, {
wrapper: createWrapper(queryClient),
})
render(
<ListenersTab
lbType="application"
loadBalancerArn={LB_ARN}
vpcId="vpc-aaa"
/>,
{
wrapper: createWrapper(queryClient),
},
)
// The TLS column summarises the policy + cert id for HTTPS listeners.
expect(
@@ -34,11 +34,26 @@ import {
import {
type CreateListenerFormData,
createListenerSchema,
defaultProtocolForType,
isSecureProtocol,
type LbType,
type ListenerProtocolValue,
protocolsForType,
} from "@/types/elbv2"
interface ListenersTabProps {
loadBalancerArn: string
vpcId: string | undefined
lbType: LbType
}
// NLB listeners default to TCP, ALB to HTTP — both on port 80.
function listenerDefaults(lbType: LbType): CreateListenerFormData {
return {
protocol: defaultProtocolForType(lbType),
port: 80,
defaultTargetGroupArn: "",
}
}
function formatTls(listener: Listener): string {
@@ -53,7 +68,12 @@ function formatTls(listener: Listener): string {
return policy ?? certId ?? "—"
}
export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
export function ListenersTab({
loadBalancerArn,
vpcId,
lbType,
}: ListenersTabProps) {
const protocols = protocolsForType(lbType)
const { data: listenersData } = useSuspenseQuery(
elbv2ListenersQueryOptions(loadBalancerArn),
)
@@ -259,9 +279,11 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
<AddListenerDialog
certificates={certificates}
isPending={createMutation.isPending}
lbType={lbType}
onOpenChange={setAddOpen}
onSubmit={handleCreate}
open={addOpen}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={vpcTgs}
/>
@@ -272,6 +294,7 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
listener={editTarget}
onOpenChange={(open) => !open && setEditTarget(undefined)}
onSubmit={handleEdit}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={vpcTgs}
/>
@@ -295,6 +318,8 @@ export function ListenersTab({ loadBalancerArn, vpcId }: ListenersTabProps) {
interface AddListenerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
lbType: LbType
protocols: readonly ListenerProtocolValue[]
targetGroups: React.ComponentProps<typeof ListenerForm>["targetGroups"]
certificates: React.ComponentProps<typeof ListenerForm>["certificates"]
sslPolicies: React.ComponentProps<typeof ListenerForm>["sslPolicies"]
@@ -302,15 +327,11 @@ interface AddListenerDialogProps {
onSubmit: (data: CreateListenerFormData) => void
}
const ADD_LISTENER_DEFAULTS: CreateListenerFormData = {
protocol: "HTTP",
port: 80,
defaultTargetGroupArn: "",
}
function AddListenerDialog({
open,
onOpenChange,
lbType,
protocols,
targetGroups,
certificates,
sslPolicies,
@@ -319,14 +340,14 @@ function AddListenerDialog({
}: AddListenerDialogProps) {
const form = useForm<CreateListenerFormData>({
resolver: zodResolver(createListenerSchema),
defaultValues: ADD_LISTENER_DEFAULTS,
defaultValues: listenerDefaults(lbType),
})
useEffect(() => {
if (!open) {
form.reset(ADD_LISTENER_DEFAULTS)
form.reset(listenerDefaults(lbType))
}
}, [open, form])
}, [open, form, lbType])
const handleConfirm = form.handleSubmit(onSubmit)
@@ -353,6 +374,7 @@ function AddListenerDialog({
<ListenerForm
certificates={certificates}
form={form}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={targetGroups}
/>
@@ -372,6 +394,7 @@ function AddListenerDialog({
interface EditListenerDialogProps {
listener: Listener | undefined
onOpenChange: (open: boolean) => void
protocols: readonly ListenerProtocolValue[]
targetGroups: React.ComponentProps<typeof ListenerForm>["targetGroups"]
certificates: React.ComponentProps<typeof ListenerForm>["certificates"]
sslPolicies: React.ComponentProps<typeof ListenerForm>["sslPolicies"]
@@ -382,6 +405,7 @@ interface EditListenerDialogProps {
function EditListenerDialog({
listener,
onOpenChange,
protocols,
targetGroups,
certificates,
sslPolicies,
@@ -403,18 +427,19 @@ function EditListenerDialog({
if (!listener) {
return
}
const protocol = listener.Protocol === "HTTPS" ? "HTTPS" : "HTTP"
const protocol: ListenerProtocolValue =
protocols.find((p) => p === listener.Protocol) ?? protocols[0] ?? "HTTP"
const secure = isSecureProtocol(protocol)
form.reset({
protocol,
port: listener.Port ?? 80,
defaultTargetGroupArn: listener.DefaultActions?.[0]?.TargetGroupArn ?? "",
certificateArn:
protocol === "HTTPS"
? listener.Certificates?.[0]?.CertificateArn
: undefined,
certificateArn: secure
? listener.Certificates?.[0]?.CertificateArn
: undefined,
sslPolicy: protocol === "HTTPS" ? listener.SslPolicy : undefined,
})
}, [listener, form])
}, [listener, form, protocols])
const handleConfirm = form.handleSubmit(onSubmit)
@@ -442,6 +467,7 @@ function EditListenerDialog({
<ListenerForm
certificates={certificates}
form={form}
protocols={protocols}
sslPolicies={sslPolicies}
targetGroups={targetGroups}
/>
@@ -469,7 +469,7 @@ export function CreateLoadBalancerPage() {
</Field>
<Field>
<FieldTitle>Subnets (select at least 2)</FieldTitle>
<FieldTitle>Subnets (select at least 1)</FieldTitle>
{groupedSubnets.length === 0 ? (
<p className="text-xs text-muted-foreground">
No subnets in the selected VPC.
@@ -196,7 +196,11 @@ export function LoadBalancerDetailPage({ arn }: Props) {
</TabsPanel>
<TabsPanel value="listeners">
<ListenersTab loadBalancerArn={arn} vpcId={lb.VpcId} />
<ListenersTab
lbType={isNlb ? "network" : "application"}
loadBalancerArn={arn}
vpcId={lb.VpcId}
/>
</TabsPanel>
{!isNlb && (
@@ -91,7 +91,7 @@ describe("createLoadBalancerSchema", () => {
expect(result.success).toBeTruthy()
})
it("rejects <2 subnets", () => {
it("accepts a single subnet", () => {
const result = createLoadBalancerSchema.safeParse({
name: "my-alb",
type: "application",
@@ -102,6 +102,20 @@ describe("createLoadBalancerSchema", () => {
tags: [],
listener: baseListener,
})
expect(result.success).toBeTruthy()
})
it("rejects zero subnets", () => {
const result = createLoadBalancerSchema.safeParse({
name: "my-alb",
type: "application",
scheme: "internet-facing",
vpcId: "vpc-1",
subnetIds: [],
securityGroupIds: [],
tags: [],
listener: baseListener,
})
expect(result.success).toBeFalsy()
})
@@ -106,18 +106,18 @@ export function isSecureProtocol(protocol: string): boolean {
export const createListenerSchema = z
.object({
protocol: z.enum(["HTTP", "HTTPS"]),
protocol: z.enum(ALL_LISTENER_PROTOCOLS),
port: portField,
defaultTargetGroupArn: z.string().min(1, "Target group is required"),
certificateArn: z.string().optional(),
sslPolicy: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.protocol === "HTTPS" && !data.certificateArn) {
if (isSecureProtocol(data.protocol) && !data.certificateArn) {
ctx.addIssue({
code: "custom",
path: ["certificateArn"],
message: "A certificate is required for HTTPS",
message: "A certificate is required for HTTPS and TLS",
})
}
})
@@ -200,7 +200,7 @@ export const createLoadBalancerSchema = z.object({
type: z.enum(LB_TYPES),
scheme: z.enum(["internet-facing", "internal"]),
vpcId: z.string().min(1, "VPC is required"),
subnetIds: z.array(z.string()).min(2, "At least 2 subnets are required"),
subnetIds: z.array(z.string()).min(1, "At least 1 subnet is required"),
securityGroupIds: z.array(z.string()),
tags: z.array(tagSchema),
listener: z
+32
View File
@@ -77,6 +77,38 @@ func TCPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficR
return r
}
// UDPRoundRobin sends n UDP datagrams to host:port, reads the echoed line per
// datagram, and counts responders. Matches the app-userdata UDP echo responder.
// UDP is connectionless, so each probe writes a payload then reads with a
// deadline; a dropped datagram counts as a failure rather than blocking.
func UDPRoundRobin(host string, port int, n int, timeout time.Duration) TrafficResult {
r := TrafficResult{Distribution: map[string]int{}, Total: n}
for i := 0; i < n; i++ {
conn, err := net.DialTimeout("udp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
continue
}
if _, err := conn.Write([]byte("ping")); err != nil {
conn.Close()
continue
}
conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 256)
nb, err := conn.Read(buf)
conn.Close()
if err != nil {
continue
}
resp := strings.TrimSpace(string(buf[:nb]))
if resp == "" {
continue
}
r.Distribution[resp]++
r.Successful++
}
return r
}
// AssertRoundRobin asserts that r has at least minUnique distinct responders
// and at least minSuccess successful probes. Logs the distribution either way.
func AssertRoundRobin(t *testing.T, r TrafficResult, minUnique, minSuccess int, label string) {
+113
View File
@@ -34,6 +34,7 @@ const (
lbKeyName = "lb-e2e-key"
httpPort = 80
tcpPort = 9000
udpPort = 9001
triggerPort = 9090
probesPerRun = 20
)
@@ -105,6 +106,10 @@ func TestLoadBalancer(t *testing.T) {
time.Sleep(15 * time.Second)
runLBSuite(t, client, fixture, kindNLB, "internal", nil, "")
})
t.Run("Internal_NLB_UDP", func(t *testing.T) {
time.Sleep(15 * time.Second)
runUDPNLBSuite(t, client, fixture)
})
t.Run("Internal_ALB_ModifyListener", func(t *testing.T) {
time.Sleep(15 * time.Second)
runModifyListenerSuite(t, client, fixture)
@@ -398,6 +403,22 @@ func configureDefaultSG(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
}
}
}
// UDP data plane for the NLB UDP suite (health-checked over TCP/9000).
if _, err := c.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
GroupId: aws.String(f.SecurityGroup),
IpPermissions: []*ec2.IpPermission{{
IpProtocol: aws.String("udp"),
FromPort: aws.Int64(udpPort),
ToPort: aws.Int64(udpPort),
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}},
}},
}); err != nil {
var aerr awserr.Error
if !errors.As(err, &aerr) || aerr.Code() != "InvalidPermission.Duplicate" {
t.Fatalf("authorize udp/%d: %v", udpPort, err)
}
}
}
// --- App instances -------------------------------------------------------
@@ -507,6 +528,74 @@ func runLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture, kind lbKin
runInternalTrafficViaClient(t, c, f, kind, priv)
}
// runUDPNLBSuite exercises an internal NLB with a UDP listener end-to-end. This
// is the regression gate for nginx NLB target health: nginx `stream` has no
// active upstream probing, so the lb-agent actively probes the targets (here
// over TCP/9000) and reports health — without it WaitForTargetsHealthy would
// never pass for any NLB. Once healthy, a UDP datagram round-trip through the
// VIP proves the L4 data plane (which HAProxy cannot serve for UDP).
func runUDPNLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
t.Helper()
const label = "NLB internal UDP"
tgArn := createUDPTargetGroup(t, c, f, "lb-e2e-nlb-udp-tg")
t.Cleanup(func() { deleteTargetGroup(t, c, tgArn) })
registerTargets(t, c, tgArn, f.AppInstanceIDs)
t.Cleanup(func() { deregisterTargets(t, c, tgArn, f.AppInstanceIDs) })
lb := createLB(t, c, f, "lb-e2e-nlb-udp", "network", "internal")
t.Cleanup(func() { deleteLB(t, c, lb) })
listener := createListener(t, c, lb.ARN, "UDP", udpPort, tgArn)
t.Cleanup(func() { deleteListener(t, c, listener) })
assert.Equal(t, "network", lb.Type, label+" type")
assert.Contains(t, lb.ARN, "/net/", label+" ARN must contain /net/")
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
// The key assertion: NLB targets reach healthy via the agent's active
// prober. Pre-feature this timed out (nginx reported empty server lists).
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
eni := lbENI(t, c, "net", lb)
priv := privateIP(eni)
require.NotEmpty(t, priv, label+" needs private IP")
runInternalUDPViaClient(t, f, priv, label)
}
// runInternalUDPViaClient drives the shared client to send UDP datagrams at the
// NLB VIP and asserts the echoed hostnames round-robin across both backends.
func runInternalUDPViaClient(t *testing.T, f *sharedFixture, lbIP, label string) {
t.Helper()
resultsFile := fmt.Sprintf("nlb-udp-%d.txt", time.Now().UnixNano())
order := map[string]any{
"proto": "udp",
"ip": lbIP,
"count": probesPerRun,
"outfile": resultsFile,
"udp_port": udpPort,
}
body, err := json.Marshal(order)
require.NoError(t, err)
triggerURL := fmt.Sprintf("http://%s:%d/", f.ClientPublicIP, triggerPort)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Post(triggerURL, "application/json", bytes.NewReader(body))
require.NoErrorf(t, err, "trigger %s probe", label)
resp.Body.Close()
require.Equalf(t, 200, resp.StatusCode, "trigger %s probe HTTP status", label)
results, err := plainHTTPGet(
fmt.Sprintf("http://%s:%d/%s", f.ClientPublicIP, httpPort, resultsFile),
10*time.Second)
require.NoErrorf(t, err, "fetch %s", resultsFile)
harness.AssertRoundRobin(t,
harness.VerifyResultsLines(results, "udp"),
1, probesPerRun/2, label)
}
// --- LB CRUD helpers -----------------------------------------------------
func createTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, name, proto string, port int64, hcPath string) string {
@@ -533,6 +622,30 @@ func createTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, nam
return arn
}
// createUDPTargetGroup creates a UDP target group. AWS (and spinifex) require a
// UDP target group's health check to run over TCP/HTTP/HTTPS — UDP itself can't
// be probed — so the check targets the app's TCP echo port (9000) while the
// data plane forwards UDP on udpPort. This exercises the nginx agent's active
// TCP prober, which is what advances NLB targets to healthy.
func createUDPTargetGroup(t *testing.T, c *harness.AWSClient, f *sharedFixture, name string) string {
t.Helper()
out, err := c.ELBv2.CreateTargetGroup(&elbv2.CreateTargetGroupInput{
Name: aws.String(name),
Protocol: aws.String("UDP"),
Port: aws.Int64(udpPort),
VpcId: aws.String(f.VPCID),
HealthCheckProtocol: aws.String("TCP"),
HealthCheckPort: aws.String(fmt.Sprintf("%d", tcpPort)),
HealthCheckIntervalSeconds: aws.Int64(10),
HealthyThresholdCount: aws.Int64(2),
UnhealthyThresholdCount: aws.Int64(2),
})
require.NoErrorf(t, err, "create-target-group %s (UDP)", name)
arn := aws.StringValue(out.TargetGroups[0].TargetGroupArn)
t.Logf("UDP TG %s: %s (HC TCP:%d)", name, arn, tcpPort)
return arn
}
func deleteTargetGroup(t *testing.T, c *harness.AWSClient, arn string) {
if arn == "" {
return
+13 -2
View File
@@ -1,7 +1,8 @@
#!/bin/bash
# App instance user-data: dual-purpose responder for LB E2E tests.
# App instance user-data: triple-purpose responder for LB E2E tests.
# HTTP:80 serves JSON {"instance_id": "<hostname>"} for ALB suites
# TCP:9000 echoes <hostname> for NLB suites
# TCP:9000 echoes <hostname> for NLB TCP suites (also UDP-TG TCP health check)
# UDP:9001 echoes <hostname> for NLB UDP suites
INSTANCE_ID=$(hostname)
mkdir -p /tmp/httpd/alpha /tmp/httpd/beta && cd /tmp/httpd
@@ -19,3 +20,13 @@ socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("0.0.0.0", 9000), Handler).serve_forever()
PYEOF
nohup python3 /tmp/tcp_echo.py > /dev/null 2>&1 &
cat > /tmp/udp_echo.py << 'PYEOF'
import socketserver, os
class Handler(socketserver.DatagramRequestHandler):
def handle(self):
self.wfile.write((os.uname()[1] + "\n").encode())
socketserver.UDPServer.allow_reuse_address = True
socketserver.UDPServer(("0.0.0.0", 9001), Handler).serve_forever()
PYEOF
nohup python3 /tmp/udp_echo.py > /dev/null 2>&1 &
+6
View File
@@ -28,6 +28,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
count = int(body.get("count", 20))
outfile = body["outfile"]
tcp_port = int(body.get("tcp_port", 9000))
udp_port = int(body.get("udp_port", 9001))
http_port = int(body.get("http_port", 80))
http_path = body.get("http_path", "/") or "/"
if not http_path.startswith("/"):
@@ -48,6 +49,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
cmd.append(f"http://{ip}:{http_port}{http_path}")
r = subprocess.run(cmd, capture_output=True, text=True)
fh.write(r.stdout.strip() + "\n")
elif proto == "udp":
r = subprocess.run(
["bash", "-c", f'echo "ping" | nc -u -w2 {ip} {udp_port} 2>/dev/null || true'],
capture_output=True, text=True)
fh.write(r.stdout.strip() + "\n")
else:
r = subprocess.run(
["bash", "-c", f'echo "" | nc -w2 {ip} {tcp_port} 2>/dev/null || true'],