Skip to content

Commit a2794c9

Browse files
committed
normalise hosts to URI::Generics, which reliably preserve defaults
An upstream bug in the Elasticsearch Ruby Client's handling of `String` host arguments that begin with a schema (e.g., `https://localhost`) causes it to default to port 80 or 443, depending on the schema, instead of Elasticsearch's port 9200. Since the Elasticsearch Ruby Client will accept a `URI` in this case, and will correctly handle falling through to appropriate defaults, we normalise to `URI::Generic`, which does not have a default port. We absorb the `ssl => true` case into this normalisation, as its previous implementation prevented the use of non-default ports in the array provided to `hosts`. We also add support for IPv6 addresses, requiring a square-bracketed notation (see: RFC2732) Supersedes: #104 Resolves: #110 Resolves: #111
1 parent a8ae485 commit a2794c9

File tree

4 files changed

+277
-12
lines changed

4 files changed

+277
-12
lines changed

docs/index.asciidoc

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ fields => {
221221

222222
* Value type is <<array,array>>
223223
* Default value is `["localhost:9200"]`
224+
* Format for each entry is one of:
225+
- a bare ipv4-literal, optionally followed by a colon and port number
226+
- a hostname, optionally followed by a colon and port number
227+
- a square-bracketed ipv6-literal, optionally followed by a colon and port number
228+
- any of the above can be prefixed with an explicit schema (e.g., `http://` or `https://`)
224229

225230
List of elasticsearch hosts to use for querying.
226231

@@ -281,7 +286,9 @@ Comma-delimited list of `<field>:<direction>` pairs that define the sort order
281286
* Value type is <<boolean,boolean>>
282287
* Default value is `false`
283288

284-
SSL
289+
Force SSL/TLS secured communication to Elasticsearch cluster.
290+
Leaving this unspecified will use whatever scheme is specified in the URLs listed in <<plugins-{type}s-{plugin}-hosts>>, where mixed schemes are supported.
291+
If SSL is set to `true`, the plugin will refuse to start if any of the hosts specifies an `http://` scheme.
285292

286293
[id="plugins-{type}s-{plugin}-tag_on_failure"]
287294
===== `tag_on_failure`

lib/logstash/filters/elasticsearch.rb

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def register
7171
@query_dsl = file.read
7272
end
7373

74+
@normalised_hosts = normalise_hosts(@hosts, @ssl)
75+
7476
test_connection!
7577
end # def register
7678

@@ -140,8 +142,7 @@ def filter(event)
140142
private
141143
def client_options
142144
{
143-
:ssl => @ssl,
144-
:hosts => @hosts,
145+
:hosts => @normalised_hosts,
145146
:ca_file => @ca_file,
146147
:logger => @logger
147148
}
@@ -191,4 +192,55 @@ def extract_total_from_hits(hits)
191192
def test_connection!
192193
get_client.client.ping
193194
end
195+
196+
private
197+
198+
##
199+
# Map the provided array-of-strings to an array of `URI::Generic`
200+
# instances, which the Elasticsearch client can use to establish
201+
# connections.
202+
#
203+
# @param hosts [Array<String>]: one or more hosts, each is one of:
204+
# - a bare hostname or ip, optionally
205+
# followed by a colon and port number
206+
# - a square-bracketed ipv6 literal,
207+
# optionally followed by a colon and port
208+
# number
209+
# - a qualified URL with http/https schema
210+
# @param force_ssl [Boolean]: true to force SSL; will cause failure if one
211+
# or more hosts explicitly supplies non-SSL
212+
# scheme (e.g., `http`).
213+
#
214+
# @return [Array<URI::Generic>]
215+
def normalise_hosts(hosts, force_ssl)
216+
hosts.map do |input|
217+
if force_ssl && input.start_with?('http://')
218+
logger.error("Plugin configured to force SSL with `ssl => true`, " +
219+
"but a host explicitly declared non-https URL `#{input}`")
220+
221+
raise LogStash::ConfigurationError, "Aborting due to conflicting configuration"
222+
end
223+
224+
if input.start_with?('http://','https://')
225+
URI::Generic.new(*URI.split(input))
226+
else
227+
if input =~ /^(\[[a-z0-9:]+\])(?::([0-9]+))?$/
228+
host, port = Regexp::last_match.captures
229+
else
230+
host, port = input.split(':')
231+
end
232+
URI::Generic.new(
233+
force_ssl ? 'https' : 'http',
234+
nil, # userinfo,
235+
host,
236+
port,
237+
nil, # registry
238+
nil, # path
239+
nil, # opaque
240+
nil, # query
241+
nil # fragment
242+
)
243+
end
244+
end
245+
end
194246
end #class LogStash::Filters::Elasticsearch

lib/logstash/filters/elasticsearch/client.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,15 @@ class ElasticsearchClient
1111
attr_reader :client
1212

1313
def initialize(user, password, options={})
14-
ssl = options.fetch(:ssl, false)
15-
hosts = options[:hosts]
16-
@logger = options[:logger]
14+
hosts = options.fetch(:hosts)
15+
@logger = options.fetch(:logger)
1716

1817
transport_options = {}
1918
if user && password
2019
token = ::Base64.strict_encode64("#{user}:#{password.value}")
2120
transport_options[:headers] = { Authorization: "Basic #{token}" }
2221
end
2322

24-
hosts.map! {|h| { host: h, scheme: 'https' } } if ssl
2523
# set ca_file even if ssl isn't on, since the host can be an https url
2624
ssl_options = { ssl: true, ca_file: options[:ca_file] } if options[:ca_file]
2725
ssl_options ||= {}

spec/filters/elasticsearch_spec.rb

Lines changed: 213 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,225 @@
44
require "logstash/filters/elasticsearch"
55
require "logstash/json"
66

7+
RSpec::Matchers.define(:hash_with_member) do |member, matcher=nil|
8+
match do |actual|
9+
expect(actual).to be_a Hash
10+
expect(actual).to include member
11+
12+
value = actual[member]
13+
14+
block_arg && block_arg.call(value)
15+
16+
matcher.nil? || matcher.matches?(value)
17+
end
18+
19+
description do
20+
desc = "hash with member `#{member.inspect}`"
21+
caveats = []
22+
caveats << 'the provided block' unless block_arg.nil?
23+
caveats << matcher.description unless matcher.nil?
24+
25+
desc += ' satisfying ' unless caveats.empty?
26+
desc += caveats.join(' and ')
27+
desc
28+
end
29+
end
30+
731
describe LogStash::Filters::Elasticsearch do
832

933
context "registration" do
1034

11-
let(:plugin) { LogStash::Plugin.lookup("filter", "elasticsearch").new({}) }
12-
before do
13-
allow(plugin).to receive(:test_connection!)
35+
let(:plugin_class) { LogStash::Plugin.lookup("filter", "elasticsearch") }
36+
let(:plugin) { plugin_class.new(config) }
37+
let(:config) { Hash.new }
38+
39+
context 'with defaults' do
40+
before do
41+
allow(plugin).to receive(:test_connection!)
42+
end
43+
44+
it "should not raise an exception" do
45+
expect {plugin.register}.to_not raise_error
46+
end
1447
end
1548

16-
it "should not raise an exception" do
17-
expect {plugin.register}.to_not raise_error
49+
context 'hosts' do
50+
let(:config) do
51+
super().merge(
52+
'hosts' => hosts
53+
)
54+
end
55+
let(:hosts) do
56+
fail NotImplementedError, 'spec or spec group must define `hosts`.'
57+
end
58+
59+
let(:client_stub) { double(:client).as_null_object }
60+
let(:logger_stub) { double(:logger).as_null_object }
61+
62+
before(:each) do
63+
allow(plugin).to receive(:logger).and_return(logger_stub)
64+
end
65+
66+
context 'with schema://hostname' do
67+
let(:hosts) { ['http://foo.local', 'http://bar.local'] }
68+
69+
it 'creates client with URIs that do not include a port' do
70+
expect(::Elasticsearch::Client).to receive(:new) do |options|
71+
expect(options).to include :hosts
72+
expect(options[:hosts]).to be_an Array
73+
expect(options[:hosts]).to include(having_attributes(host: 'foo.local', scheme: 'http', port: nil))
74+
expect(options[:hosts]).to include(having_attributes(host: 'bar.local', scheme: 'http', port: nil))
75+
end.and_return(client_stub)
76+
77+
plugin.register
78+
end
79+
end
80+
81+
context 'with `ssl => true`' do
82+
let(:config) { super().merge('ssl' => 'true') }
83+
context 'and one or more explicitly-http hosts' do
84+
let(:hosts) { ['https://foo.local', 'http://bar.local'] }
85+
86+
it 'raises an exception' do
87+
expect { plugin.register }.to raise_error(LogStash::ConfigurationError)
88+
end
89+
90+
it 'emits a helpful log message' do
91+
plugin.register rescue nil
92+
expect(plugin.logger).to have_received(:error).with(match(/force SSL/))
93+
end
94+
end
95+
96+
context 'and all explicitly-https hosts' do
97+
let(:hosts) { ['https://foo.local', 'https://bar.local'] }
98+
99+
it 'sets the schemas on all to https' do
100+
expect(::Elasticsearch::Client).to receive(:new) do |options|
101+
expect(options).to include :hosts
102+
expect(options[:hosts]).to be_an Array
103+
options[:hosts].each do |host|
104+
expect(host).to be_an URI
105+
expect(host.scheme).to eq 'https'
106+
end
107+
end.and_return(client_stub)
108+
109+
plugin.register
110+
end
111+
end
112+
113+
context 'and one or more schemaless hosts' do
114+
let(:hosts) { ['https://foo.local', 'bar.local'] }
115+
116+
it 'sets the schemas on all to https' do
117+
expect(::Elasticsearch::Client).to receive(:new) do |options|
118+
expect(options).to include :hosts
119+
expect(options[:hosts]).to be_an Array
120+
options[:hosts].each do |host|
121+
expect(host).to be_an URI
122+
expect(host.scheme).to eq 'https'
123+
end
124+
end.and_return(client_stub)
125+
126+
plugin.register
127+
end
128+
end
129+
130+
context 'with one or more square-bracketed ipv6 literals' do
131+
let(:hosts) { ['[::1]', '[::2]:9201', 'https://[::3]:9202'] }
132+
it 'defaults to the http protocol' do
133+
expect(::Elasticsearch::Client).to receive(:new) do |options|
134+
expect(options).to include :hosts
135+
expect(options[:hosts]).to be_an Array
136+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: '[::1]', port: nil))
137+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: '[::2]', port: 9201))
138+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: '[::3]', port: 9202))
139+
end.and_return(client_stub)
140+
141+
plugin.register
142+
end
143+
end
144+
end
145+
146+
{
147+
'with `ssl => false' => {'ssl' => 'false'},
148+
'without `ssl` directive' => {}
149+
}.each do |context_string, config_override|
150+
context(context_string) do
151+
let(:config) { super().merge(config_override) }
152+
153+
context 'with a mix of http and https hosts' do
154+
let(:hosts) { ['https://foo.local', 'http://bar.local'] }
155+
it 'does not modify the protocol' do
156+
expect(::Elasticsearch::Client).to receive(:new) do |options|
157+
expect(options).to include :hosts
158+
expect(options[:hosts]).to be_an Array
159+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: 'foo.local', port: nil))
160+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: 'bar.local', port: nil))
161+
end.and_return(client_stub)
162+
163+
plugin.register
164+
end
165+
end
166+
167+
context 'with https-only hosts' do
168+
let(:hosts) { ['https://foo.local', 'https://bar.local'] }
169+
it 'does not modify the protocol' do
170+
expect(::Elasticsearch::Client).to receive(:new) do |options|
171+
expect(options).to include :hosts
172+
expect(options[:hosts]).to be_an Array
173+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: 'foo.local', port: nil))
174+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: 'bar.local', port: nil))
175+
end.and_return(client_stub)
176+
177+
plugin.register
178+
end
179+
end
180+
181+
context 'with http-only hosts' do
182+
let(:hosts) { ['http://foo.local', 'http://bar.local'] }
183+
it 'does not modify the protocol' do
184+
expect(::Elasticsearch::Client).to receive(:new) do |options|
185+
expect(options).to include :hosts
186+
expect(options[:hosts]).to be_an Array
187+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: 'foo.local', port: nil))
188+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: 'bar.local', port: nil))
189+
end.and_return(client_stub)
190+
191+
plugin.register
192+
end
193+
end
194+
195+
context 'with one or more schemaless hosts' do
196+
let(:hosts) { ['foo.local', 'bar.local' ] }
197+
it 'defaults to the http protocol' do
198+
expect(::Elasticsearch::Client).to receive(:new) do |options|
199+
expect(options).to include :hosts
200+
expect(options[:hosts]).to be_an Array
201+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: 'foo.local', port: nil))
202+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: 'bar.local', port: nil))
203+
end.and_return(client_stub)
204+
205+
plugin.register
206+
end
207+
end
208+
209+
context 'with one or more square-bracketed ipv6 literals' do
210+
let(:hosts) { ['[::1]', '[::2]:9201', 'http://[::3]','https://[::4]:9202'] }
211+
it 'defaults to the http protocol' do
212+
expect(::Elasticsearch::Client).to receive(:new) do |options|
213+
expect(options).to include :hosts
214+
expect(options[:hosts]).to be_an Array
215+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: '[::1]', port: nil))
216+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: '[::2]', port: 9201))
217+
expect(options[:hosts]).to include(having_attributes(scheme: 'http', host: '[::3]', port: nil))
218+
expect(options[:hosts]).to include(having_attributes(scheme: 'https', host: '[::4]', port: 9202))
219+
end.and_return(client_stub)
220+
221+
plugin.register
222+
end
223+
end
224+
end
225+
end
18226
end
19227
end
20228

0 commit comments

Comments
 (0)