smart_proxy_host_reports 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Smart Proxy Host Reports
2
+
3
+ Transforms configuration and security management reports into Foreman-friendly
4
+ JSON and sends them to a Foreman instance. For more information about Foreman
5
+ JSON report format, visit
6
+ [foreman_host_reports](https://github.com/theforeman/foreman_host_reports).
7
+
8
+ ## Usage
9
+
10
+ Send a POST HTTP call to `/host_reports/FORMAT` where FORMAT is one of the following formats.
11
+
12
+ ### Puppet
13
+
14
+ Accepts Puppet Server YAML format:
15
+
16
+ * [Example input](test/fixtures/puppet6-foreman-web.yaml)
17
+ * [Example output](test/snapshots/foreman-web.json)
18
+
19
+ ## Development setup
20
+
21
+ Few words about setting up a dev setup.
22
+
23
+ ### Ansible
24
+
25
+ Checkoud foreman-ansible-modules and build it via `make` command. Configure
26
+ Ansible collection path to the build directory:
27
+
28
+ [defaults]
29
+ collection_path = /home/lzap/work/foreman-ansible-modules/build
30
+ callback_whitelist = foreman
31
+ [callback_foreman]
32
+ url = http://localhost:8448/host_reports
33
+ verify_certs = 0
34
+ client_cert = /home/lzap/DummyX509/client-one.crt
35
+ client_key = /home/lzap/DummyX509/client-one.key
36
+
37
+ Configure Foreman Ansible callback with the correct Foreman URL:
38
+
39
+ Then call Ansible:
40
+
41
+ ANSIBLE_LOAD_CALLBACK_PLUGINS=1 ansible localhost -m ping -vvv
42
+
43
+ ## Example data
44
+
45
+ For testing, there are several example data. Before importing them into Foreman, make sure to have `localhost` smart proxy and also a host named `report.example.com`. It is possible to capture example data via `incoming_save_dir` setting. Name generated files correctly and put them into the `contrib/fixtures` directory. There is a utility to use fixtures for development and testing purposes:
46
+
47
+ ```
48
+ $ contrib/upload-fixture
49
+ Usage:
50
+ contrib/upload-fixture -h Display this help message
51
+ contrib/upload-fixture -u URL Proxy URL (http://localhost:8448)
52
+ contrib/upload-fixture -f FILE Fixture to upload
53
+ contrib/upload-fixture -a Upload all fixtures
54
+
55
+ $ contrib/upload-fixture -a
56
+ contrib/fixtures/ansible-copy-nochange.json: 200
57
+ contrib/fixtures/ansible-copy-success.json: 200
58
+ contrib/fixtures/ansible-package-install-failure.json: 200
59
+ contrib/fixtures/ansible-package-install-nochange.json: 200
60
+ contrib/fixtures/ansible-package-install-success.json: 200
61
+ contrib/fixtures/ansible-package-remove-failure.json: 200
62
+ contrib/fixtures/ansible-package-remove-success.json: 200
63
+ ```
64
+
65
+ ### Importing into Foreman directly
66
+
67
+ To import a report directly info Foreman:
68
+
69
+ ```
70
+ curl -H "Accept:application/json,version=2" -H "Content-Type:application/json" -X POST -d @test/snapshots/foreman-web.json http://localhost:5000/api/v2/host_reports
71
+ ```
72
+
73
+ ### Puppet
74
+
75
+ To install and configure a Puppetserver on EL7, run the following:
76
+
77
+ ```bash
78
+ # Install the server - modify as needed for your platform
79
+ yum -y install https://yum.puppet.com/puppet7-release-el-7.noarch.rpm
80
+ yum -y install puppetserver
81
+ # Correct $PATH in the current shell - happens on start of fresh shells automatically
82
+ source /etc/profile.d/puppet-agent.sh
83
+ # Configure the HTTP report processor
84
+ puppet config set reports store,http
85
+ puppet config set reporturl http://$HOSTNAME:8000/host_reports/puppet
86
+ # Enable & start the service
87
+ systemctl enable --now puppetserver
88
+ ```
89
+
90
+ If you prefer to use HTTPS, set the different reporturl and configure the CA certificates according to the example below
91
+ ```
92
+ # use HTTPS, without Katello the port is 8443, with Katello it's 9090
93
+ puppet config set reporturl https://$HOSTNAME:8443/host_reports/puppet
94
+ # install the Smart Proxy CA certificate to the Puppet's localcacert store
95
+ ## first find the correct pem file
96
+ grep :ssl_ca_file /etc/foreman-proxy/settings.yml
97
+ ## find the localcacert puppet storage
98
+ puppet config print --section server localcacert
99
+ ## then copy the content of both pem files to each other
100
+ cp /etc/foreman-proxy/ssl_ca.pem /tmp/smart-proxy.pem
101
+ cp /etc/puppetlabs/puppet/ssl/certs/ca.pem /tmp/puppet-ca.pem
102
+ cat /tmp/smart-proxy.pem >> /etc/puppetlabs/puppet/ssl/certs/ca.pem
103
+ cat /tmp/puppet-ca.pem >> /etc/foreman-proxy/ssl_ca.pem
104
+ # restart the services
105
+ systemctl restart puppetserver
106
+ systemctl restart foreman-proxy
107
+ ```
108
+ Note that this means that the Puppetserver API will trust client certificates signed by the Smart Proxy CA
109
+ certificate and will be subject to authorization defined in puppet's auth.conf, e.g. a client with the certificate
110
+ of the same cname can get a catalog for such node. That is typically not a bad thing but you need to consider the
111
+ implications in your SSL certificates layout. Similarly the proxy will now trust certificates signed by the
112
+ Puppet CA, however they are still subject to smart proxy trusted hosts authorization.
113
+
114
+ By default an agent connects to `puppet` which may not resolve. Set it to your hostname:
115
+
116
+ ```bash
117
+ puppet config set server $HOSTNAME
118
+ ```
119
+
120
+ You can manually trigger a puppet run by using `puppet agent -t`. You may need to look at `/var/log/puppetlabs/puppetserver/puppetserver.log` to see errors.
121
+
122
+ ## Contributing
123
+
124
+ Fork and send a Pull Request. Thanks!
125
+
126
+ ## License
127
+
128
+ GNU GPLv3, see LICENSE file for more information.
129
+
130
+ ## Copyright
131
+
132
+ Copyright (c) 2021 Red Hat, Inc.
133
+
134
+ This program is free software: you can redistribute it and/or modify
135
+ it under the terms of the GNU General Public License as published by
136
+ the Free Software Foundation, either version 3 of the License, or
137
+ (at your option) any later version.
138
+
139
+ This program is distributed in the hope that it will be useful,
140
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
141
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
142
+ GNU General Public License for more details.
143
+
144
+ You should have received a copy of the GNU General Public License
145
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1 @@
1
+ gem "smart_proxy_host_reports"
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AnsibleProcessor < Processor
4
+ KEYS_TO_COPY = %w[status check_mode].freeze
5
+
6
+ def initialize(data, json_body: true)
7
+ super(data, json_body: json_body)
8
+ measure :parse do
9
+ @data = JSON.parse(data)
10
+ end
11
+ @body = {}
12
+ logger.debug "Processing report #{report_id}"
13
+ debug_payload("Input", @data)
14
+ end
15
+
16
+ def report_id
17
+ @data["uuid"] || generated_report_id
18
+ end
19
+
20
+ def process_results
21
+ @data["results"]&.each do |result|
22
+ process_facts(result)
23
+ process_level(result)
24
+ process_message(result)
25
+ process_keywords(result)
26
+ end
27
+ @data["results"]
28
+ rescue StandardError => e
29
+ logger.error "Unable to parse results", e
30
+ @data["results"]
31
+ end
32
+
33
+ def process
34
+ measure :process do
35
+ @body["format"] = "ansible"
36
+ @body["id"] = report_id
37
+ @body["host"] = hostname_from_config || @data["host"]
38
+ @body["proxy"] = Proxy::HostReports::Plugin.settings.reported_proxy_hostname
39
+ @body["reported_at"] = @data["reported_at"]
40
+ @body["results"] = process_results
41
+ @body["keywords"] = keywords
42
+ @body["telemetry"] = telemetry
43
+ @body["errors"] = errors if errors?
44
+ KEYS_TO_COPY.each do |key|
45
+ @body[key] = @data[key]
46
+ end
47
+ end
48
+ end
49
+
50
+ def build_report
51
+ process
52
+ if debug_payload?
53
+ logger.debug { JSON.pretty_generate(@body) }
54
+ end
55
+ build_report_root(
56
+ format: "ansible",
57
+ version: 1,
58
+ host: @body["host"],
59
+ reported_at: @body["reported_at"],
60
+ status: 0,
61
+ proxy: @body["proxy"],
62
+ body: @body,
63
+ keywords: @body["keywords"],
64
+ )
65
+ end
66
+
67
+ def spool_report
68
+ report_hash = build_report
69
+ debug_payload("Output", report_hash)
70
+ payload = measure :format do
71
+ report_hash.to_json
72
+ end
73
+ SpooledHttpClient.instance.spool(report_id, payload)
74
+ end
75
+
76
+ private
77
+
78
+ def process_facts(result)
79
+ # TODO: add fact processing and sending to the fact endpoint
80
+ result["result"]["ansible_facts"] = {}
81
+ end
82
+
83
+ def process_keywords(result)
84
+ if result["failed"]
85
+ add_keywords("HasFailure", "AnsibleTaskFailed:#{result["task"]["action"]}")
86
+ elsif result["result"]["changed"]
87
+ add_keywords("HasChange")
88
+ end
89
+ end
90
+
91
+ def process_level(result)
92
+ if result["failed"]
93
+ result["level"] = "err"
94
+ elsif result["result"]["changed"]
95
+ result["level"] = "notice"
96
+ else
97
+ result["level"] = "info"
98
+ end
99
+ end
100
+
101
+ def process_message(result)
102
+ msg = "N/A"
103
+ return result["friendly_message"] = msg if result["task"].nil? || result["task"]["action"].nil?
104
+ return result["friendly_message"] = result["result"]["msg"] if result["failed"]
105
+ result_tree = result["result"]
106
+ task_tree = result["task"]
107
+ raise("Report do not contain required 'results/result' element") unless result_tree
108
+ raise("Report do not contain required 'results/task' element") unless task_tree
109
+ module_args_tree = result_tree.dig("invocation", "module_args")
110
+
111
+ case task_tree["action"]
112
+ when "ansible.builtin.package", "package"
113
+ detail = result_tree["results"] || result_tree["msg"] || "No details"
114
+ msg = "Package(s) #{module_args_tree["name"].join(",")} are #{module_args_tree["state"]}: #{detail}"
115
+ when "ansible.builtin.template", "template"
116
+ msg = "Render template #{module_args_tree["_original_basename"]} to #{result_tree["dest"]}"
117
+ when "ansible.builtin.service", "service"
118
+ msg = "Service #{result_tree["name"]} is #{result_tree["state"]} and enabled: #{result_tree["enabled"]}"
119
+ when "ansible.builtin.group", "group"
120
+ msg = "User group #{result_tree["name"]} is #{result_tree["state"]} with gid: #{result_tree["gid"]}"
121
+ when "ansible.builtin.user", "user"
122
+ msg = "User #{result_tree["name"]} is #{result_tree["state"]} with uid: #{result_tree["uid"]}"
123
+ when "ansible.builtin.cron", "cron"
124
+ msg = "Cron job: #{module_args_tree["minute"]} #{module_args_tree["hour"]} #{module_args_tree["day"]} #{module_args_tree["month"]} #{module_args_tree["weekday"]} #{module_args_tree["job"]} and disabled: #{module_args_tree["disabled"]}"
125
+ when "ansible.builtin.copy", "copy"
126
+ msg = "Copy #{module_args_tree["_original_basename"]} to #{result_tree["dest"]}"
127
+ when "ansible.builtin.command", "ansible.builtin.shell", "command", "shell"
128
+ msg = result_tree["stdout_lines"]
129
+ end
130
+ rescue StandardError => e
131
+ logger.debug "Unable to parse result (#{e.message}): #{result.inspect}"
132
+ ensure
133
+ result["friendly_message"] = msg
134
+ end
135
+ end
@@ -0,0 +1,29 @@
1
+ module Proxy::HostReports
2
+ class PluginConfiguration
3
+ def load_classes
4
+ require "smart_proxy_host_reports/spooled_http_client"
5
+ end
6
+
7
+ def load_dependency_injection_wirings(container, _settings)
8
+ container.singleton_dependency :host_reports_spool, -> {
9
+ SpooledHttpClient.instance.initialize_directory
10
+ }
11
+ end
12
+ end
13
+
14
+ class Plugin < ::Proxy::Plugin
15
+ plugin :host_reports, Proxy::HostReports::VERSION
16
+
17
+ default_settings reported_proxy_hostname: "localhost",
18
+ debug_payload: false,
19
+ spool_dir: "/var/lib/foreman-proxy",
20
+ keep_reports: false
21
+
22
+ http_rackup_path File.expand_path("host_reports_http_config.ru", File.expand_path("../", __FILE__))
23
+ https_rackup_path File.expand_path("host_reports_http_config.ru", File.expand_path("../", __FILE__))
24
+
25
+ load_classes PluginConfiguration
26
+ load_dependency_injection_wirings PluginConfiguration
27
+ start_services :host_reports_spool
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ require "sinatra"
2
+ require "yaml"
3
+ require "smart_proxy_host_reports/host_reports"
4
+ require "smart_proxy_host_reports/processor"
5
+ require "smart_proxy_host_reports/puppet_processor"
6
+ require "smart_proxy_host_reports/ansible_processor"
7
+
8
+ module Proxy::HostReports
9
+ class Api < ::Sinatra::Base
10
+ include ::Proxy::Log
11
+ include ::Proxy::Util
12
+ helpers ::Proxy::Helpers
13
+
14
+ before do
15
+ content_type "application/json"
16
+ end
17
+
18
+ def check_content_type(format)
19
+ request_type = request.env["CONTENT_TYPE"]
20
+ if format == "puppet"
21
+ log_halt(415, "Content type must be application/x-yaml, was: #{request_type}") unless request_type.start_with?("application/x-yaml")
22
+ elsif format == "ansible"
23
+ log_halt(415, "Content type must be application/json, was: #{request_type}") unless request_type.start_with?("application/json")
24
+ else
25
+ log_halt(415, "Unknown format: #{format}")
26
+ end
27
+ end
28
+
29
+ EXTS = {
30
+ puppet: "yaml",
31
+ ansible: "json",
32
+ }.freeze
33
+
34
+ def save_payload(input, format)
35
+ filename = File.join(Proxy::HostReports::Plugin.settings.incoming_save_dir, "#{format}-#{Time.now.to_i}.#{EXTS[format.to_sym]}")
36
+ File.open(filename, "w") { |f| f.write(input) }
37
+ end
38
+
39
+ post "/:format" do
40
+ format = params[:format]
41
+ log_halt(404, "Format argument not specified") unless format
42
+ check_content_type(format)
43
+ input = request.body.read
44
+ save_payload(input, format) if Proxy::HostReports::Plugin.settings.incoming_save_dir
45
+ log_halt(415, "Missing body") if input.empty?
46
+ json_body = to_bool(params[:json_body], true)
47
+ processor = Processor.new_processor(format, input, json_body: json_body)
48
+ processor.spool_report
49
+ rescue => e
50
+ log_halt 415, e, "Error during report processing: #{e.message}"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ require "smart_proxy_host_reports/host_reports_api"
2
+
3
+ map "/host_reports" do
4
+ run Proxy::HostReports::Api
5
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+ require "pp"
3
+ require "proxy/log"
4
+
5
+ # TODO: move everything into a module
6
+
7
+ class Processor
8
+ include ::Proxy::Log
9
+
10
+ def self.new_processor(format, data, json_body: true)
11
+ case format
12
+ when "puppet"
13
+ PuppetProcessor.new(data, json_body: json_body)
14
+ when "ansible"
15
+ AnsibleProcessor.new(data, json_body: json_body)
16
+ else
17
+ NotImplementedError.new
18
+ end
19
+ end
20
+
21
+ def initialize(*, json_body: true)
22
+ @keywords_set = {}
23
+ @errors = []
24
+ @json_body = json_body
25
+ end
26
+
27
+ def generated_report_id
28
+ @generated_report_id ||= SecureRandom.uuid
29
+ end
30
+
31
+ def hostname_from_config
32
+ @hostname_from_config ||= Proxy::HostReports::Plugin.settings.override_hostname
33
+ end
34
+
35
+ def build_report_root(format:, version:, host:, reported_at:, status:, proxy:, body:, keywords:)
36
+ {
37
+ "host_report" => {
38
+ "format" => format,
39
+ "version" => version,
40
+ "host" => host,
41
+ "reported_at" => reported_at,
42
+ "status" => status,
43
+ "proxy" => proxy,
44
+ "body" => @json_body ? body.to_json : body,
45
+ "keywords" => keywords,
46
+ },
47
+ }
48
+ # TODO add metric with total time
49
+ end
50
+
51
+ def debug_payload?
52
+ Proxy::HostReports::Plugin.settings.debug_payload
53
+ end
54
+
55
+ def debug_payload(prefix, data)
56
+ return unless debug_payload?
57
+ logger.debug { "#{prefix}: #{data.pretty_inspect}" }
58
+ end
59
+
60
+ def add_keywords(*keywords)
61
+ keywords.each do |keyword|
62
+ @keywords_set[keyword] = true
63
+ end
64
+ end
65
+
66
+ def keywords
67
+ @keywords_set.keys.to_a rescue []
68
+ end
69
+
70
+ attr_reader :errors
71
+
72
+ def log_error(message)
73
+ @errors << message.to_s
74
+ end
75
+
76
+ def errors?
77
+ @errors&.any?
78
+ end
79
+
80
+ # TODO support multiple metrics and adding total time
81
+ attr_reader :telemetry
82
+
83
+ def measure(metric)
84
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
+ yield
86
+ ensure
87
+ t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ @telemetry ||= {}
89
+ @telemetry[metric] = (t2 - t1) * 1000
90
+ end
91
+
92
+ def telemetry_as_string
93
+ result = []
94
+ telemetry.each do |key, value|
95
+ result << "#{key}=#{value.round(1)}ms"
96
+ end
97
+ result.join(", ")
98
+ end
99
+
100
+ def spool_report
101
+ super
102
+ logger.debug "Spooled #{report_id}: #{telemetry_as_string}"
103
+ end
104
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PuppetProcessor < Processor
4
+ YAML_CLEAN = /!ruby\/object.*$/.freeze
5
+ KEYS_TO_COPY = %w[report_format puppet_version environment metrics].freeze
6
+ MAX_EVAL_TIMES = 29
7
+
8
+ def initialize(data, json_body: true)
9
+ super(data, json_body: json_body)
10
+ measure :parse do
11
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6.0")
12
+ # Ruby 2.5 or older does not have permitted_classes argument available
13
+ @data = YAML.load(data.gsub(YAML_CLEAN, ""))
14
+ else
15
+ @data = YAML.safe_load(data.gsub(YAML_CLEAN, ""), permitted_classes: [Symbol, Time, Date])
16
+ end
17
+ end
18
+ raise("No content") unless @data
19
+ @body = {}
20
+ @evaluation_times = []
21
+ logger.debug "Processing report #{report_id}"
22
+ debug_payload("Input", @data)
23
+ end
24
+
25
+ def report_id
26
+ @data["transaction_uuid"] || generated_report_id
27
+ end
28
+
29
+ def process_logs
30
+ logs = []
31
+ @data["logs"]&.each do |log|
32
+ logs << [log["level"]&.to_s, log["source"], log["message"]]
33
+ end
34
+ logs
35
+ rescue StandardError => e
36
+ logger.error "Unable to parse logs", e
37
+ logs
38
+ end
39
+
40
+ def process_resource_statuses
41
+ statuses = []
42
+ @data["resource_statuses"]&.each_pair do |key, value|
43
+ statuses << key
44
+ @evaluation_times << [key, value["evaluation_time"]]
45
+ # failures
46
+ add_keywords("PuppetResourceFailed:#{key}", "PuppetHasFailure") if value["failed"] || value["failed_to_restart"]
47
+ value["events"]&.each do |event|
48
+ add_keywords("PuppetResourceFailed:#{key}", "PuppetHasFailure") if event["status"] == "failed"
49
+ add_keywords("PuppetHasCorrectiveChange") if event["corrective_change"]
50
+ end
51
+ # changes
52
+ add_keywords("PuppetHasChange") if value["changed"]
53
+ add_keywords("PuppetHasChange") if value["change_count"] && value["change_count"] > 0
54
+ # changes
55
+ add_keywords("PuppetIsOutOfSync") if value["out_of_sync"]
56
+ add_keywords("PuppetIsOutOfSync") if value["out_of_sync_count"] && value["out_of_sync_count"] > 0
57
+ # skips
58
+ add_keywords("PuppetHasSkips") if value["skipped"]
59
+ # corrective changes
60
+ add_keywords("PuppetHasCorrectiveChange") if value["corrective_change"]
61
+ end
62
+ statuses
63
+ rescue StandardError => e
64
+ logger.error "Unable to parse resource_statuses", e
65
+ statuses
66
+ end
67
+
68
+ def process_evaluation_times
69
+ @evaluation_times.sort! { |a, b| b[1] <=> a[1] }
70
+ if @evaluation_times.count > MAX_EVAL_TIMES
71
+ others = @evaluation_times[MAX_EVAL_TIMES..@evaluation_times.count - 1].sum { |x| x[1] }
72
+ @evaluation_times = @evaluation_times[0..MAX_EVAL_TIMES - 1]
73
+ @evaluation_times << ["Others", others] if others > 0.0001
74
+ end
75
+ @evaluation_times
76
+ rescue StandardError => e
77
+ logger.error "Unable to process evaluation_times", e
78
+ []
79
+ end
80
+
81
+ def process
82
+ measure :process do
83
+ @body["format"] = "puppet"
84
+ @body["id"] = report_id
85
+ @body["host"] = hostname_from_config || @data["host"]
86
+ @body["proxy"] = Proxy::HostReports::Plugin.settings.reported_proxy_hostname
87
+ @body["reported_at"] = @data["time"]
88
+ KEYS_TO_COPY.each do |key|
89
+ @body[key] = @data[key]
90
+ end
91
+ @body["logs"] = process_logs
92
+ @body["resource_statuses"] = process_resource_statuses
93
+ @body["keywords"] = keywords
94
+ @body["evaluation_times"] = process_evaluation_times
95
+ @body["telemetry"] = telemetry
96
+ @body["errors"] = errors if errors?
97
+ end
98
+ end
99
+
100
+ def build_report
101
+ process
102
+ if debug_payload?
103
+ logger.debug { JSON.pretty_generate(@body) }
104
+ end
105
+ build_report_root(
106
+ format: "puppet",
107
+ version: 1,
108
+ host: @body["host"],
109
+ reported_at: @body["reported_at"],
110
+ status: 0,
111
+ proxy: @body["proxy"],
112
+ body: @body,
113
+ keywords: @body["keywords"],
114
+ )
115
+ end
116
+
117
+ def spool_report
118
+ report_hash = build_report
119
+ debug_payload("Output", report_hash)
120
+ payload = measure :format do
121
+ report_hash.to_json
122
+ end
123
+ SpooledHttpClient.instance.spool(report_id, payload)
124
+ end
125
+ end