Skip to content

Commit

Permalink
feat: implement config handling
Browse files Browse the repository at this point in the history
This does leak fds as the logging target never gets closed,
this has to be fixed before release but this will work for now.
  • Loading branch information
fionera committed Mar 5, 2024
1 parent f0db161 commit a7b4167
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 54 deletions.
138 changes: 138 additions & 0 deletions cmd/coraza-spoa/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package main

import (
"fmt"
"io"
"net/url"
"os"
"time"

"github.com/rs/zerolog"
"gopkg.in/yaml.v3"

"github.com/corazawaf/coraza-spoa/internal"
)

func readConfig() (*config, error) {
open, err := os.Open(configPath)
if err != nil {
return nil, err
}

d := yaml.NewDecoder(open)
d.KnownFields(true)

var cfg config
if err := d.Decode(&cfg); err != nil {
return nil, err
}

if len(cfg.Applications) == 0 {
globalLogger.Warn().Msg("no applications defined")
}

return &cfg, nil
}

type config struct {
Bind string `yaml:"bind"`
Log logConfig `yaml:",inline"`
Applications []struct {
Log logConfig `yaml:",inline"`
Name string `yaml:"name"`
Directives string `yaml:"directives"`
ResponseCheck bool `yaml:"response_check"`
TransactionTTLMS int `yaml:"transaction_ttl_ms"`
TransactionActiveLimit int `yaml:"transaction_active_limit"`
TransactionActiveLimitReject bool `yaml:"transaction_active_limit_reject"`
} `yaml:"applications"`
}

func (c config) networkAddressFromBind() (network string, address string) {
bindUrl, err := url.Parse(c.Bind)
if err == nil {
return bindUrl.Scheme, bindUrl.Path
}

return "tcp", c.Bind
}

func (c config) newApplications() (map[string]*internal.Application, error) {
allApps := make(map[string]*internal.Application)

for name, a := range c.Applications {
logger, err := a.Log.newLogger()
if err != nil {
return nil, fmt.Errorf("creating logger for application %q: %v", name, err)
}

appConfig := internal.AppConfig{
Logger: logger,
Directives: a.Directives,
ResponseCheck: a.ResponseCheck,
TransactionTTLMS: time.Duration(a.TransactionTTLMS) * time.Millisecond,
}

application, err := appConfig.NewApplication()
if err != nil {
return nil, fmt.Errorf("initializing application %q: %v", name, err)
}

allApps[a.Name] = application
}

return allApps, nil
}

type logConfig struct {
Level string `yaml:"log_level"`
File string `yaml:"log_file"`
Format string `yaml:"log_format"`
}

func (lc logConfig) outputWriter() (io.Writer, error) {
var out io.Writer
if lc.File == "" || lc.File == "/dev/stdout" {
out = os.Stdout
} else if lc.File == "/dev/stderr" {
out = os.Stderr
} else if lc.File == "/dev/null" {
out = io.Discard
} else {
// TODO: Close the handle if not used anymore.
// Currently these are leaked as soon as we reload.
f, err := os.OpenFile(lc.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
out = f
}
return out, nil
}

func (lc logConfig) newLogger() (zerolog.Logger, error) {
out, err := lc.outputWriter()
if err != nil {
return globalLogger, err
}

switch lc.Format {
case "console":
out = zerolog.ConsoleWriter{
Out: out,
}
case "json":
default:
return globalLogger, fmt.Errorf("unknown log format: %v", lc.Format)
}

if lc.Level == "" {
lc.Level = "info"
}
lvl, err := zerolog.ParseLevel(lc.Level)
if err != nil {
return globalLogger, err
}

return zerolog.New(out).Level(lvl).With().Timestamp().Logger(), nil
}
91 changes: 72 additions & 19 deletions cmd/coraza-spoa/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,104 @@ import (
"os/signal"
"syscall"

"github.com/rs/zerolog/log"
"github.com/rs/zerolog"

"github.com/corazawaf/coraza-spoa/internal"
)

var configPath string
var globalLogger = zerolog.New(os.Stderr).With().Timestamp().Logger()

func main() {
flag.StringVar(&configPath, "config", "", "configuration file")
flag.Parse()

log.Info().Msg("Starting coraza-spoa")
//TODO START HERE
if configPath == "" {
globalLogger.Fatal().Msg("Configuration file is not set")
}

cfg, err := readConfig()
if err != nil {
globalLogger.Fatal().Err(err).Msg("Failed loading config")
}

logger, err := cfg.Log.newLogger()
if err != nil {
globalLogger.Fatal().Err(err).Msg("Failed creating global logger")
}
globalLogger = logger

apps, err := cfg.newApplications()
if err != nil {
globalLogger.Fatal().Err(err).Msg("Failed creating applications")
}

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

l, err := net.Listen("tcp", "127.0.0.1:8000")
network, address := cfg.networkAddressFromBind()
l, err := (&net.ListenConfig{}).Listen(ctx, network, address)
if err != nil {
return
globalLogger.Fatal().Err(err).Msg("Failed opening socket")
}

a := &internal.Agent{
Context: context.Background(),
Applications: map[string]*internal.Application{
"default": {
ResponseCheck: true,
TransactionTTLMs: 1000,
},
},
Context: ctx,
Applications: apps,
Logger: globalLogger,
}
go func() {
defer cancelFunc()

log.Print(a.Serve(l))
globalLogger.Info().Msg("Starting coraza-spoa")
if err := a.Serve(l); err != nil {
globalLogger.Fatal().Err(err).Msg("listener closed")
}
}()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGINT)
for {
sig := <-sigCh
switch sig {
case syscall.SIGTERM:
log.Info().Msg("Received SIGTERM, shutting down...")
globalLogger.Info().Msg("Received SIGTERM, shutting down...")
// this return will run cancel() and close the server
return
case syscall.SIGINT:
log.Info().Msg("Received SIGINT, shutting down...")
globalLogger.Info().Msg("Received SIGINT, shutting down...")
return
case syscall.SIGHUP:
log.Info().Msg("Received SIGHUP, reloading configuration...")
log.Error().Err(nil).Msg("Error loading configuration, using old configuration")
case syscall.SIGUSR1:
log.Info().Msg("SIGUSR1 received. Changing port is not supported yet")
globalLogger.Info().Msg("Received SIGHUP, reloading configuration...")

newCfg, err := readConfig()
if err != nil {
globalLogger.Error().Err(err).Msg("Error loading configuration, using old configuration")
continue
}

if cfg.Log != newCfg.Log {
newLogger, err := newCfg.Log.newLogger()
if err != nil {
globalLogger.Error().Err(err).Msg("Error creating new global logger, using old configuration")
continue
}
globalLogger = newLogger
}

if cfg.Bind != newCfg.Bind {
globalLogger.Error().Msg("Changing bind is not supported yet, using old configuration")
continue
}

apps, err := newCfg.newApplications()
if err != nil {
globalLogger.Error().Err(err).Msg("Error applying configuration, using old configuration")
continue
}

a.ReplaceApplications(apps)
cfg = newCfg
}
}
}
8 changes: 5 additions & 3 deletions examples/coraza-spoa.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# The SPOA server bind address
bind: 0.0.0.0:9000

# The maximum number of transactions which can be cached
transaction_active_limit: 1000

# The log level configuration, one of: debug/info/warn/error/panic/fatal
log_level: info
# The log file path
Expand Down Expand Up @@ -46,6 +43,11 @@ applications:
log_level: info
# The log file path
log_file: /dev/stdout
# The log format, one of: console/json
log_format: console

# The maximum number of transactions which can be cached
transaction_active_limit: 1000

# After reaching the maximum number of transactions:
# If true then the new transactions will be rejected with deny and status code 503
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/magefile/mage v1.15.0
github.com/mccutchen/go-httpbin/v2 v2.13.4
github.com/rs/zerolog v1.32.0
gopkg.in/yaml.v3 v3.0.1
istio.io/istio v0.0.0-20240218163812-d80ef7b19049
)

Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
istio.io/istio v0.0.0-20240218163812-d80ef7b19049 h1:jR4INLKnkLNgQRNMBjkAt1ctPnuTq+vQ9wlZSOtR1+o=
istio.io/istio v0.0.0-20240218163812-d80ef7b19049/go.mod h1:5ATT2TaGbT/L1SwCYvs2ArNeLxHkPKwhvT7r3TPMu6M=
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
Expand Down
24 changes: 17 additions & 7 deletions internal/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"net"
"sync"

"github.com/dropmorepackets/haproxy-go/pkg/encoding"
"github.com/dropmorepackets/haproxy-go/spop"
Expand All @@ -13,8 +14,9 @@ import (
type Agent struct {
Context context.Context
Applications map[string]*Application
Logger zerolog.Logger

logger *zerolog.Logger
mtx sync.RWMutex
}

func (a *Agent) Serve(l net.Listener) error {
Expand All @@ -26,6 +28,12 @@ func (a *Agent) Serve(l net.Listener) error {
return agent.Serve(l)
}

func (a *Agent) ReplaceApplications(newApps map[string]*Application) {
a.mtx.Lock()
a.Applications = newApps
a.mtx.Unlock()
}

func (a *Agent) HandleSPOE(ctx context.Context, writer *encoding.ActionWriter, message *encoding.Message) {
const (
messageCorazaRequest = "coraza-req"
Expand All @@ -39,29 +47,31 @@ func (a *Agent) HandleSPOE(ctx context.Context, writer *encoding.ActionWriter, m
case messageCorazaResponse:
messageHandler = (*Application).HandleResponse
default:
a.logger.Debug().Str("message", name).Msg("unknown spoe message")
a.Logger.Debug().Str("message", name).Msg("unknown spoe message")
return
}

k := encoding.AcquireKVEntry()
defer encoding.ReleaseKVEntry(k)
if !message.KV.Next(k) {
a.logger.Panic().Msg("failed reading kv entry")
a.Logger.Panic().Msg("failed reading kv entry")
return
}

appName := string(k.ValueBytes())
if !k.NameEquals("app") {
// Without knowing the app, we cannot continue. We could fall back to a default application,
// but all following code would have to support that as we now already read one of the kv entries.
a.logger.Panic().Str("expected", "app").Str("got", appName).Msg("unexpected kv entry")
a.Logger.Panic().Str("expected", "app").Str("got", appName).Msg("unexpected kv entry")
return
}

a.mtx.RLock()
app := a.Applications[appName]
a.mtx.RUnlock()
if app == nil {
// If we cannot resolve the app, we fail as this is an invalid configuration.
a.logger.Panic().Str("app", appName).Msg("app not found")
a.Logger.Panic().Str("app", appName).Msg("app not found")
return
}

Expand All @@ -77,10 +87,10 @@ func (a *Agent) HandleSPOE(ctx context.Context, writer *encoding.ActionWriter, m
_ = writer.SetString(encoding.VarScopeTransaction, "data", interruption.Interruption.Data)
_ = writer.SetInt64(encoding.VarScopeTransaction, "ruleid", int64(interruption.Interruption.RuleID))

a.logger.Debug().Err(err).Msg("sending interruption")
a.Logger.Debug().Err(err).Msg("sending interruption")
return
}

// If the error is not an ErrInterrupted, we panic to let the spop stream fail.
a.logger.Panic().Err(err).Msg("Error handling request")
a.Logger.Panic().Err(err).Msg("Error handling request")
}
Loading

0 comments on commit a7b4167

Please sign in to comment.