Skip to content

Commit 9d8f9ec

Browse files
committed
websocket support
Signed-off-by: ad <[email protected]>
1 parent 3dd5dfe commit 9d8f9ec

File tree

9 files changed

+490
-13
lines changed

9 files changed

+490
-13
lines changed

CONFIGURATION.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ modules:
4141
[ dns: <dns_probe> ]
4242
[ icmp: <icmp_probe> ]
4343
[ grpc: <grpc_probe> ]
44+
[ websocket: <websocket_probe> ]
4445

4546
```
4647

@@ -318,9 +319,105 @@ tls_config:
318319
[ <tls_config> ]
319320
```
320321

322+
### `<websocket_probe>`
323+
324+
```yml
325+
# Optional HTTP request configuration
326+
http_config:
327+
328+
# The HTTP basic authentification credentials
329+
basic_auth:
330+
[ username: <string> ]
331+
[ password: <string >]
332+
333+
# Sets the `Authorization: Bearer <token>` header on every request with
334+
# the configured token.
335+
[ bearer_token: <string>
336+
337+
# Sets HTTP headers for the request
338+
headers:
339+
[ - [ header_name: <string> ], ... ]
340+
341+
342+
# Whether to skip certificate verification on connect
343+
[insecure_skip_verify: <boolean> | default = true ]
344+
345+
# The query sent after connection upgrade and the expected associated response.
346+
query_response:
347+
[ - [ [ expect: <string> ],
348+
[ send: <string> ],
349+
[ starttls: <boolean | default = false> ]
350+
], ...
351+
]
352+
353+
```
354+
355+
### `<tls_config>`
356+
357+
```yml
358+
359+
# Disable target certificate validation.
360+
[ insecure_skip_verify: <boolean> | default = false ]
361+
362+
# The CA cert to use for the targets.
363+
[ ca_file: <filename> ]
364+
365+
# The client cert file for the targets.
366+
[ cert_file: <filename> ]
367+
368+
# The client key file for the targets.
369+
[ key_file: <filename> ]
370+
371+
# Used to verify the hostname for the targets.
372+
[ server_name: <string> ]
373+
374+
# Minimum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS
375+
# 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3).
376+
# If unset, Prometheus will use Go default minimum version, which is TLS 1.2.
377+
# See MinVersion in https://pkg.go.dev/crypto/tls#Config.
378+
[ min_version: <string> ]
379+
380+
# Maximum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS
381+
# 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3).
382+
# Can be used to test for the presence of insecure TLS versions.
383+
# If unset, Prometheus will use Go default maximum version, which is TLS 1.3.
384+
# See MaxVersion in https://pkg.go.dev/crypto/tls#Config.
385+
[ max_version: <string> ]
386+
```
387+
388+
#### `<oauth2>`
389+
390+
OAuth 2.0 authentication using the client credentials grant type. Blackbox
391+
exporter fetches an access token from the specified endpoint with the given
392+
client access and secret keys.
393+
394+
NOTE: This is *experimental* in the blackbox exporter and might not be
395+
reflected properly in the probe metrics at the moment.
396+
397+
```yml
398+
client_id: <string>
399+
[ client_secret: <secret> ]
400+
401+
# Read the client secret from a file.
402+
# It is mutually exclusive with `client_secret`.
403+
[ client_secret_file: <filename> ]
404+
405+
# Scopes for the token request.
406+
scopes:
407+
[ - <string> ... ]
408+
409+
# The URL to fetch the token from.
410+
token_url: <string>
411+
412+
# Optional parameters to append to the token URL.
413+
endpoint_params:
414+
[ <string>: <string> ... ]
415+
```
416+
321417
### `<tls_config>`
322418

323419
```yml
420+
[ http_config: <websocket_http_config>]
324421
325422
# Disable target certificate validation.
326423
[ insecure_skip_verify: <boolean> | default = false ]

blackbox.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,5 @@ modules:
4949
timeout: 5s
5050
icmp:
5151
ttl: 5
52+
websocket:
53+
prober: websocket

config/config.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package config
1515

1616
import (
17+
"encoding/base64"
1718
"errors"
1819
"fmt"
1920
"math"
@@ -193,13 +194,14 @@ func MustNewRegexp(s string) Regexp {
193194
}
194195

195196
type Module struct {
196-
Prober string `yaml:"prober,omitempty"`
197-
Timeout time.Duration `yaml:"timeout,omitempty"`
198-
HTTP HTTPProbe `yaml:"http,omitempty"`
199-
TCP TCPProbe `yaml:"tcp,omitempty"`
200-
ICMP ICMPProbe `yaml:"icmp,omitempty"`
201-
DNS DNSProbe `yaml:"dns,omitempty"`
202-
GRPC GRPCProbe `yaml:"grpc,omitempty"`
197+
Prober string `yaml:"prober,omitempty"`
198+
Timeout time.Duration `yaml:"timeout,omitempty"`
199+
HTTP HTTPProbe `yaml:"http,omitempty"`
200+
TCP TCPProbe `yaml:"tcp,omitempty"`
201+
ICMP ICMPProbe `yaml:"icmp,omitempty"`
202+
DNS DNSProbe `yaml:"dns,omitempty"`
203+
GRPC GRPCProbe `yaml:"grpc,omitempty"`
204+
Websocket WebsocketProbe `yaml:"websocket,omitempty"`
203205
}
204206

205207
type HTTPProbe struct {
@@ -287,6 +289,27 @@ type DNSRRValidator struct {
287289
FailIfNoneMatchesRegexp []string `yaml:"fail_if_none_matches_regexp,omitempty"`
288290
}
289291

292+
type WebsocketProbe struct {
293+
HTTPClientConfig HTTPClientConfig `yaml:"http_config,omitempty"`
294+
QueryResponse []QueryResponse `yaml:"query_response,omitempty"`
295+
}
296+
297+
type HTTPClientConfig struct {
298+
HTTPHeaders map[string]interface{} `yaml:"headers,omitempty"`
299+
BasicAuth HTTPBasicAuth `yaml:"basic_auth,omitempty"`
300+
BearerToken string `yaml:"bearer_token,omitempty"`
301+
InsecureSkipVerify bool `yaml:"insecure_skip_verify,omitempty"`
302+
}
303+
304+
type HTTPBasicAuth struct {
305+
Username string `yaml:"username"`
306+
Password string `yaml:"password"`
307+
}
308+
309+
func (c *HTTPBasicAuth) BasicAuthHeader() string {
310+
return "Basic " + base64.StdEncoding.EncodeToString([]byte(c.Username+":"+c.Password))
311+
}
312+
290313
// UnmarshalYAML implements the yaml.Unmarshaler interface.
291314
func (s *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
292315
type plain Config

example.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,18 @@ modules:
181181
transport_protocol: "tcp" # defaults to "udp"
182182
preferred_ip_protocol: "ip4" # defaults to "ip6"
183183
query_name: "www.prometheus.io"
184+
websocket_example:
185+
prober: websocket
186+
websocket:
187+
http_config:
188+
basic_auth:
189+
username: "user"
190+
password: "password"
191+
bearer_token: "secret_token"
192+
headers:
193+
X-Some-Header: "my_header"
194+
insecure_skip_verify: true
195+
query_response:
196+
- expect: ^Hello,\s(.+)"
197+
- send: "Hello server, i'am ${1}"
198+
- expect: ^Welcome

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ require (
77
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9
88
github.com/andybalholm/brotli v1.1.0
99
github.com/go-kit/log v0.2.1
10+
github.com/gorilla/websocket v1.5.3
1011
github.com/miekg/dns v1.1.61
1112
github.com/prometheus/client_golang v1.19.1
1213
github.com/prometheus/client_model v0.6.1
1314
github.com/prometheus/common v0.55.0
1415
github.com/prometheus/exporter-toolkit v0.11.0
1516
golang.org/x/net v0.27.0
17+
golang.org/x/text v0.16.0
1618
google.golang.org/grpc v1.65.0
1719
gopkg.in/yaml.v2 v2.4.0
1820
gopkg.in/yaml.v3 v3.0.1
@@ -33,7 +35,6 @@ require (
3335
golang.org/x/oauth2 v0.21.0 // indirect
3436
golang.org/x/sync v0.7.0 // indirect
3537
golang.org/x/sys v0.22.0 // indirect
36-
golang.org/x/text v0.16.0 // indirect
3738
golang.org/x/tools v0.22.0 // indirect
3839
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect
3940
google.golang.org/protobuf v1.34.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KE
2020
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
2121
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
2222
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
23+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
24+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
2325
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
2426
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
2527
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

prober/handler.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@ import (
3434

3535
var (
3636
Probers = map[string]ProbeFn{
37-
"http": ProbeHTTP,
38-
"tcp": ProbeTCP,
39-
"icmp": ProbeICMP,
40-
"dns": ProbeDNS,
41-
"grpc": ProbeGRPC,
37+
"http": ProbeHTTP,
38+
"tcp": ProbeTCP,
39+
"icmp": ProbeICMP,
40+
"dns": ProbeDNS,
41+
"grpc": ProbeGRPC,
42+
"websocket": ProbeWebsocket,
4243
}
4344
)
4445

prober/websocket.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright 2016 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package prober
15+
16+
import (
17+
"context"
18+
"crypto/tls"
19+
"net/http"
20+
"net/url"
21+
22+
"github.com/go-kit/log"
23+
"github.com/go-kit/log/level"
24+
"github.com/gorilla/websocket"
25+
"github.com/prometheus/blackbox_exporter/config"
26+
"github.com/prometheus/client_golang/prometheus"
27+
"golang.org/x/text/cases"
28+
"golang.org/x/text/language"
29+
)
30+
31+
func ProbeWebsocket(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger log.Logger) (success bool) {
32+
33+
targetURL, err := url.Parse(target)
34+
if err != nil {
35+
logger.Log("msg", "Could not parse target URL", "err", err)
36+
return false
37+
}
38+
39+
level.Debug(logger).Log("msg", "probing websocket", "target", targetURL.String())
40+
41+
httpStatusCode := prometheus.NewGauge(prometheus.GaugeOpts{
42+
Name: "probe_http_status_code",
43+
Help: "Response HTTP status code",
44+
})
45+
isConnected := prometheus.NewGauge(prometheus.GaugeOpts{
46+
Name: "probe_is_upgraded",
47+
Help: "Indicates if the websocket connection was successfully upgraded",
48+
})
49+
50+
registry.MustRegister(isConnected)
51+
registry.MustRegister(httpStatusCode)
52+
53+
dialer := websocket.Dialer{
54+
TLSClientConfig: &tls.Config{
55+
InsecureSkipVerify: module.Websocket.HTTPClientConfig.InsecureSkipVerify,
56+
},
57+
}
58+
59+
connection, resp, err := dialer.DialContext(ctx, targetURL.String(), constructHeadersFromConfig(module.Websocket.HTTPClientConfig, logger))
60+
if resp != nil {
61+
httpStatusCode.Set(float64(resp.StatusCode))
62+
}
63+
if err != nil {
64+
logger.Log("msg", "Error dialing websocket", "err", err)
65+
return false
66+
}
67+
defer connection.Close()
68+
69+
isConnected.Set(1)
70+
71+
if len(module.Websocket.QueryResponse) > 0 {
72+
probeFailedDueToRegex := prometheus.NewGauge(prometheus.GaugeOpts{
73+
Name: "probe_failed_due_to_regex",
74+
Help: "Indicates if probe failed due to regex",
75+
})
76+
registry.MustRegister(probeFailedDueToRegex)
77+
78+
queryMatched := true
79+
for _, qr := range module.Websocket.QueryResponse {
80+
send := qr.Send
81+
82+
if qr.Expect.Regexp != nil {
83+
var match []int
84+
_, message, err := connection.ReadMessage()
85+
if err != nil {
86+
logger.Log("msg", "Error reading message", "err", err)
87+
queryMatched = false
88+
break
89+
}
90+
match = qr.Expect.Regexp.FindSubmatchIndex(message)
91+
if match != nil {
92+
level.Debug(logger).Log("msg", "regexp matched", "regexp", qr.Expect.Regexp, "line", message)
93+
} else {
94+
level.Error(logger).Log("msg", "Regexp did not match", "regexp", qr.Expect.Regexp, "line", message)
95+
queryMatched = false
96+
break
97+
}
98+
send = string(qr.Expect.Regexp.Expand(nil, []byte(send), message, match))
99+
}
100+
101+
if send != "" {
102+
err = connection.WriteMessage(websocket.TextMessage, []byte(send))
103+
if err != nil {
104+
queryMatched = false
105+
logger.Log("msg", "Error sending message", "err", err)
106+
break
107+
}
108+
level.Debug(logger).Log("msg", "message sent", "message", send)
109+
}
110+
}
111+
if queryMatched {
112+
probeFailedDueToRegex.Set(0)
113+
} else {
114+
probeFailedDueToRegex.Set(1)
115+
}
116+
}
117+
118+
return true
119+
}
120+
121+
func constructHeadersFromConfig(config config.HTTPClientConfig, logger log.Logger) map[string][]string {
122+
headers := http.Header{}
123+
if config.BasicAuth.Username != "" || config.BasicAuth.Password != "" {
124+
headers.Add("Authorization", config.BasicAuth.BasicAuthHeader())
125+
} else if config.BearerToken != "" {
126+
headers.Add("Authorization", "Bearer "+config.BearerToken)
127+
}
128+
for key, value := range config.HTTPHeaders {
129+
if _, ok := value.(string); ok {
130+
headers.Add(key, value.(string))
131+
} else if _, ok := value.([]string); ok {
132+
headers[cases.Title(language.English).String(key)] = append(headers[key], value.([]string)...)
133+
}
134+
}
135+
136+
level.Debug(logger).Log("msg", "Constructed headers", "headers", headers)
137+
return headers
138+
}

0 commit comments

Comments
 (0)