Modules cloud id auth (#8059)

* add newlines to generated json

* Implement cloud.id and cloud.auth settings merge to module settings

* Fixes from review plus convert to using Password for any Module Setting

* Review changes

* update modules.asciidoc to include a section on Cloud

* Capitalize Id

* remove unnecessesary require lines
This commit is contained in:
Guy Boertje 2017-08-28 16:46:02 +01:00 committed by Suyog Rao
parent fec6288a41
commit c5f56e84d8
16 changed files with 515 additions and 13 deletions

View file

@ -109,6 +109,19 @@
#
# modules:
#
# ------------ Cloud Settings ---------------
# Define Elastic Cloud settings here.
# Format of cloud.id is a base64 value e.g. dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy
# and it may have an label prefix e.g. staging:dXMtZ...
# This will overwrite 'var.elasticsearch.hosts' and 'var.kibana.host'
# cloud.id: <identifier>
#
# Format of cloud.auth is: <user>:<pass>
# This is optional
# If supplied this will overwrite 'var.elasticsearch.username' and 'var.elasticsearch.password'
# If supplied this will overwrite 'var.kibana.username' and 'var.kibana.password'
# cloud.auth: elastic:<password>
#
# ------------ Queuing Settings --------------
#
# Internal queuing model, "memory" for legacy in-memory based queuing and

View file

@ -107,7 +107,7 @@ Elasticsearch `host` setting and the `udp.port` setting:
[source,shell]
----
bin/logstash --modules netflow -M "netflow.var.input.udp.port=3555" -M "netflow.var.elasticseach.host=my-es-cloud"
bin/logstash --modules netflow -M "netflow.var.input.udp.port=3555" -M "netflow.var.elasticsearch.hosts=my-es-cloud"
----
Any settings defined in the command line are ephemeral and will not persist across
@ -118,4 +118,64 @@ Settings that you specify at the command line are merged with any settings
specified in the `logstash.yml` file. If an option is set in both
places, the value specified at the command line takes precedence.
[[connecting-to-cloud]]
=== Using Elastic Cloud
Logstash comes with two settings that simplify using modules with https://cloud.elastic.co/[Elastic Cloud].
The Elasticsearch and Kibana hostnames in Elastic Cloud may be hard to set
in the Logstash config or on the commandline, so a Cloud ID can be used instead.
==== Cloud ID
The Cloud ID, which can be found in the Elastic Cloud web console, is used by
Logstash to build the Elasticsearch and Kibana hosts settings.
It is a base64 encoded text value of about 120 characters made up of upper and
lower case letters and numbers.
If you have several Cloud IDs, you can add a label, which is ignored
internally, to help you tell them apart. To add a label you should prefix your
Cloud ID with a label and a `:` separator in this format "<label>:<cloud-id>"
`cloud.id` will overwrite these settings:
----
var.elasticsearch.hosts
var.kibana.host
----
==== Cloud Auth
This is optional. Construct this value by following this format "<username>:<password>".
Use your Cloud username for the first part. Use your Cloud password for the second part,
which is given once in the Cloud UI when you create a cluster.
As your Cloud password is changeable, if you change it in the Cloud UI remember to change it here too.
`cloud.auth` when specified will overwrite these settings:
----
var.elasticsearch.username
var.elasticsearch.password
var.kibana.username
var.kibana.password
----
Example:
These settings can be specified in the `logstash.yml` <<logstash-settings-file,settings file>>.
They should be added separately from any module configuration settings you may have added before.
[source,yaml]
----
# example with a label
cloud.id: "staging:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy"
cloud.auth: "elastic:changeme"
----
----
# example without a label
cloud.id: "dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy"
cloud.auth: "elastic:changeme"
----
These settings can be also specified at the command line, like this:
["source","sh",subs="attributes,callouts"]
----
bin/logstash --modules netflow -M "netflow.var.input.udp.port=3555" --cloud.id <cloud-id> --cloud.auth <cloud.auth>
----

View file

@ -64,6 +64,7 @@ module LogStash module Config
alt_name = "module-#{module_name}"
pipeline_id = alt_name
module_settings.set("pipeline.id", pipeline_id)
LogStash::Modules::SettingsMerger.merge_cloud_settings(module_hash, module_settings)
current_module.with_settings(module_hash)
config_test = settings.get("config.test_and_exit")
modul_setup = settings.get("modules_setup")

View file

@ -39,8 +39,11 @@ module LogStash class ElasticsearchClient
@client_args[:ssl] = ssl_options
username = @settings["var.elasticsearch.username"]
password = @settings["var.elasticsearch.password"]
if username
password = @settings["var.elasticsearch.password"]
if password.is_a?(LogStash::Util::Password)
password = password.value
end
@client_args[:transport_options] = { :headers => { "Authorization" => 'Basic ' + Base64.encode64( "#{username}:#{password}" ).chomp } }
end

View file

@ -3,6 +3,9 @@ require "logstash/errors"
require "logstash/java_integration"
require "logstash/config/cpu_core_strategy"
require "logstash/settings"
require "logstash/util/cloud_setting_id"
require "logstash/util/cloud_setting_auth"
require "logstash/util/modules_setting_array"
require "socket"
require "stud/temporary"
@ -20,8 +23,10 @@ module LogStash
Setting::NullableString.new("path.config", nil, false),
Setting::WritableDirectory.new("path.data", ::File.join(LogStash::Environment::LOGSTASH_HOME, "data")),
Setting::NullableString.new("config.string", nil, false),
Setting.new("modules.cli", Array, []),
Setting.new("modules", Array, []),
Setting::Modules.new("modules.cli", LogStash::Util::ModulesSettingArray, []),
Setting::Modules.new("modules", LogStash::Util::ModulesSettingArray, []),
Setting::Modules.new("cloud.id", LogStash::Util::CloudSettingId),
Setting::Modules.new("cloud.auth",LogStash::Util::CloudSettingAuth),
Setting::Boolean.new("modules_setup", false),
Setting::Boolean.new("config.test_and_exit", false),
Setting::Boolean.new("config.reload.automatic", false),

View file

@ -50,12 +50,14 @@ module LogStash module Modules class KibanaClient
@client = Manticore::Client.new(client_options)
@host = @settings.fetch("var.kibana.host", "localhost:5601")
username = @settings["var.kibana.username"]
password = @settings["var.kibana.password"]
@scheme = @settings.fetch("var.kibana.scheme", "http")
@http_options = {:headers => {'Content-Type' => 'application/json'}}
username = @settings["var.kibana.username"]
if username
password = @settings["var.kibana.password"]
if password.is_a?(LogStash::Util::Password)
password = password.value
end
@http_options[:headers]['Authorization'] = 'Basic ' + Base64.encode64( "#{username}:#{password}" ).chomp
end

View file

@ -1,8 +1,13 @@
# encoding: utf-8
require "logstash/namespace"
require "logstash/util"
require "logstash/util/loggable"
module LogStash module Modules class SettingsMerger
def self.merge(cli_settings, yml_settings)
module LogStash module Modules module SettingsMerger
include LogStash::Util::Loggable
extend self
def merge(cli_settings, yml_settings)
# both args are arrays of hashes, e.g.
# [{"name"=>"mod1", "var.input.tcp.port"=>"3333"}, {"name"=>"mod2"}]
# [{"name"=>"mod1", "var.input.tcp.port"=>2222, "var.kibana.username"=>"rupert", "var.kibana.password"=>"fotherington"}, {"name"=>"mod3", "var.input.tcp.port"=>4445}]
@ -11,13 +16,56 @@ module LogStash module Modules class SettingsMerger
# union will also coalesce identical hashes
union_of_settings = (cli_settings | yml_settings)
grouped_by_name = union_of_settings.group_by{|e| e["name"]}
grouped_by_name.each do |name, array|
grouped_by_name.each do |_, array|
if array.size == 2
merged << array.first.merge(array.last)
merged << array.last.merge(array.first)
else
merged.concat(array)
end
end
merged
end
def merge_cloud_settings(module_settings, logstash_settings)
cloud_id = logstash_settings.get("cloud.id")
cloud_auth = logstash_settings.get("cloud.auth")
if cloud_id.nil?
if cloud_auth.nil?
return # user did not specify cloud settings
else
raise ArgumentError.new("Cloud Auth without Cloud Id")
end
end
if logger.debug?
settings_copy = LogStash::Util.deep_clone(module_settings)
end
module_settings["var.kibana.scheme"] = "https"
module_settings["var.kibana.host"] = cloud_id.kibana_host
module_settings["var.elasticsearch.hosts"] = cloud_id.elasticsearch_host
unless cloud_auth.nil?
module_settings["var.elasticsearch.username"] = cloud_auth.username
module_settings["var.elasticsearch.password"] = cloud_auth.password
module_settings["var.kibana.username"] = cloud_auth.username
module_settings["var.kibana.password"] = cloud_auth.password
end
if logger.debug?
format_module_settings(settings_copy, module_settings).each {|line| logger.debug(line)}
end
end
def format_module_settings(settings_before, settings_after)
output = []
output << "-------- Module Settings ---------"
settings_after.each do |setting_name, setting|
setting_before = settings_before.fetch(setting_name, "")
line = "#{setting_name}: '#{setting}'"
if setting_before != setting
line.concat(", was: '#{setting_before}'")
end
output << line
end
output << "-------- Module Settings ---------"
output
end
end end end

View file

@ -79,6 +79,14 @@ class LogStash::Runner < Clamp::StrictCommand
:default => LogStash::SETTINGS.get_default("modules_setup"),
:attribute_name => "modules_setup"
option ["--cloud.id"], "CLOUD_ID",
I18n.t("logstash.runner.flag.cloud_id"),
:attribute_name => "cloud.id"
option ["--cloud.auth"], "CLOUD_AUTH",
I18n.t("logstash.runner.flag.cloud_auth"),
:attribute_name => "cloud.auth"
# Pipeline settings
option ["-w", "--pipeline.workers"], "COUNT",
I18n.t("logstash.runner.flag.pipeline-workers"),

View file

@ -255,6 +255,7 @@ module LogStash
@default = default
end
end
def set(value)
coerced_value = coerce(value)
validate(coerced_value)
@ -557,7 +558,32 @@ module LogStash
end
end
end
class Modules < Coercible
def initialize(name, klass, default = nil)
super(name, klass, default, false)
end
def set(value)
@value = coerce(value)
@value_is_set = true
@value
end
def coerce(value)
if value.is_a?(@klass)
return value
end
@klass.new(value)
end
protected
def validate(value)
coerce(value)
end
end
end
SETTINGS = Settings.new
end

View file

@ -0,0 +1,29 @@
# encoding: utf-8
require "logstash/namespace"
require "logstash/util/password"
module LogStash module Util class CloudSettingAuth
attr_reader :original, :username, :password
def initialize(value)
return if value.nil?
unless value.is_a?(String)
raise ArgumentError.new("Cloud Auth must be String. Received: #{value.class}")
end
@original = value
@username, sep, password = @original.partition(":")
if @username.empty? || sep.empty? || password.empty?
raise ArgumentError.new("Cloud Auth username and password format should be \"<username>:<password>\".")
end
@password = LogStash::Util::Password.new(password)
end
def to_s
"#{@username}:#{@password}"
end
def inspect
to_s
end
end end end

View file

@ -0,0 +1,41 @@
# encoding: utf-8
require "logstash/namespace"
require "base64"
module LogStash module Util class CloudSettingId
attr_reader :original, :decoded, :label, :elasticsearch_host, :kibana_host
def initialize(value)
return if value.nil?
unless value.is_a?(String)
raise ArgumentError.new("Cloud Id must be String. Received: #{value.class}")
end
@original = value
@label, sep, last = value.partition(":")
if last.empty?
@decoded = Base64.urlsafe_decode64(@label) rescue ""
@label = ""
else
@decoded = Base64.urlsafe_decode64(last) rescue ""
end
unless @decoded.count("$") == 2
raise ArgumentError.new("Cloud Id does not decode. Received: \"#{@original}\".")
end
parts = @decoded.split("$")
if parts.any?(&:empty?)
raise ArgumentError.new("Cloud Id, after decoding, is invalid. Format: '<part1>$<part2>$<part3>'. Received: \"#{@decoded}\".")
end
cloud_host, es_server, kb_server = parts
@elasticsearch_host = sprintf("%s.%s:443", es_server, cloud_host)
@kibana_host = sprintf("%s.%s:443", kb_server, cloud_host)
end
def to_s
@original.to_s
end
def inspect
to_s
end
end end end

View file

@ -0,0 +1,28 @@
# encoding: utf-8
require "logstash/namespace"
require "logstash/util/password"
module LogStash module Util class ModulesSettingArray
extend Forwardable
DELEGATED_METHODS = [].public_methods.reject{|symbol| symbol.to_s.end_with?('__')}
def_delegators :@original, *DELEGATED_METHODS
attr_reader :original
def initialize(value)
unless value.is_a?(Array)
raise ArgumentError.new("Module Settings must be an Array. Received: #{value.class}")
end
@original = value
# wrap passwords
@original.each do |hash|
hash.keys.select{|key| key.to_s.end_with?('password')}.each do |key|
hash[key] = LogStash::Util::Password.new(hash[key])
end
end
end
def __class__
LogStash::Util::ModulesSettingArray
end
end end end

View file

@ -246,6 +246,18 @@ en:
Load index template into Elasticsearch, and saved searches,
index-pattern, visualizations, and dashboards into Kibana when
running modules.
cloud_id: |+
Sets the elasticsearch and kibana host settings for
module connections in Elastic Cloud.
Your Elastic Cloud User interface or the Cloud support
team should provide this.
Add an optional label prefix '<label>:' to help you
identify multiple cloud.ids.
e.g. 'staging:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy'
cloud_auth: |+
Sets the elasticsearch and kibana username and password
for module connections in Elastic Cloud
e.g. 'username:<password>'
configtest: |+
Check configuration for valid syntax and then exit.
http_host: Web API binding host

View file

@ -0,0 +1,111 @@
# encoding: utf-8
require "spec_helper"
require "logstash/util/cloud_setting_id"
require "logstash/util/cloud_setting_auth"
require "logstash/modules/settings_merger"
require "logstash/util/password"
class SubstituteSettingsForRSpec
def initialize(hash = {}) @hash = hash; end
def put(key, value) @hash[key] = value; end
def get(key) @hash[key]; end
end
describe LogStash::Modules::SettingsMerger do
describe "#merge" do
let(:cli) {[{"name"=>"mod1", "var.input.tcp.port"=>"3333"}, {"name"=>"mod2"}]}
let(:yml) {[{"name"=>"mod1", "var.input.tcp.port"=>2222, "var.kibana.username"=>"rupert", "var.kibana.password"=>"fotherington"}, {"name"=>"mod3", "var.input.tcp.port"=>4445}]}
subject(:results) { described_class.merge(cli, yml) }
it "merges cli overwriting any common fields in yml" do
expect(results).to be_a(Array)
expect(results.size).to eq(3)
expect(results[0]["name"]).to eq("mod1")
expect(results[0]["var.input.tcp.port"]).to eq("3333")
expect(results[0]["var.kibana.username"]).to eq("rupert")
expect(results[1]["name"]).to eq("mod2")
expect(results[2]["name"]).to eq("mod3")
expect(results[2]["var.input.tcp.port"]).to eq(4445)
end
end
describe "#merge_cloud_settings" do
let(:cloud_id) { LogStash::Util::CloudSettingId.new("label:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy") }
let(:cloud_auth) { LogStash::Util::CloudSettingAuth.new("elastix:bigwhoppingfairytail") }
let(:mod_settings) { {} }
context "when both are supplied" do
let(:expected_table) do
{
"var.kibana.scheme" => "https",
"var.kibana.host" => "identifier.us-east-1.aws.found.io:443",
"var.elasticsearch.hosts" => "notareal.us-east-1.aws.found.io:443",
"var.elasticsearch.username" => "elastix",
"var.kibana.username" => "elastix"
}
end
let(:ls_settings) { SubstituteSettingsForRSpec.new({"cloud.id" => cloud_id, "cloud.auth" => cloud_auth}) }
before do
described_class.merge_cloud_settings(mod_settings, ls_settings)
end
it "adds entries to module settings" do
expected_table.each do |key, expected|
expect(mod_settings[key]).to eq(expected)
end
expect(mod_settings["var.elasticsearch.password"].value).to eq("bigwhoppingfairytail")
expect(mod_settings["var.kibana.password"].value).to eq("bigwhoppingfairytail")
end
end
context "when cloud.id is supplied" do
let(:expected_table) do
{
"var.kibana.scheme" => "https",
"var.kibana.host" => "identifier.us-east-1.aws.found.io:443",
"var.elasticsearch.hosts" => "notareal.us-east-1.aws.found.io:443",
}
end
let(:ls_settings) { SubstituteSettingsForRSpec.new({"cloud.id" => cloud_id}) }
before do
described_class.merge_cloud_settings(mod_settings, ls_settings)
end
it "adds entries to module settings" do
expected_table.each do |key, expected|
expect(mod_settings[key]).to eq(expected)
end
end
end
context "when only cloud.auth is supplied" do
let(:ls_settings) { SubstituteSettingsForRSpec.new({"cloud.auth" => cloud_auth}) }
it "should raise an error" do
expect{ described_class.merge_cloud_settings(mod_settings, ls_settings) }.to raise_exception(ArgumentError)
end
end
context "when neither cloud.id nor cloud.auth is supplied" do
let(:ls_settings) { SubstituteSettingsForRSpec.new() }
it "should do nothing" do
expect(mod_settings).to be_empty
end
end
end
describe "#format_module_settings" do
let(:before_hash) { {"foo" => "red", "bar" => "blue", "qux" => "pink"} }
let(:after_hash) { {"foo" => "red", "bar" => "steel-blue", "baz" => LogStash::Util::Password.new("cyan"), "qux" => nil} }
subject(:results) { described_class.format_module_settings(before_hash, after_hash) }
it "yields an array of formatted lines for ease of logging" do
expect(results.size).to eq(after_hash.size + 2)
expect(results.first).to eq("-------- Module Settings ---------")
expect(results.last).to eq("-------- Module Settings ---------")
expect(results[1]).to eq("foo: 'red'")
expect(results[2]).to eq("bar: 'steel-blue', was: 'blue'")
expect(results[3]).to eq("baz: '<password>', was: ''")
expect(results[4]).to eq("qux: '', was: 'pink'")
end
end
end

View file

@ -0,0 +1,115 @@
# encoding: utf-8
require "spec_helper"
require "logstash/settings"
require "logstash/util/cloud_setting_id"
require "logstash/util/cloud_setting_auth"
require "logstash/util/modules_setting_array"
describe LogStash::Setting::Modules do
describe "Modules.Cli" do
subject { described_class.new("mycloudid", LogStash::Util::ModulesSettingArray, []) }
context "when given an array of hashes that contains a password key" do
it "should convert password Strings to Password" do
source = [{"var.kibana.password" => "some_secret"}]
setting = subject.set(source)
expect(setting).to be_a(Array)
expect(setting.__class__).to eq(LogStash::Util::ModulesSettingArray)
expect(setting.first.fetch("var.kibana.password")).to be_a(LogStash::Util::Password)
end
end
end
describe "Cloud.Id" do
subject { described_class.new("mycloudid", LogStash::Util::CloudSettingId) }
context "when given a string which is not a cloud id" do
it "should raise an exception" do
expect { subject.set("foobarbaz") }.to raise_error(ArgumentError, /Cloud Id does not decode/)
end
end
context "when given a string which is empty" do
it "should raise an exception" do
expect { subject.set("") }.to raise_error(ArgumentError, /Cloud Id does not decode/)
end
end
context "when given a string which is has environment prefix only" do
it "should raise an exception" do
expect { subject.set("testing:") }.to raise_error(ArgumentError, /Cloud Id does not decode/)
end
end
context "when given a badly formatted encoded id" do
it "should not raise an error" do
encoded = Base64.urlsafe_encode64("foo$$bal")
expect { subject.set(encoded) }.to raise_error(ArgumentError, /Cloud Id, after decoding, is invalid. Format: '<part1>\$<part2>\$<part3>'/)
end
end
context "when given a nil" do
it "should not raise an error" do
expect { subject.set(nil) }.to_not raise_error
end
end
context "when given a string which is an unlabelled cloud id" do
it "should set a LogStash::Util::CloudId instance" do
expect { subject.set("dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy") }.to_not raise_error
expect(subject.value.elasticsearch_host).to eq("notareal.us-east-1.aws.found.io:443")
expect(subject.value.kibana_host).to eq("identifier.us-east-1.aws.found.io:443")
expect(subject.value.label).to eq("")
end
end
context "when given a string which is a labelled cloud id" do
it "should set a LogStash::Util::CloudId instance" do
expect { subject.set("staging:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRub3RhcmVhbCRpZGVudGlmaWVy") }.to_not raise_error
expect(subject.value.elasticsearch_host).to eq("notareal.us-east-1.aws.found.io:443")
expect(subject.value.kibana_host).to eq("identifier.us-east-1.aws.found.io:443")
expect(subject.value.label).to eq("staging")
end
end
end
describe "Cloud.Auth" do
subject { described_class.new("mycloudauth", LogStash::Util::CloudSettingAuth) }
context "when given a string without a separator or a password" do
it "should raise an exception" do
expect { subject.set("foobarbaz") }.to raise_error(ArgumentError, /Cloud Auth username and password format should be/)
end
end
context "when given a string without a password" do
it "should raise an exception" do
expect { subject.set("foo:") }.to raise_error(ArgumentError, /Cloud Auth username and password format should be/)
end
end
context "when given a string without a username" do
it "should raise an exception" do
expect { subject.set(":bar") }.to raise_error(ArgumentError, /Cloud Auth username and password format should be/)
end
end
context "when given a string which is empty" do
it "should raise an exception" do
expect { subject.set("") }.to raise_error(ArgumentError, /Cloud Auth username and password format should be/)
end
end
context "when given a nil" do
it "should not raise an error" do
expect { subject.set(nil) }.to_not raise_error
end
end
context "when given a string which is a cloud auth" do
it "should set the string" do
expect { subject.set("frodo:baggins") }.to_not raise_error
expect(subject.value.username).to eq("frodo")
expect(subject.value.password.value).to eq("baggins")
expect(subject.value.to_s).to eq("frodo:<password>")
end
end
end
end

View file

@ -23,7 +23,7 @@ namespace "modules" do
full_path = ::File.join(partial_path, filename)
FileUtils.rm_f(full_path)
content = JSON.pretty_generate(source)
content = JSON.pretty_generate(source) + "\n"
puts "Writing #{full_path}"
IO.write(full_path, content)
end
@ -42,7 +42,7 @@ namespace "modules" do
full_path = ::File.join(dashboard_dir, "#{module_name}.json")
FileUtils.rm_f(full_path)
content = JSON.pretty_generate(filenames)
content = JSON.pretty_generate(filenames) + "\n"
puts "Writing #{full_path}"
IO.write(full_path, content)
end