mirror of
https://github.com/hanzo-fi/gateway.git
synced 2026-10-30 20:34:40 +00:00
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/IBM/sarama"
|
|
"github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka"
|
|
wNats "github.com/ThreeDotsLabs/watermill-nats/v2/pkg/nats"
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/xdg-go/scram"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/audit"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/audit/httpaudit"
|
|
v5oidc "github.com/hanzo-fi/go-libs/v5/pkg/authn/oidc"
|
|
v5client "github.com/hanzo-fi/go-libs/v5/pkg/authn/oidc/client"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/messaging/publish"
|
|
logging "github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
)
|
|
|
|
const EventApp = "gateway"
|
|
|
|
func init() {
|
|
caddy.RegisterModule(Audit{})
|
|
httpcaddyfile.RegisterHandlerDirective("audit", parseAuditCaddyfile)
|
|
}
|
|
|
|
type Audit struct {
|
|
logger *zap.Logger `json:"-"`
|
|
publisher message.Publisher `json:"-"`
|
|
natsConn *nats.Conn `json:"-"`
|
|
closing *atomic.Bool `json:"-"`
|
|
middleware func(http.Handler) http.Handler `json:"-"`
|
|
|
|
TopicName string `json:"topic_name,omitempty"`
|
|
OrganizationID string `json:"organization_id,omitempty"`
|
|
StackID string `json:"stack_id,omitempty"`
|
|
AuthEnabled bool `json:"auth_enabled,omitempty"`
|
|
AuthURL string `json:"auth_url,omitempty"`
|
|
AuthIssuer string `json:"auth_issuer,omitempty"`
|
|
AutoProvision bool `json:"auto_provision,omitempty"`
|
|
|
|
PublisherKafkaBroker string `json:"publisher_kafka_broker,omitempty"`
|
|
PublisherKafkaEnabled bool `json:"publisher_kafka_enabled,omitempty"`
|
|
PublisherKafkaTLSEnabled bool `json:"publisher_kafka_tls_enabled,omitempty"`
|
|
PublisherKafkaSASLEnabled bool `json:"publisher_kafka_sasl_enabled,omitempty"`
|
|
PublisherKafkaSASLUsername string `json:"publisher_kafka_sasl_username,omitempty"`
|
|
PublisherKafkaSASLPassword string `json:"publisher_kafka_sasl_password,omitempty"`
|
|
PublisherKafkaSASLMechanism string `json:"publisher_kafka_sasl_mechanism,omitempty"`
|
|
PublisherKafkaSASLScramSHASize int `json:"publisher_kafka_sasl_scram_sha_size,omitempty"`
|
|
|
|
PublisherNatsEnabled bool `json:"publisher_nats_enabled,omitempty"`
|
|
PublisherNatsURL string `json:"publisher_nats_url,omitempty"`
|
|
PublisherNatsClientId string `json:"publisher_nats_client_id,omitempty"`
|
|
PublisherNatsMaxReconnects int `json:"publisher_nats_max_reconnects,omitempty"`
|
|
PublisherNatsMaxReconnectsWait time.Duration `json:"publisher_nats_max_reconnects_wait,omitempty"`
|
|
}
|
|
|
|
func (Audit) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "http.handlers.audit",
|
|
New: func() caddy.Module { return new(Audit) },
|
|
}
|
|
}
|
|
|
|
func parseAuditCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
|
|
a := new(Audit)
|
|
a.AutoProvision = true
|
|
|
|
for h.Next() {
|
|
for h.NextBlock(0) {
|
|
key := h.Val()
|
|
switch key {
|
|
case "publisher_kafka_enabled":
|
|
var err error
|
|
a.PublisherKafkaEnabled, err = parseBool(h.Dispenser)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_kafka_enabled: %v", err)
|
|
}
|
|
case "publisher_kafka_broker":
|
|
if !h.AllArgs(&a.PublisherKafkaBroker) {
|
|
return nil, h.Errf("expected one string value for kafka_broker")
|
|
}
|
|
case "publisher_kafka_tls_enabled":
|
|
var err error
|
|
a.PublisherKafkaTLSEnabled, err = parseBool(h.Dispenser)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_kafka_tls_enabled: %v", err)
|
|
}
|
|
case "publisher_kafka_sasl_enabled":
|
|
var err error
|
|
a.PublisherKafkaSASLEnabled, err = parseBool(h.Dispenser)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_kafka_sasl_enabled: %v", err)
|
|
}
|
|
case "publisher_kafka_sasl_username":
|
|
if !h.AllArgs(&a.PublisherKafkaSASLUsername) {
|
|
return nil, h.Errf("expected one string value for publisher kafka sasl username")
|
|
}
|
|
case "publisher_kafka_sasl_password":
|
|
if !h.AllArgs(&a.PublisherKafkaSASLPassword) {
|
|
return nil, h.Errf("expected one string value for publisher kafka sasl password")
|
|
}
|
|
case "publisher_kafka_sasl_mechanism":
|
|
if !h.AllArgs(&a.PublisherKafkaSASLMechanism) {
|
|
return nil, h.Errf("expected one string value for publisher kafka sasl mechanism")
|
|
}
|
|
case "publisher_kafka_sasl_scram_sha_size":
|
|
var v string
|
|
if !h.AllArgs(&v) {
|
|
return nil, h.Errf("expected one integer value")
|
|
}
|
|
res, err := strconv.ParseInt(v, 10, 32)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_kafka_sasl_scram_sha_size: %v", err)
|
|
}
|
|
a.PublisherKafkaSASLScramSHASize = int(res)
|
|
case "publisher_nats_enabled":
|
|
var err error
|
|
a.PublisherNatsEnabled, err = parseBool(h.Dispenser)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_nats_enabled: %v", err)
|
|
}
|
|
case "publisher_nats_url":
|
|
if !h.AllArgs(&a.PublisherNatsURL) {
|
|
return nil, h.Errf("expected one string value for publisher_nats_url")
|
|
}
|
|
case "publisher_nats_client_id":
|
|
if !h.AllArgs(&a.PublisherNatsClientId) {
|
|
return nil, h.Errf("expected one string value for publisher_nats_client_id")
|
|
}
|
|
case "publisher_nats_max_reconnects":
|
|
var v string
|
|
if !h.AllArgs(&v) {
|
|
return nil, h.Errf("expected one integer value")
|
|
}
|
|
res, err := strconv.ParseInt(v, 10, 32)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_nats_max_reconnects: %v", err)
|
|
}
|
|
a.PublisherNatsMaxReconnects = int(res)
|
|
case "publisher_nats_max_reconnects_wait":
|
|
var v string
|
|
if !h.AllArgs(&v) {
|
|
return nil, h.Errf("expected one duration value")
|
|
}
|
|
res, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse publisher_nats_max_reconnects_wait: %v", err)
|
|
}
|
|
a.PublisherNatsMaxReconnectsWait = res
|
|
case "topic_name":
|
|
if !h.AllArgs(&a.TopicName) {
|
|
return nil, h.Errf("expected one string value")
|
|
}
|
|
case "auto_provision":
|
|
var v string
|
|
if !h.AllArgs(&v) {
|
|
return nil, h.Errf("expected one boolean value")
|
|
}
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse auto_provision: %v", err)
|
|
}
|
|
a.AutoProvision = b
|
|
case "auth_url":
|
|
if !h.AllArgs(&a.AuthURL) {
|
|
return nil, h.Errf("expected one string value for auth_internal_url")
|
|
}
|
|
case "auth_issuer":
|
|
if !h.AllArgs(&a.AuthIssuer) {
|
|
return nil, h.Errf("expected one string value for expected_issuer")
|
|
}
|
|
case "auth_enabled":
|
|
var err error
|
|
a.AuthEnabled, err = parseBool(h.Dispenser)
|
|
if err != nil {
|
|
return nil, h.Errf("failed to parse auth_enabled: %v", err)
|
|
}
|
|
case "organization_id":
|
|
if !h.AllArgs(&a.OrganizationID) {
|
|
return nil, h.Errf("expected one string value for organization_id")
|
|
}
|
|
case "stack_id":
|
|
if !h.AllArgs(&a.StackID) {
|
|
return nil, h.Errf("expected one string value for stack_id")
|
|
}
|
|
default:
|
|
return nil, h.Errf("unrecognized option: %s", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
if a.TopicName == "" {
|
|
return nil, fmt.Errorf("topic_name parameter is required")
|
|
}
|
|
if a.AuthEnabled && a.AuthURL == "" {
|
|
return nil, fmt.Errorf("auth_url parameter is required when auth_enabled is true")
|
|
}
|
|
|
|
return a, nil
|
|
}
|
|
|
|
func parseBool(d *caddyfile.Dispenser) (bool, error) {
|
|
var b string
|
|
if !d.AllArgs(&b) {
|
|
return false, d.Errf("expected one boolean value")
|
|
}
|
|
res, err := strconv.ParseBool(b)
|
|
if err != nil {
|
|
return false, d.Errf("expected boolean value")
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (a *Audit) Provision(ctx caddy.Context) error {
|
|
a.logger = ctx.Logger(a)
|
|
a.closing = &atomic.Bool{}
|
|
|
|
if a.PublisherKafkaEnabled {
|
|
if err := a.provisionKafkaPublisher(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if a.PublisherNatsEnabled {
|
|
if err := a.provisionNatsPublisher(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
opts := []audit.Option{
|
|
audit.WithOrganizationID(a.OrganizationID),
|
|
audit.WithStackID(a.StackID),
|
|
}
|
|
|
|
if a.AuthEnabled {
|
|
httpClient := http.DefaultClient
|
|
issuer := a.AuthIssuer
|
|
if issuer == "" {
|
|
issuer = a.AuthURL
|
|
}
|
|
|
|
discovery, err := discoverAuthConfiguration(ctx, a.AuthURL, issuer, httpClient)
|
|
if err != nil {
|
|
a.logger.Error("failed to discover oidc configuration", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
jwksURL, err := internalAuthEndpointURL(discovery.JwksURI, issuer, a.AuthURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
keySet := v5client.NewRemoteKeySet(httpClient, jwksURL)
|
|
opts = append(opts, audit.WithAuth(map[string]v5oidc.KeySet{issuer: keySet}))
|
|
}
|
|
|
|
a.middleware = httpaudit.Middleware(a.publisher, a.TopicName, EventApp, opts,
|
|
httpaudit.WithEnabled(true),
|
|
httpaudit.WithSensitivePaths("/api/auth/oauth/token"),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
func discoverAuthConfiguration(
|
|
ctx context.Context,
|
|
authURL string,
|
|
issuer string,
|
|
httpClient *http.Client,
|
|
) (*v5oidc.DiscoveryConfiguration, error) {
|
|
wellKnownURL := v5oidc.NormalizeIssuer(authURL) + v5oidc.DiscoveryEndpoint
|
|
return v5client.Discover[v5oidc.DiscoveryConfiguration](ctx, issuer, httpClient, wellKnownURL)
|
|
}
|
|
|
|
func internalAuthEndpointURL(endpoint string, issuer string, authURL string) (string, error) {
|
|
endpointURL, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse discovered auth endpoint url: %w", err)
|
|
}
|
|
|
|
issuerURL, err := url.Parse(v5oidc.NormalizeIssuer(issuer))
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse auth issuer url: %w", err)
|
|
}
|
|
|
|
authBaseURL, err := url.Parse(v5oidc.NormalizeIssuer(authURL))
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse auth url: %w", err)
|
|
}
|
|
|
|
if endpointURL.Scheme != issuerURL.Scheme ||
|
|
endpointURL.Host != issuerURL.Host ||
|
|
!sameOrChildPath(endpointURL.Path, issuerURL.Path) {
|
|
return endpoint, nil
|
|
}
|
|
|
|
suffix := strings.TrimPrefix(endpointURL.Path, strings.TrimRight(issuerURL.Path, "/"))
|
|
authBaseURL.Path = strings.TrimRight(authBaseURL.Path, "/") + suffix
|
|
authBaseURL.RawQuery = endpointURL.RawQuery
|
|
authBaseURL.Fragment = endpointURL.Fragment
|
|
|
|
return authBaseURL.String(), nil
|
|
}
|
|
|
|
func sameOrChildPath(path string, basePath string) bool {
|
|
basePath = strings.TrimRight(basePath, "/")
|
|
if basePath == "" {
|
|
return strings.HasPrefix(path, "/")
|
|
}
|
|
return path == basePath || strings.HasPrefix(path, basePath+"/")
|
|
}
|
|
|
|
func (a Audit) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
|
|
var handlerErr error
|
|
|
|
a.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
handlerErr = next.ServeHTTP(w, r)
|
|
})).ServeHTTP(w, r)
|
|
|
|
return handlerErr
|
|
}
|
|
|
|
func (a *Audit) Cleanup() error {
|
|
a.closing.Store(true)
|
|
if a.publisher != nil {
|
|
_ = a.publisher.Close()
|
|
}
|
|
if a.natsConn != nil {
|
|
a.natsConn.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Interface Guards
|
|
var (
|
|
_ caddy.Provisioner = (*Audit)(nil)
|
|
_ caddy.Module = (*Audit)(nil)
|
|
_ caddy.CleanerUpper = (*Audit)(nil)
|
|
_ caddyhttp.MiddlewareHandler = (*Audit)(nil)
|
|
)
|
|
|
|
func (a *Audit) provisionNatsPublisher() error {
|
|
jetStreamConfig := wNats.JetStreamConfig{
|
|
AutoProvision: a.AutoProvision,
|
|
DurablePrefix: "gateway",
|
|
}
|
|
|
|
natsOptions := []nats.Option{
|
|
nats.Name(a.PublisherNatsClientId),
|
|
nats.MaxReconnects(a.PublisherNatsMaxReconnects),
|
|
nats.ReconnectWait(a.PublisherNatsMaxReconnectsWait),
|
|
nats.ClosedHandler(func(c *nats.Conn) {
|
|
a.logger.Info("nats connection closed")
|
|
if a.closing.Load() {
|
|
return
|
|
}
|
|
err := caddy.Stop()
|
|
if err != nil {
|
|
a.logger.Error("failed to stop caddy", zap.Error(err))
|
|
panic(err)
|
|
}
|
|
os.Exit(1)
|
|
}),
|
|
}
|
|
|
|
publisherConfig := wNats.PublisherConfig{
|
|
URL: a.PublisherNatsURL,
|
|
NatsOptions: natsOptions,
|
|
JetStream: jetStreamConfig,
|
|
Marshaler: &wNats.NATSMarshaler{},
|
|
SubjectCalculator: wNats.DefaultSubjectCalculator,
|
|
}
|
|
|
|
var err error
|
|
a.natsConn, err = publish.NewNatsConn(publisherConfig)
|
|
if err != nil {
|
|
a.logger.Error("failed to create nats connection", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
a.publisher, err = publish.NewNatsPublisherWithConn(
|
|
a.natsConn,
|
|
logging.NewZapLoggerAdapter(a.logger),
|
|
publisherConfig,
|
|
)
|
|
if err != nil {
|
|
a.logger.Error("failed to create nats publisher", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *Audit) provisionKafkaPublisher() error {
|
|
options := []publish.SaramaOption{
|
|
publish.WithSASLCredentials(
|
|
a.PublisherKafkaSASLUsername,
|
|
a.PublisherKafkaSASLPassword,
|
|
),
|
|
}
|
|
|
|
if a.PublisherKafkaTLSEnabled {
|
|
options = append(options, publish.WithTLS())
|
|
}
|
|
|
|
if a.PublisherKafkaSASLEnabled {
|
|
options = append(options, publish.WithSASLMechanism(sarama.SASLMechanism(a.PublisherKafkaSASLMechanism)))
|
|
options = append(options,
|
|
publish.WithSASLScramClient(func() sarama.SCRAMClient {
|
|
var fn scram.HashGeneratorFcn
|
|
switch a.PublisherKafkaSASLScramSHASize {
|
|
case 512:
|
|
fn = publish.SHA512
|
|
case 256:
|
|
fn = publish.SHA256
|
|
default:
|
|
panic("sha size not handled")
|
|
}
|
|
return &publish.XDGSCRAMClient{
|
|
HashGeneratorFcn: fn,
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
var err error
|
|
a.publisher, err = publish.NewKafkaPublisher(
|
|
logging.NewZapLoggerAdapter(a.logger),
|
|
publish.NewSaramaConfig("gateway", sarama.V1_0_0_0, options...),
|
|
kafka.DefaultMarshaler{},
|
|
a.PublisherKafkaBroker,
|
|
)
|
|
if err != nil {
|
|
a.logger.Error("failed to create kafka publisher", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|