Skip to content

Commit 83c0f77

Browse files
committed
Add SPOA cmd
1 parent af59abf commit 83c0f77

File tree

7 files changed

+474
-0
lines changed

7 files changed

+474
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
spoa
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Build stage
2+
FROM golang:1.24-alpine AS builder
3+
ENV CGO_ENABLED=1
4+
5+
WORKDIR /app
6+
COPY . .
7+
8+
RUN apk add --no-cache --update git build-base openssl
9+
10+
# Build the spoa binary
11+
RUN go build -tags=appsec -o ./contrib/haproxy/stream-processing-offload/cmd/spoa/spoa ./contrib/haproxy/stream-processing-offload/cmd/spoa
12+
13+
# Runtime stage
14+
FROM alpine:3.20.3
15+
16+
# Set opencontainers labels for Github container registry
17+
LABEL org.opencontainers.image.source=https://github.com/DataDog/dd-trace-go/tree/main/contrib/haproxy/stream-processing-offload/cmd/spoa
18+
LABEL org.opencontainers.image.description="An HAProxy Stream Processing Offload Agent service with Datadog App & API Protection support"
19+
LABEL org.opencontainers.image.licenses=Apache-2.0
20+
21+
ARG COMMIT_SHA=""
22+
LABEL org.opencontainers.image.revision=${COMMIT_SHA}
23+
24+
RUN apk --no-cache add ca-certificates tzdata libc6-compat libgcc libstdc++
25+
WORKDIR /app
26+
27+
ARG DD_VERSION=""
28+
ENV DD_VERSION=${DD_VERSION}
29+
30+
COPY --from=builder /app/contrib/haproxy/stream-processing-offload/cmd/spoa/spoa /app/spoa
31+
32+
EXPOSE 3000
33+
EXPOSE 3080
34+
35+
CMD ["./spoa"]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# HAProxy Stream Processing Offload Agent (SPOA) with Datadog App & API Protection
2+
3+
[HAProxy SPOE](https://www.haproxy.com/blog/extending-haproxy-with-the-stream-processing-offload-engine) enable users to provide programmability and extensibility on HAProxy.
4+
5+
## Installation
6+
7+
### From Release
8+
9+
The images are published at each release of the tracer and can be found in [the repo registry](https://github.com/DataDog/dd-trace-go/pkgs/container/dd-trace-go%2Fhaproxy-spoa).
10+
11+
### Build image
12+
13+
The docker image can be build locally using docker. Start by cloning the `dd-trace-go` repo, `cd` inside it and run that command:
14+
```sh
15+
docker build -f contrib/haproxy/stream-processing-offload/cmd/spoa/Dockerfile -t datadog/dd-trace-go/haproxy-spoa:local .
16+
```
17+
18+
## Configuration
19+
20+
The HAProxy SPOA agent expose some configuration:
21+
22+
| Environment variable | Default value | Description |
23+
|-------------------------------------|---------------|---------------------------------------------------------------------------------------------------------------------------------------------|
24+
| `DD_HAPROXY_SPOA_HOST` | `0.0.0.0` | Host on where the SPOA and HTTP server should listen to. |
25+
| `DD_HAPROXY_SPOA_PORT` | `3000` | Port used by the SPOA that accept communication with HAProxy. |
26+
| `DD_HAPROXY_SPOA_HEALTHCHECK_PORT` | `3080` | Port used for the HTTP server for the health check. |
27+
| `DD_APPSEC_BODY_PARSING_SIZE_LIMIT` | `0` | Maximum size of the bodies to be processed in bytes. If set to 0, the bodies are not processed. The recommended value is `10000000` (10MB). |
28+
|
29+
30+
> The HAProxy SPOA need to be connected to a deployed [Datadog agent](https://docs.datadoghq.com/agent).
31+
32+
| Environment variable | Default value | Description |
33+
|-----------------------|---------------|----------------------------------|
34+
| `DD_AGENT_HOST` | `localhost` | Host of a running Datadog Agent. |
35+
| `DD_TRACE_AGENT_PORT` | `8126` | Port of a running Datadog Agent. |
36+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package main
7+
8+
import (
9+
"net"
10+
"os"
11+
"strconv"
12+
)
13+
14+
// IntEnv returns the parsed int value of an environment variable, or
15+
// def otherwise.
16+
func intEnv(key string, def int) int {
17+
vv, ok := os.LookupEnv(key)
18+
if !ok {
19+
return def
20+
}
21+
v, err := strconv.Atoi(vv)
22+
if err != nil {
23+
log.Warn("Non-integer value for env var %s, defaulting to %d. Parse failed with error: %v", key, def, err)
24+
return def
25+
}
26+
return v
27+
}
28+
29+
// IpEnv returns the valid IP value of an environment variable, or def otherwise.
30+
func ipEnv(key string, def net.IP) net.IP {
31+
vv, ok := os.LookupEnv(key)
32+
if !ok {
33+
return def
34+
}
35+
36+
ip := net.ParseIP(vv)
37+
if ip == nil {
38+
log.Warn("Non-IP value for env var %s, defaulting to %s", key, def.String())
39+
return def
40+
}
41+
42+
return ip
43+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package main
7+
8+
import (
9+
ll "log"
10+
"os"
11+
)
12+
13+
// Logger wraps the standard library log.Logger
14+
type Logger struct {
15+
*ll.Logger
16+
}
17+
18+
// NewLogger creates a new Logger instance
19+
func NewLogger() *Logger {
20+
return &Logger{
21+
Logger: ll.New(os.Stdout, "", ll.LstdFlags),
22+
}
23+
}
24+
25+
// Info logs an informational message
26+
func (l *Logger) Info(format string, v ...interface{}) {
27+
l.SetPrefix("INFO: ")
28+
//l.Printf(format, v...)
29+
l.Printf(format, v...)
30+
}
31+
32+
// Warn logs a warning message
33+
func (l *Logger) Warn(format string, v ...interface{}) {
34+
l.SetPrefix("WARN: ")
35+
l.Printf(format, v...)
36+
}
37+
38+
// Error logs an error message
39+
func (l *Logger) Error(format string, v ...interface{}) {
40+
l.SetPrefix("ERROR: ")
41+
l.Printf(format, v...)
42+
}
43+
44+
func (l *Logger) Errorf(format string, v ...interface{}) {
45+
l.Error("haproxy_spoa: "+format, v...)
46+
}
47+
48+
// Debug logs a debug message
49+
func (l *Logger) Debug(format string, v ...interface{}) {
50+
l.SetPrefix("DEBUG: ")
51+
l.Printf(format, v...)
52+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2025 Datadog, Inc.
5+
6+
package main
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"net"
13+
"net/http"
14+
"os"
15+
"os/signal"
16+
"strconv"
17+
"syscall"
18+
"time"
19+
20+
"github.com/negasus/haproxy-spoe-go/agent"
21+
"golang.org/x/sync/errgroup"
22+
23+
"github.com/DataDog/dd-trace-go/contrib/haproxy/stream-processing-offload/v2"
24+
"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
25+
"github.com/DataDog/dd-trace-go/v2/instrumentation"
26+
)
27+
28+
type haProxySpoaConfig struct {
29+
extensionPort string
30+
healthcheckPort string
31+
extensionHost string
32+
bodyParsingSizeLimit int
33+
}
34+
35+
var log = NewLogger()
36+
37+
func getDefaultEnvVars() map[string]string {
38+
return map[string]string{
39+
"DD_VERSION": instrumentation.Version(), // Version of the tracer
40+
"DD_APM_TRACING_ENABLED": "false", // Appsec Standalone
41+
"DD_APPSEC_WAF_TIMEOUT": "10ms", // Proxy specific WAF timeout
42+
"_DD_APPSEC_PROXY_ENVIRONMENT": "true", // Internal config: Enable API Security proxy sampler
43+
}
44+
}
45+
46+
// initializeEnvironment sets up required environment variables with their defaults
47+
func initializeEnvironment() {
48+
for k, v := range getDefaultEnvVars() {
49+
if os.Getenv(k) == "" {
50+
if err := os.Setenv(k, v); err != nil {
51+
log.Error("haproxy_spoa: failed to set %s environment variable: %s\n", k, err.Error())
52+
}
53+
}
54+
}
55+
}
56+
57+
// loadConfig loads the configuration from the environment variables
58+
func loadConfig() haProxySpoaConfig {
59+
extensionHostStr := ipEnv("DD_HAPROXY_SPOA_HOST", net.IP{0, 0, 0, 0}).String()
60+
extensionPortInt := intEnv("DD_HAPROXY_SPOA_PORT", 3000)
61+
healthcheckPortInt := intEnv("DD_HAPROXY_SPOA_HEALTHCHECK_PORT", 3080)
62+
bodyParsingSizeLimit := intEnv("DD_APPSEC_BODY_PARSING_SIZE_LIMIT", 0)
63+
64+
extensionPortStr := strconv.FormatInt(int64(extensionPortInt), 10)
65+
healthcheckPortStr := strconv.FormatInt(int64(healthcheckPortInt), 10)
66+
67+
return haProxySpoaConfig{
68+
extensionPort: extensionPortStr,
69+
extensionHost: extensionHostStr,
70+
healthcheckPort: healthcheckPortStr,
71+
bodyParsingSizeLimit: bodyParsingSizeLimit,
72+
}
73+
}
74+
75+
func main() {
76+
initializeEnvironment()
77+
config := loadConfig()
78+
79+
if err := startService(config); err != nil {
80+
log.Error("haproxy_spoa: %s\n", err.Error())
81+
os.Exit(1)
82+
}
83+
84+
log.Info("haproxy_spoa: shutting down\n")
85+
}
86+
87+
func startService(config haProxySpoaConfig) error {
88+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
89+
defer cancel()
90+
g, ctx := errgroup.WithContext(ctx)
91+
92+
g.Go(func() error {
93+
return startSpoa(ctx, config)
94+
})
95+
96+
g.Go(func() error {
97+
return startHealthCheck(ctx, config)
98+
})
99+
100+
if err := g.Wait(); err != nil {
101+
return err
102+
}
103+
104+
return nil
105+
}
106+
107+
func startHealthCheck(ctx context.Context, config haProxySpoaConfig) error {
108+
muxServer := http.NewServeMux()
109+
muxServer.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
110+
w.Header().Set("Content-Type", "application/json")
111+
w.WriteHeader(http.StatusOK)
112+
w.Write([]byte(`{"status": "ok", "library": {"language": "golang", "version": "` + instrumentation.Version() + `"}}`))
113+
})
114+
115+
server := &http.Server{
116+
Addr: config.extensionHost + ":" + config.healthcheckPort,
117+
Handler: muxServer,
118+
}
119+
120+
go func() {
121+
<-ctx.Done()
122+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
123+
defer cancel()
124+
if err := server.Shutdown(shutdownCtx); err != nil {
125+
log.Error("haproxy_spoa: health check server shutdown: %s\n", err.Error())
126+
}
127+
}()
128+
129+
log.Info("haproxy_spoa: health check server started on %s:%s\n", config.extensionHost, config.healthcheckPort)
130+
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
131+
return fmt.Errorf("health check http server: %s", err.Error())
132+
}
133+
134+
return nil
135+
}
136+
137+
func startSpoa(ctx context.Context, config haProxySpoaConfig) error {
138+
listener, err := net.Listen("tcp4", config.extensionHost+":"+config.extensionPort)
139+
if err != nil {
140+
return fmt.Errorf("error creating listener: %w", err)
141+
}
142+
defer listener.Close()
143+
144+
_ = tracer.Start(tracer.WithAppSecEnabled(true))
145+
defer tracer.Stop()
146+
147+
appsecHAProxy := streamprocessingoffload.NewHAProxySPOA(streamprocessingoffload.AppsecHAProxyConfig{
148+
BlockingUnavailable: false,
149+
BodyParsingSizeLimit: config.bodyParsingSizeLimit,
150+
Context: ctx,
151+
})
152+
153+
a := agent.New(appsecHAProxy.Handler, log)
154+
155+
log.Info("haproxy_spoa: datadog agent server started on %s:%s\n", config.extensionHost, config.extensionPort)
156+
if err := a.Serve(listener); err != nil {
157+
return fmt.Errorf("error starting agent server: %w", err)
158+
}
159+
160+
return nil
161+
}

0 commit comments

Comments
 (0)