diff --git a/go.mod b/go.mod index a9c9c9eb7..325c03764 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/OneOfOne/xxhash v1.2.8 github.com/PaesslerAG/gval v1.2.3 github.com/PaesslerAG/jsonpath v0.1.1 + github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e github.com/apache/pulsar-client-go v0.15.0 github.com/aws/aws-lambda-go v1.46.0 github.com/aws/aws-msk-iam-sasl-signer-go v1.0.4 @@ -184,6 +185,7 @@ require ( github.com/gomlx/gopjrt v0.7.3 // indirect github.com/gomlx/onnx-gomlx v0.2.4 // indirect github.com/hamba/avro/v2 v2.26.0 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -330,7 +332,7 @@ require ( github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect - github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect diff --git a/go.sum b/go.sum index e0b805577..fc8b4c7c0 100644 --- a/go.sum +++ b/go.sum @@ -746,6 +746,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= @@ -1305,7 +1307,9 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index ad7718c70..3dc822da8 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -3,6 +3,7 @@ package httpclient import ( "bytes" "context" + "crypto/tls" "errors" "fmt" "io" @@ -75,16 +76,11 @@ func NewClientFromOldConfig(conf OldConfig, mgr *service.Resources, opts ...Requ h.client.Timeout = conf.Timeout } + var tlsClientConfig *tls.Config + var proxy func(*http.Request) (*url.URL, error) + if conf.TLSEnabled && conf.TLSConf != nil { - if c, ok := http.DefaultTransport.(*http.Transport); ok { - cloned := c.Clone() - cloned.TLSClientConfig = conf.TLSConf - h.client.Transport = cloned - } else { - h.client.Transport = &http.Transport{ - TLSClientConfig: conf.TLSConf, - } - } + tlsClientConfig = conf.TLSConf } if conf.ProxyURL != "" { @@ -92,17 +88,34 @@ func NewClientFromOldConfig(conf OldConfig, mgr *service.Resources, opts ...Requ if err != nil { return nil, fmt.Errorf("failed to parse proxy_url string: %v", err) } - if h.client.Transport != nil { - if tr, ok := h.client.Transport.(*http.Transport); ok { - tr.Proxy = http.ProxyURL(proxyURL) - } else { - return nil, fmt.Errorf("unable to apply proxy_url to transport, unexpected type %T", h.client.Transport) + proxy = http.ProxyURL(proxyURL) + } + + if tlsClientConfig != nil || proxy != nil { + if c, ok := http.DefaultTransport.(*http.Transport); ok { + cloned := c.Clone() + + if tlsClientConfig != nil { + cloned.TLSClientConfig = tlsClientConfig + } + + if proxy != nil { + cloned.Proxy = proxy } + + h.client.Transport = cloned } else { h.client.Transport = &http.Transport{ - Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: tlsClientConfig, + Proxy: proxy, } } + + if conf.Negotiate { + h.client.Transport = newSPPNEGORoundTripper(h.client.Transport) + } + } else if conf.Negotiate { + h.client.Transport = newSPPNEGORoundTripper(http.DefaultTransport) } h.client.Transport, err = newRequestLog(h.client.Transport, h.log, conf.DumpRequestLogLevel) diff --git a/internal/httpclient/config.go b/internal/httpclient/config.go index 4ccdca9a2..6a037106a 100644 --- a/internal/httpclient/config.go +++ b/internal/httpclient/config.go @@ -27,6 +27,7 @@ const ( hcFieldDumpRequestLogLevel = "dump_request_log_level" hcFieldTLS = "tls" hcFieldProxyURL = "proxy_url" + hcFieldNegotiate = "negotiate" ) // ConfigField returns a public API config field spec for an HTTP component, @@ -101,6 +102,10 @@ func ConfigField(defaultVerb string, forOutput bool, extraChildren ...*service.C Description("An optional HTTP proxy URL."). Advanced(). Optional(), + service.NewBoolField(hcFieldNegotiate). + Description("Use Negotiate (SPNEGO) authentication."). + Advanced(). + Default(false), ) innerFields = append(innerFields, extraChildren...) @@ -154,6 +159,7 @@ func ConfigFromParsed(pConf *service.ParsedConfig) (conf OldConfig, err error) { return } conf.ProxyURL, _ = pConf.FieldString(hcFieldProxyURL) + conf.Negotiate, _ = pConf.FieldBool(hcFieldNegotiate) if conf.authSigner, err = pConf.HTTPRequestAuthSignerFromParsed(); err != nil { return } @@ -182,6 +188,7 @@ type OldConfig struct { TLSEnabled bool TLSConf *tls.Config ProxyURL string + Negotiate bool authSigner func(f fs.FS, req *http.Request) error clientCtor func(context.Context, *http.Client) *http.Client } diff --git a/internal/httpclient/spnego_kerberos.go b/internal/httpclient/spnego_kerberos.go new file mode 100644 index 000000000..bc2736028 --- /dev/null +++ b/internal/httpclient/spnego_kerberos.go @@ -0,0 +1,165 @@ +//go:build !windows +// +build !windows + +package httpclient + +import ( + "bytes" + "io" + "net/http" + "os" + "os/user" + "runtime" + "strings" + + "github.com/jcmturner/gokrb5/v8/client" + "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +func kerberosConfigFromPath(path string) (*config.Config, error) { + file, err := os.Open(path) + + if err != nil { + return nil, err + } + + defer file.Close() + + return config.NewFromReader(file) +} + +func kerberosConfig() (*config.Config, error) { + configPath := os.Getenv("KRB5_CONFIG") + + if len(configPath) == 0 { + switch runtime.GOOS { + case "linux": + configPath = "/etc/krb5.conf" + case "darwin": + configPath = "/opt/local/etc/krb5.conf" + default: + configPath = "/etc/krb5/krb5.conf" + } + return kerberosConfigFromPath(configPath) + } + + config, err := kerberosConfigFromPath(configPath) + + if err != nil { + if os.IsNotExist(err) { + switch runtime.GOOS { + case "linux": + configPath = "/etc/krb5.conf" + case "darwin": + configPath = "/opt/local/etc/krb5.conf" + default: + configPath = "/etc/krb5/krb5.conf" + } + return kerberosConfigFromPath(configPath) + } + } + + return config, nil +} + +type kerberosTransport struct { + http.RoundTripper +} + +func (t *kerberosTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body := bytes.Buffer{} + if req.Body != nil { + _, err := body.ReadFrom(req.Body) + if err != nil { + return nil, err + } + req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + } + res, err := t.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusUnauthorized { + return res, err + } + + wwwAuthenticateHeaders := res.Header.Values("WWW-Authenticate") + + hasAuth := false + + for _, wwwAuthenticate := range wwwAuthenticateHeaders { + if strings.HasPrefix(wwwAuthenticate, "Negotiate ") { + hasAuth = true + break + } + + if strings.HasPrefix(wwwAuthenticate, "NTLM ") { + hasAuth = true + break + } + } + + if !hasAuth { + return res, err + } + + config, err := kerberosConfig() + if err != nil { + return nil, err + } + + var ccpath string + + ccname := os.Getenv("KRB5CCNAME") + if strings.HasPrefix(ccname, "FILE:") { + ccpath = ccname[len("FILE:"):] + } else { + u, err := user.Current() + if err != nil { + return nil, err + } + + ccpath = "/tmp/krb5cc_" + u.Uid + } + + ccache, err := credentials.LoadCCache(ccpath) + if err != nil { + return nil, err + } + + client, err := client.NewFromCCache(ccache, config, client.DisablePAFXFAST(true)) + + if err != nil { + return nil, err + } + + spn := req.Host + + if len(spn) == 0 { + spn = req.URL.Host + } + + spn = "http/" + spn + + err = spnego.SetSPNEGOHeader(client, req, spn) + + if err != nil { + return nil, err + } + + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + + return t.RoundTripper.RoundTrip(req) +} + +func newSPPNEGORoundTripper(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &kerberosTransport{ + RoundTripper: base, + } +} diff --git a/internal/httpclient/spnego_windows.go b/internal/httpclient/spnego_windows.go new file mode 100644 index 000000000..ed91d20fc --- /dev/null +++ b/internal/httpclient/spnego_windows.go @@ -0,0 +1,207 @@ +package httpclient + +import ( + "bytes" + "encoding/base64" + "errors" + "io" + "net/http" + "strings" + + "github.com/alexbrainman/sspi/negotiate" + "github.com/alexbrainman/sspi/ntlm" +) + +type windowsTransport struct { + http.RoundTripper +} + +func (t *windowsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body := bytes.Buffer{} + if req.Body != nil { + _, err := body.ReadFrom(req.Body) + if err != nil { + return nil, err + } + req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + } + res, err := t.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusUnauthorized { + return res, err + } + + wwwAuthenticateHeaders := res.Header.Values("Www-Authenticate") + + hasNegotiate := false + + for _, wwwAuthenticate := range wwwAuthenticateHeaders { + if strings.HasPrefix(wwwAuthenticate, "Negotiate") { + hasNegotiate = true + break + } + } + + if hasNegotiate { + cred, err := negotiate.AcquireCurrentUserCredentials() + if err != nil { + return nil, err + } + defer cred.Release() + + targetName := req.Host + + if len(targetName) == 0 { + targetName = req.URL.Host + } + + targetName = "http/" + targetName + + secctx, token, err := negotiate.NewClientContext(cred, targetName) + if err != nil { + return nil, err + } + defer secctx.Release() + + req.Header.Set("Authorization", "Negotiate "+base64.StdEncoding.EncodeToString(token)) + + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + + res, err := t.RoundTripper.RoundTrip(req) + + if err != nil { + return res, err + } + + if res.StatusCode != http.StatusOK { + return res, nil + } + + wwwAuthenticateHeaders = res.Header.Values("WWW-Authenticate") + + var negotiateToken []byte + + for _, wwwAuthenticate := range wwwAuthenticateHeaders { + if strings.HasPrefix(wwwAuthenticate, "Negotiate ") { + firstSpaceIndex := strings.IndexRune(wwwAuthenticate, ' ') + if firstSpaceIndex < 0 { + return nil, errors.New("invalid negotiate auth header") + } + + token, err := base64.StdEncoding.DecodeString(wwwAuthenticate[len("Negotiate "):]) + if err != nil { + return nil, err + } + + negotiateToken = token + break + } + } + + if len(negotiateToken) == 0 { + return nil, errors.New("empty negotiate auth header") + } + + authCompleted, _, err := secctx.Update(negotiateToken) + + if err != nil { + return nil, err + } + + if !authCompleted { + return nil, errors.New("client authentication not completed") + } + + return res, nil + } + + hasNTLM := false + + for _, wwwAuthenticate := range wwwAuthenticateHeaders { + if strings.HasPrefix(wwwAuthenticate, "NTLM") { + hasNTLM = true + break + } + } + + if hasNTLM { + cred, err := ntlm.AcquireCurrentUserCredentials() + if err != nil { + return nil, err + } + defer cred.Release() + + secctx, token, err := ntlm.NewClientContext(cred) + if err != nil { + return nil, err + } + defer secctx.Release() + + req.Header.Set("Authorization", "NTLM "+base64.StdEncoding.EncodeToString(token)) + + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + + res, err := t.RoundTripper.RoundTrip(req) + + if err != nil { + return res, err + } + + if res.StatusCode != http.StatusUnauthorized { + return res, nil + } + + wwwAuthenticateHeaders = res.Header.Values("Www-Authenticate") + + var ntlmToken []byte + + for _, wwwAuthenticate := range wwwAuthenticateHeaders { + if strings.HasPrefix(wwwAuthenticate, "NTLM ") { + firstSpaceIndex := strings.IndexRune(wwwAuthenticate, ' ') + if firstSpaceIndex < 0 { + return nil, errors.New("invalid NTLM auth header") + } + + token, err := base64.StdEncoding.DecodeString(wwwAuthenticate[len("NTLM "):]) + if err != nil { + return nil, err + } + + ntlmToken = token + break + } + } + + if len(ntlmToken) == 0 { + return nil, errors.New("empty negotiate auth header") + } + + authenticate, err := secctx.Update(ntlmToken) + + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "NTLM "+base64.StdEncoding.EncodeToString(authenticate)) + + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + + return t.RoundTripper.RoundTrip(req) + } + + req.Body = io.NopCloser(bytes.NewReader(body.Bytes())) + + return t.RoundTripper.RoundTrip(req) +} + +func newSPPNEGORoundTripper(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &windowsTransport{ + RoundTripper: base, + } +} diff --git a/website/docs/components/inputs/http_client.md b/website/docs/components/inputs/http_client.md index c0c6659d5..2d1904433 100644 --- a/website/docs/components/inputs/http_client.md +++ b/website/docs/components/inputs/http_client.md @@ -102,6 +102,7 @@ input: drop_on: [] successful_on: [] proxy_url: "" # No default (optional) + negotiate: false payload: "" # No default (optional) drop_empty_bodies: true stream: @@ -724,6 +725,14 @@ An optional HTTP proxy URL. Type: `string` +### `negotiate` + +Use Negotiate (SPNEGO) authentication. + + +Type: `bool` +Default: `false` + ### `payload` An optional payload to deliver for each request. diff --git a/website/docs/components/outputs/http_client.md b/website/docs/components/outputs/http_client.md index d47ff2dcd..3c97d4d4a 100644 --- a/website/docs/components/outputs/http_client.md +++ b/website/docs/components/outputs/http_client.md @@ -102,6 +102,7 @@ output: drop_on: [] successful_on: [] proxy_url: "" # No default (optional) + negotiate: false batch_as_multipart: false propagate_response: false max_in_flight: 64 @@ -698,6 +699,14 @@ An optional HTTP proxy URL. Type: `string` +### `negotiate` + +Use Negotiate (SPNEGO) authentication. + + +Type: `bool` +Default: `false` + ### `batch_as_multipart` Send message batches as a single request using [RFC1341](https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html). If disabled messages in batches will be sent as individual requests. diff --git a/website/docs/components/processors/http.md b/website/docs/components/processors/http.md index 2fbf19888..5121ab3b1 100644 --- a/website/docs/components/processors/http.md +++ b/website/docs/components/processors/http.md @@ -94,6 +94,7 @@ http: drop_on: [] successful_on: [] proxy_url: "" # No default (optional) + negotiate: false batch_as_multipart: false parallel: false ``` @@ -716,6 +717,14 @@ An optional HTTP proxy URL. Type: `string` +### `negotiate` + +Use Negotiate (SPNEGO) authentication. + + +Type: `bool` +Default: `false` + ### `batch_as_multipart` Send message batches as a single request using [RFC1341](https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html).