castle-rb 4.0.0 → 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +61 -20
- data/lib/castle.rb +5 -4
- data/lib/castle/api.rb +16 -7
- data/lib/castle/api/connection.rb +24 -0
- data/lib/castle/api/request.rb +16 -11
- data/lib/castle/api/session.rb +20 -0
- data/lib/castle/client.rb +29 -8
- data/lib/castle/configuration.rb +45 -20
- data/lib/castle/context/default.rb +1 -1
- data/lib/castle/extractors/headers.rb +10 -10
- data/lib/castle/extractors/ip.rb +37 -17
- data/lib/castle/{header_filter.rb → headers_filter.rb} +7 -7
- data/lib/castle/headers_formatter.rb +22 -0
- data/lib/castle/version.rb +1 -1
- data/spec/lib/castle/api/connection_spec.rb +59 -0
- data/spec/lib/castle/api/request_spec.rb +75 -37
- data/spec/lib/castle/api/session_spec.rb +86 -0
- data/spec/lib/castle/api_spec.rb +4 -4
- data/spec/lib/castle/client_spec.rb +2 -2
- data/spec/lib/castle/commands/impersonate_spec.rb +2 -2
- data/spec/lib/castle/configuration_spec.rb +17 -16
- data/spec/lib/castle/context/default_spec.rb +3 -2
- data/spec/lib/castle/extractors/client_id_spec.rb +1 -1
- data/spec/lib/castle/extractors/headers_spec.rb +11 -12
- data/spec/lib/castle/extractors/ip_spec.rb +39 -6
- data/spec/lib/castle/{header_filter_spec.rb → headers_filter_spec.rb} +6 -6
- data/spec/lib/castle/{header_formatter_spec.rb → headers_formatter_spec.rb} +2 -2
- data/spec/spec_helper.rb +1 -2
- metadata +18 -15
- data/lib/castle/api/request/build.rb +0 -27
- data/lib/castle/header_formatter.rb +0 -12
- data/spec/lib/castle/api/request/build_spec.rb +0 -46
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 441cbae1aed67e419bff1683c41183abb10c3890d5a159f3365c0b4bd3855f0b
|
4
|
+
data.tar.gz: 6ef7663b21e2de6b1ef833917dd4b62ee1891f017e33f88a1cb57252e5c3bcc4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b54200b206ea055878d2e4a6b46f82960cda186c4b57009765c7e4c8cce28c7f00c66ad216840f8fa5412a7ed876224f285a18388620bf7df9201425a0efb055
|
7
|
+
data.tar.gz: cf9f8b5019377138fe5e61ced02b6323aa27464dc117c521fe560a6832115416bbd7e92a64a4710b96939adcd49af12feb538f3d24392d9654b14a21a48021a5
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Ruby SDK for Castle
|
2
2
|
|
3
|
-
[![Build Status](https://
|
3
|
+
[![Build Status](https://circleci.com/gh/castle/castle-ruby.svg?style=shield&branch=master)](https://circleci.com/gh/castle/castle-ruby)
|
4
4
|
[![Coverage Status](https://coveralls.io/repos/github/castle/castle-ruby/badge.svg?branch=coveralls)](https://coveralls.io/github/castle/castle-ruby?branch=coveralls)
|
5
5
|
[![Gem Version](https://badge.fury.io/rb/castle-rb.svg)](https://badge.fury.io/rb/castle-rb)
|
6
6
|
|
@@ -65,39 +65,61 @@ Castle.configure do |config|
|
|
65
65
|
# Castle::RequestError is raised when timing out in milliseconds (default: 500 milliseconds)
|
66
66
|
config.request_timeout = 2000
|
67
67
|
|
68
|
-
#
|
69
|
-
#
|
68
|
+
# Allowlisted and Denylisted headers are case insensitive and allow to use _ and - as a separator, http prefixes are removed
|
69
|
+
# Allowlisted headers
|
70
70
|
# By default, the SDK sends all HTTP headers, except for Cookie and Authorization.
|
71
|
-
# If you decide to use a
|
71
|
+
# If you decide to use a allowlist, the SDK will:
|
72
72
|
# - always send the User-Agent header
|
73
|
-
# - send scrubbed values of non-
|
74
|
-
# - send proper values of
|
73
|
+
# - send scrubbed values of non-allowlisted headers
|
74
|
+
# - send proper values of allowlisted headers.
|
75
75
|
# @example
|
76
|
-
# config.
|
76
|
+
# config.allowlisted = ['X_HEADER']
|
77
77
|
# # will send { 'User-Agent' => 'Chrome', 'X_HEADER' => 'proper value', 'Any-Other-Header' => true }
|
78
78
|
#
|
79
|
-
# We highly suggest using
|
80
|
-
# as possible to secure your users. If you want to use the
|
79
|
+
# We highly suggest using denylist instead of allowlist, so that Castle can use as many data points
|
80
|
+
# as possible to secure your users. If you want to use the allowlist, this is the minimal
|
81
81
|
# amount of headers we recommend:
|
82
|
-
config.
|
82
|
+
config.allowlisted = Castle::Configuration::DEFAULT_ALLOWLIST
|
83
83
|
|
84
|
-
#
|
85
|
-
# We always
|
86
|
-
# might contain sensitive information, you should
|
87
|
-
config.
|
84
|
+
# Denylisted headers take precedence over allowlisted elements
|
85
|
+
# We always denylist Cookie and Authentication headers. If you use any other headers that
|
86
|
+
# might contain sensitive information, you should denylist them.
|
87
|
+
config.denylisted = ['HTTP-X-header']
|
88
88
|
|
89
89
|
# Castle needs the original IP of the client, not the IP of your proxy or load balancer.
|
90
|
-
#
|
91
|
-
#
|
92
|
-
#
|
90
|
+
# The SDK will only trust the proxy chain as defined in the configuration.
|
91
|
+
# We try to fetch the client IP based on X-Forwarded-For or Remote-Addr headers in that order,
|
92
|
+
# but sometimes the client IP may be stored in a different header or order.
|
93
|
+
# The SDK can be configured to look for the client IP address in headers that you specify.
|
94
|
+
|
95
|
+
# Sometimes, Cloud providers do not use consistent IP addresses to proxy requests.
|
96
|
+
# In this case, the client IP is usually preserved in a custom header. Example:
|
97
|
+
# Cloudflare preserves the client request in the 'Cf-Connecting-Ip' header.
|
98
|
+
# It would be used like so: configuration.ip_headers=['Cf-Connecting-Ip']
|
93
99
|
configuration.ip_headers = []
|
94
100
|
|
95
|
-
#
|
101
|
+
# If the specified header or X-Forwarded-For default contains a proxy chain with public IP addresses,
|
102
|
+
# then one of the following must be set
|
103
|
+
# 1. The trusted_proxies value must match the known proxy IP's
|
104
|
+
# 2. The trusted_proxy_depth value must be set to the number of known trusted proxies in the chain (see below)
|
105
|
+
|
106
|
+
# Additionally to make X-Forwarded-For and other headers work better discovering client ip address,
|
96
107
|
# and not the address of a reverse proxy server, you can define trusted proxies
|
97
108
|
# which will help to fetch proper ip from those headers
|
109
|
+
|
110
|
+
# In order to extract the client IP of the X-Forwarded-For header
|
111
|
+
# and not the address of a reverse proxy server, you must define all trusted public proxies
|
112
|
+
# you can achieve this by listing all the proxies ip defined by string or regular expressions
|
113
|
+
# in trusted_proxies setting
|
98
114
|
configuration.trusted_proxies = []
|
99
|
-
#
|
100
|
-
|
115
|
+
# or by providing number of trusted proxies used in the chain
|
116
|
+
configuration.trusted_proxy_depth = 0
|
117
|
+
|
118
|
+
# If there is no possibility to define options above and there is no other header which can have client ip
|
119
|
+
# then you may set trust_proxy_chain = true to trust all of the proxy IP's in X-Forwarded-For
|
120
|
+
configuration.trust_proxy_chain = false
|
121
|
+
|
122
|
+
# *Note: default list of proxies which is always marked as trusted: Castle::Configuration::TRUSTED_PROXIES
|
101
123
|
end
|
102
124
|
```
|
103
125
|
|
@@ -175,6 +197,25 @@ track_options = ::Castle::Client.to_options({
|
|
175
197
|
CastleTrackingWorker.perform_async(request_context, track_options)
|
176
198
|
```
|
177
199
|
|
200
|
+
## Connection reuse
|
201
|
+
|
202
|
+
If you want to reuse the connection to send multiple events:
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
Castle::API::Session.call do |http|
|
206
|
+
castle.track(
|
207
|
+
event: ::Castle::Events::LOGOUT_SUCCEEDED,
|
208
|
+
user_id: user2.id
|
209
|
+
http: http
|
210
|
+
)
|
211
|
+
castle.track(
|
212
|
+
event: ::Castle::Events::LOGIN_SUCCEEDED,
|
213
|
+
user_id: user1.id
|
214
|
+
http: http
|
215
|
+
)
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
178
219
|
## Events
|
179
220
|
|
180
221
|
List of Recognized Events can be found [here](https://github.com/castle/castle-ruby/tree/master/lib/castle/events.rb) or in the [docs](https://docs.castle.io/api_reference/#list-of-recognized-events)
|
data/lib/castle.rb
CHANGED
@@ -29,15 +29,16 @@
|
|
29
29
|
castle/configuration
|
30
30
|
castle/failover_auth_response
|
31
31
|
castle/client
|
32
|
-
castle/
|
33
|
-
castle/
|
32
|
+
castle/headers_filter
|
33
|
+
castle/headers_formatter
|
34
34
|
castle/secure_mode
|
35
35
|
castle/extractors/client_id
|
36
36
|
castle/extractors/headers
|
37
37
|
castle/extractors/ip
|
38
|
+
castle/api/connection
|
38
39
|
castle/api/response
|
39
40
|
castle/api/request
|
40
|
-
castle/api/
|
41
|
+
castle/api/session
|
41
42
|
castle/review
|
42
43
|
castle/api
|
43
44
|
].each(&method(:require))
|
@@ -54,7 +55,7 @@ module Castle
|
|
54
55
|
end
|
55
56
|
|
56
57
|
def config
|
57
|
-
|
58
|
+
Configuration.instance
|
58
59
|
end
|
59
60
|
|
60
61
|
def api_secret=(api_secret)
|
data/lib/castle/api.rb
CHANGED
@@ -17,16 +17,18 @@ module Castle
|
|
17
17
|
private_constant :HANDLED_ERRORS
|
18
18
|
|
19
19
|
class << self
|
20
|
-
|
20
|
+
# @param command [String]
|
21
|
+
# @param headers [Hash]
|
22
|
+
# @param http [Net::HTTP]
|
23
|
+
def request(command, headers = {}, http = nil)
|
21
24
|
raise Castle::ConfigurationError, 'configuration is not valid' unless Castle.config.valid?
|
22
25
|
|
23
26
|
begin
|
24
|
-
Castle::API::
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
)
|
27
|
+
Castle::API::Request.call(
|
28
|
+
command,
|
29
|
+
Castle.config.api_secret,
|
30
|
+
headers,
|
31
|
+
http
|
30
32
|
)
|
31
33
|
rescue *HANDLED_ERRORS => e
|
32
34
|
# @note We need to initialize the error, as the original error is a cause for this
|
@@ -35,6 +37,13 @@ module Castle
|
|
35
37
|
raise Castle::RequestError.new(e) # rubocop:disable Style/RaiseArgs
|
36
38
|
end
|
37
39
|
end
|
40
|
+
|
41
|
+
# @param command [String]
|
42
|
+
# @param headers [Hash]
|
43
|
+
# @param http [Net::HTTP]
|
44
|
+
def call(command, headers = {}, http = nil)
|
45
|
+
Castle::API::Response.call(request(command, headers, http))
|
46
|
+
end
|
38
47
|
end
|
39
48
|
end
|
40
49
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module API
|
5
|
+
# this module returns a new configured Net::HTTP object
|
6
|
+
module Connection
|
7
|
+
HTTPS_SCHEME = 'https'
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def call
|
11
|
+
http = Net::HTTP.new(Castle.config.url.host, Castle.config.url.port)
|
12
|
+
http.read_timeout = Castle.config.request_timeout / 1000.0
|
13
|
+
|
14
|
+
if Castle.config.url.scheme == HTTPS_SCHEME
|
15
|
+
http.use_ssl = true
|
16
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
17
|
+
end
|
18
|
+
|
19
|
+
http
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/lib/castle/api/request.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Castle
|
4
|
-
# this class is responsible for making requests to api
|
5
4
|
module API
|
5
|
+
# this class is responsible for making requests to api
|
6
6
|
module Request
|
7
7
|
# Default headers that we add to passed ones
|
8
8
|
DEFAULT_HEADERS = {
|
@@ -12,9 +12,9 @@ module Castle
|
|
12
12
|
private_constant :DEFAULT_HEADERS
|
13
13
|
|
14
14
|
class << self
|
15
|
-
def call(command, api_secret, headers)
|
16
|
-
http.request(
|
17
|
-
|
15
|
+
def call(command, api_secret, headers, http = nil)
|
16
|
+
(http || Castle::API::Connection.call).request(
|
17
|
+
build(
|
18
18
|
command,
|
19
19
|
headers.merge(DEFAULT_HEADERS),
|
20
20
|
api_secret
|
@@ -22,14 +22,19 @@ module Castle
|
|
22
22
|
)
|
23
23
|
end
|
24
24
|
|
25
|
-
def
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
25
|
+
def build(command, headers, api_secret)
|
26
|
+
request_obj = Net::HTTP.const_get(
|
27
|
+
command.method.to_s.capitalize
|
28
|
+
).new("#{Castle.config.url.path}/#{command.path}", headers)
|
29
|
+
|
30
|
+
unless command.method == :get
|
31
|
+
request_obj.body = ::Castle::Utils.replace_invalid_characters(
|
32
|
+
command.data
|
33
|
+
).to_json
|
31
34
|
end
|
32
|
-
|
35
|
+
|
36
|
+
request_obj.basic_auth('', api_secret)
|
37
|
+
request_obj
|
33
38
|
end
|
34
39
|
end
|
35
40
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Castle
|
4
|
+
module API
|
5
|
+
# this module uses the Connection object
|
6
|
+
# and provides start method for persistent connection usage
|
7
|
+
# when there is a need of sending multiple requests at once
|
8
|
+
module Session
|
9
|
+
HTTPS_SCHEME = 'https'
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def call(&block)
|
13
|
+
return unless block_given?
|
14
|
+
|
15
|
+
Connection.call.start(&block)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/castle/client.rb
CHANGED
@@ -42,9 +42,11 @@ module Castle
|
|
42
42
|
return generate_do_not_track_response(options[:user_id]) unless tracked?
|
43
43
|
|
44
44
|
add_timestamp_if_necessary(options)
|
45
|
-
|
45
|
+
|
46
46
|
begin
|
47
|
-
Castle::API
|
47
|
+
Castle::API
|
48
|
+
.call(authenticate_command(options), {}, options[:http])
|
49
|
+
.merge(failover: false, failover_reason: nil)
|
48
50
|
rescue Castle::RequestError, Castle::InternalServerError => e
|
49
51
|
self.class.failover_response_or_raise(
|
50
52
|
FailoverAuthResponse.new(options[:user_id], reason: e.to_s), e
|
@@ -59,8 +61,7 @@ module Castle
|
|
59
61
|
|
60
62
|
add_timestamp_if_necessary(options)
|
61
63
|
|
62
|
-
|
63
|
-
Castle::API.request(command)
|
64
|
+
Castle::API.call(identify_command(options), {}, options[:http])
|
64
65
|
end
|
65
66
|
|
66
67
|
def track(options = {})
|
@@ -70,15 +71,15 @@ module Castle
|
|
70
71
|
|
71
72
|
add_timestamp_if_necessary(options)
|
72
73
|
|
73
|
-
|
74
|
-
Castle::API.request(command)
|
74
|
+
Castle::API.call(track_command(options), {}, options[:http])
|
75
75
|
end
|
76
76
|
|
77
77
|
def impersonate(options = {})
|
78
78
|
options = Castle::Utils.deep_symbolize_keys(options || {})
|
79
|
+
|
79
80
|
add_timestamp_if_necessary(options)
|
80
|
-
|
81
|
-
Castle::API.
|
81
|
+
|
82
|
+
Castle::API.call(impersonate_command(options), {}, options[:http]).tap do |response|
|
82
83
|
raise Castle::ImpersonationFailed unless response[:success]
|
83
84
|
end
|
84
85
|
end
|
@@ -104,6 +105,26 @@ module Castle
|
|
104
105
|
).generate
|
105
106
|
end
|
106
107
|
|
108
|
+
# @param options [Hash]
|
109
|
+
def authenticate_command(options)
|
110
|
+
Castle::Commands::Authenticate.new(@context).build(options)
|
111
|
+
end
|
112
|
+
|
113
|
+
# @param options [Hash]
|
114
|
+
def identify_command(options)
|
115
|
+
Castle::Commands::Identify.new(@context).build(options)
|
116
|
+
end
|
117
|
+
|
118
|
+
# @param options [Hash]
|
119
|
+
def impersonate_command(options)
|
120
|
+
Castle::Commands::Impersonate.new(@context).build(options)
|
121
|
+
end
|
122
|
+
|
123
|
+
# @param options [Hash]
|
124
|
+
def track_command(options)
|
125
|
+
Castle::Commands::Track.new(@context).build(options)
|
126
|
+
end
|
127
|
+
|
107
128
|
def add_timestamp_if_necessary(options)
|
108
129
|
options[:timestamp] ||= @timestamp if @timestamp
|
109
130
|
end
|
data/lib/castle/configuration.rb
CHANGED
@@ -1,11 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'singleton'
|
4
|
+
require 'uri'
|
5
|
+
|
3
6
|
module Castle
|
4
7
|
# manages configuration variables
|
5
8
|
class Configuration
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
+
include Singleton
|
10
|
+
|
11
|
+
# API endpoint
|
12
|
+
URL = 'https://api.castle.io/v1'
|
9
13
|
FAILOVER_STRATEGY = :allow
|
10
14
|
REQUEST_TIMEOUT = 500 # in milliseconds
|
11
15
|
FAILOVER_STRATEGIES = %i[allow deny challenge throw].freeze
|
@@ -19,9 +23,9 @@ module Castle
|
|
19
23
|
\Aunix:
|
20
24
|
/ix].freeze
|
21
25
|
|
22
|
-
# @note this value is not assigned as we don't recommend using a
|
26
|
+
# @note this value is not assigned as we don't recommend using a allowlist. If you need to use
|
23
27
|
# one, this constant is provided as a good default.
|
24
|
-
|
28
|
+
DEFAULT_ALLOWLIST = %w[
|
25
29
|
Accept
|
26
30
|
Accept-Charset
|
27
31
|
Accept-Datetime
|
@@ -31,42 +35,58 @@ module Castle
|
|
31
35
|
Connection
|
32
36
|
Content-Length
|
33
37
|
Content-Type
|
38
|
+
Dnt
|
34
39
|
Host
|
35
40
|
Origin
|
36
41
|
Pragma
|
37
42
|
Referer
|
38
|
-
|
43
|
+
Sec-Fetch-Dest
|
44
|
+
Sec-Fetch-Mode
|
45
|
+
Sec-Fetch-Site
|
46
|
+
Sec-Fetch-User
|
47
|
+
Te
|
39
48
|
Upgrade-Insecure-Requests
|
49
|
+
User-Agent
|
40
50
|
X-Castle-Client-Id
|
51
|
+
X-Requested-With
|
41
52
|
].freeze
|
42
53
|
|
43
|
-
attr_accessor :
|
44
|
-
attr_reader :api_secret, :
|
54
|
+
attr_accessor :request_timeout, :trust_proxy_chain
|
55
|
+
attr_reader :api_secret, :allowlisted, :denylisted, :failover_strategy, :ip_headers,
|
56
|
+
:trusted_proxies, :trusted_proxy_depth, :url
|
45
57
|
|
46
58
|
def initialize
|
47
|
-
@formatter = Castle::
|
59
|
+
@formatter = Castle::HeadersFormatter
|
48
60
|
@request_timeout = REQUEST_TIMEOUT
|
61
|
+
reset
|
62
|
+
end
|
63
|
+
|
64
|
+
def reset
|
49
65
|
self.failover_strategy = FAILOVER_STRATEGY
|
50
|
-
self.
|
51
|
-
self.
|
52
|
-
self.
|
53
|
-
self.whitelisted = [].freeze
|
54
|
-
self.blacklisted = [].freeze
|
66
|
+
self.url = URL
|
67
|
+
self.allowlisted = [].freeze
|
68
|
+
self.denylisted = [].freeze
|
55
69
|
self.api_secret = ENV.fetch('CASTLE_API_SECRET', '')
|
56
70
|
self.ip_headers = [].freeze
|
57
71
|
self.trusted_proxies = [].freeze
|
72
|
+
self.trust_proxy_chain = false
|
73
|
+
self.trusted_proxy_depth = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def url=(value)
|
77
|
+
@url = URI(value)
|
58
78
|
end
|
59
79
|
|
60
80
|
def api_secret=(value)
|
61
81
|
@api_secret = value.to_s
|
62
82
|
end
|
63
83
|
|
64
|
-
def
|
65
|
-
@
|
84
|
+
def allowlisted=(value)
|
85
|
+
@allowlisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
|
66
86
|
end
|
67
87
|
|
68
|
-
def
|
69
|
-
@
|
88
|
+
def denylisted=(value)
|
89
|
+
@denylisted = (value ? value.map { |header| @formatter.call(header) } : []).freeze
|
70
90
|
end
|
71
91
|
|
72
92
|
# sets ip headers
|
@@ -78,15 +98,20 @@ module Castle
|
|
78
98
|
end
|
79
99
|
|
80
100
|
# sets trusted proxies
|
81
|
-
# @param value [Array<String
|
101
|
+
# @param value [Array<String,Regexp>]
|
82
102
|
def trusted_proxies=(value)
|
83
103
|
raise Castle::ConfigurationError, 'trusted proxies must be an Array' unless value.is_a?(Array)
|
84
104
|
|
85
105
|
@trusted_proxies = value
|
86
106
|
end
|
87
107
|
|
108
|
+
# @param value [String,Number,NilClass]
|
109
|
+
def trusted_proxy_depth=(value)
|
110
|
+
@trusted_proxy_depth = value.to_i
|
111
|
+
end
|
112
|
+
|
88
113
|
def valid?
|
89
|
-
!api_secret.to_s.empty? && !host.to_s.empty? && !port.to_s.empty?
|
114
|
+
!api_secret.to_s.empty? && !url.host.to_s.empty? && !url.port.to_s.empty?
|
90
115
|
end
|
91
116
|
|
92
117
|
def failover_strategy=(value)
|