diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..e1d793e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,110 @@ +PATH + remote: . + specs: + xmon (0.1.0) + activesupport (~> 7.0.8) + colorize + dnsruby + httparty + ruby-nmap + slop + whois-parser + +GEM + remote: https://rubygems.org/ + specs: + activesupport (7.0.8.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + ast (2.4.2) + coderay (1.1.3) + colorize (1.1.0) + command_mapper (0.3.2) + concurrent-ruby (1.2.3) + dnsruby (1.70.0) + simpleidn (~> 0.2.1) + httparty (0.21.0) + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.4) + concurrent-ruby (~> 1.0) + json (2.7.1) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + method_source (1.0.0) + mini_mime (1.1.5) + minitest (5.22.2) + multi_xml (0.6.0) + nokogiri (1.16.2-arm64-darwin) + racc (~> 1.4) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + racc (1.7.3) + rainbow (3.1.1) + rake (13.1.0) + regexp_parser (2.9.0) + rexml (3.2.6) + rubocop (1.62.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-nmap (1.0.3) + command_mapper (~> 0.3) + nokogiri (~> 1.3) + ruby-progressbar (1.13.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + slop (4.10.1) + standard (1.34.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.60) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.3.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.20.2) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.9.1) + unicode-display_width (2.5.0) + whois (5.1.1) + whois-parser (2.0.0) + activesupport (>= 4) + whois (>= 4.1.0) + +PLATFORMS + arm64-darwin-22 + +DEPENDENCIES + pry + rake (~> 13.0) + standard (~> 1.3) + xmon! + +BUNDLED WITH + 2.4.22 diff --git a/README.md b/README.md index 5fd90e2..a8178f8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Xmon -TODO: Delete this and the text below, and describe your gem +Yet another network monitoring tool -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/xmon`. To experiment with that code, run `bin/console` for an interactive prompt. +Use DSL to describe your network and services, run periodic checks and get notified when something changes. ## Installation @@ -18,7 +18,13 @@ If bundler is not being used to manage dependencies, install the gem by executin ## Usage -TODO: Write usage instructions here +Create definition file like + +Run monitor + +``` + bin/xmon -d examples/nic.rb +``` ## Development diff --git a/bin/xmon b/bin/xmon new file mode 100755 index 0000000..009053b --- /dev/null +++ b/bin/xmon @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "xmon" +require "slop" +require "irb" + +begin +opts = Slop.parse do |o| + o.banner = "Usage: xmon [options]" + o.array '-d', '--definition', 'Definition file to load', required: true + o.on '-v', '--version' do + puts "1.1.1" + exit + end + + o.on '-h', '--help' do + puts o + exit + end +end +rescue Slop::Error => e + puts e + exit +end + +opts[:definition].each do |file| + Xmon.load(file).check +end diff --git a/examples/nic.rb b/examples/nic.rb new file mode 100644 index 0000000..928c0a6 --- /dev/null +++ b/examples/nic.rb @@ -0,0 +1,27 @@ +describe "nic.cz", type: :domain do + describe :rdap do + status :server_transfer_prohibited + registrar "REG-CZNIC" + registrant "CZ-NIC" + expires "2027-03-14" + end + + describe :dns do + dnssec :valid + nameservers ["a.ns.nic.cz", "b.ns.nic.cz", "d.ns.nic.cz"] + record "www", :a, "217.31.205.50" + record "www", :aaaa, "2001:1488:0:3::2" + end +end + +describe "217.31.205.50", type: :ipv4 do + port 80, type: :tcp do + status :open + end + port 443, type: :tcp, protocol: :https do + host "www.nic.cz" + server "nginx" + status_code 200 + cert_sn "04E732C227971E1E92114CE4E26657CD6258" + end +end diff --git a/lib/xmon.rb b/lib/xmon.rb index 79a7b7f..e1d0bbc 100644 --- a/lib/xmon.rb +++ b/lib/xmon.rb @@ -1,8 +1,46 @@ # frozen_string_literal: true +require "pry" +require "colorize" +# require 'net/protocol' + require_relative "xmon/version" +require_relative "xmon/descriptions" + +require_relative "xmon/tcp" +require_relative "xmon/ssl" +require_relative "xmon/dns" +require_relative "xmon/rdap" module Xmon class Error < StandardError; end - # Your code goes here... + + def self.compare(a, b) + if a == b + [:ok, a] + else + [:fail, a, b] + end + end + + def self.print_results(results) + # puts "Results: #{results.inspect}".colorize(:blue) + results.each do |res| + res.each do |r| + if r.is_a?(Array) + if r[0] == :ok + puts "OK: #{r[1]}".colorize(:green) + else + puts "FAIL: #{r[1]} != #{r[2]}".colorize(:red) + end + end + end + end + end + + def self.load(file) + d = Description.new + d.instance_eval(File.read(file)) + d + end end diff --git a/lib/xmon/descriptions.rb b/lib/xmon/descriptions.rb new file mode 100644 index 0000000..1157d80 --- /dev/null +++ b/lib/xmon/descriptions.rb @@ -0,0 +1,94 @@ +module Xmon + class Description + def initialize + @descriptions = [] + end + + def define_attributes(attributes) + attributes.each do |m| + self.class.send(:define_method, m) do |*args| + if args[0] + instance_variable_set(:"@#{m}", args[0]) + else + instance_variable_get(:"@#{m}") + end + end + end + end + + def describe(*args, **kwargs, &) + unless @description + if kwargs[:type] == :domain + @description = DomainDescription.new(args[0]) + elsif kwargs[:type] == :ipv4 + @description = IPv4Description.new(args[0]) + else + puts "unknown block given with args: #{args} and kwargs: #{kwargs}" + @description = Description.new + end + end + + if block_given? + @description.instance_eval(&) + else + @description = @description.class.new(*args, **kwargs) + end + @descriptions ||= [] + @descriptions << @description + @description = nil + end + + def status(status) # standard:disable Style/TrivialAccessors + @status = status + end + + def check + @results = [] + (@descriptions || []).each { |d| + puts "#{d.class} #{@name}".colorize(:yellow) + res = d.check + @results << res + + Xmon.print_results([res]) + } + end + end + + class DomainDescription < Description + def initialize(name, *) + @name = name + end + + def describe(*args, **kwarg) + if args == [:rdap] + @description = Xmon::RDAP.new(@name) + elsif args == [:dns] + @description = Xmon::DNS.new(@name) + end + super + end + end + + class IPv4Description < Description + def initialize(address, *) + @address = address + end + + def describe(*, **kwargs) + if kwargs[:type] == :tcp + @description = if kwargs[:protocol] == :https + Xmon::SSL.new(@address, *, **kwargs) + else + Xmon::TCP.new(@address, *, **kwargs) + end + elsif kwargs[:type] == :udp + @description = Xmon::UDP.new(*, **kwargs) + end + super + end + + def port(...) + describe(...) + end + end +end diff --git a/lib/xmon/dns.rb b/lib/xmon/dns.rb new file mode 100644 index 0000000..085c6af --- /dev/null +++ b/lib/xmon/dns.rb @@ -0,0 +1,28 @@ +require "dnsruby" + +module Xmon + class DNS < Description + def initialize(domain) + @domain = domain + define_attributes([:nameservers, :records, :dnssec]) + end + + def record(name, type, value) + @records ||= [] + @records << {name: name, type: type, value: value} + end + + def fetch(record, type = "A") + Dnsruby::Resolver.new.query(record, type).answer.map { |a| a.respond_to?(:address) ? a.address : a.rdata }.flatten.map(&:to_s).sort + end + + def check + r = [Xmon.compare(@nameservers, fetch(@domain, "NS"))] + + @records.each do |record| + r << Xmon.compare(record[:value], fetch(record[:name] + "." + @domain, record[:type].to_s.upcase).sort.join(",")) + end + r + end + end +end diff --git a/lib/xmon/rdap.rb b/lib/xmon/rdap.rb new file mode 100644 index 0000000..b0fa2b1 --- /dev/null +++ b/lib/xmon/rdap.rb @@ -0,0 +1,30 @@ +require "httparty" + +module Xmon + class RDAP < Description + def initialize(domain, **opts) + @domain = domain + define_attributes([:registrant, :registrar, :expires]) + end + + def fetch(record) + response = HTTParty.get("https://rdap.nic.cz/domain/#{record}") + response = JSON.parse(response.body, symbolize_names: true) + { + registrant: response[:entities].detect { |a| a[:roles] == ["registrant"] }[:handle], + registrar: response[:entities].detect { |a| a[:roles] == ["registrar"] }[:handle], + nameservers: response[:nameservers].map { |a| a[:ldhName] }.sort, + expiration: response[:events].detect { |a| a[:eventAction] == "expiration" }[:eventDate], + status: response[:status].map { |s| s.split(" ").join("_") }.sort.join("_").to_sym + } + end + + def check + checker = fetch(@domain) + [Xmon.compare(@status, checker[:status]), + Xmon.compare(@registrant, checker[:registrant]), + Xmon.compare(@registrar, checker[:registrar]), + Xmon.compare(@expires, checker[:expiration][0, 10])] + end + end +end diff --git a/lib/xmon/ssl.rb b/lib/xmon/ssl.rb new file mode 100644 index 0000000..1b5592a --- /dev/null +++ b/lib/xmon/ssl.rb @@ -0,0 +1,48 @@ +module Xmon + class SSL < TCP + def initialize(address, *args, **kwargs) + @host = kwargs[:host] + @path = kwargs[:path] || "/" + define_attributes([:host, :status_code, :server, :cert_sn, :location]) + super + end + + def fetch(host, name = nil, port = 443, path = "/") + ctx = OpenSSL::SSL::SSLContext.new + sock = TCPSocket.new(host, port) + ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx) + ssl.hostname = name if name + ssl.sync_close = true + ssl.connect + cert = ssl.peer_cert + request = "GET / HTTP/1.1\r\nHost: #{name}\r\nConnection: close\r\n\r\n" + ssl.write request + header = ssl.gets("\r\n\r\n") + begin + body = ssl.read + rescue OpenSSL::SSL::SSLError + body = "" + end + + status, header = header.split("\r\n", 2) + _protocol, status_code, _status_text = status.split(" ", 3) + { + cert_sn: cert.serial.to_s(16), + status_code: status_code.to_i, + headers: header.split("\r\n").map { |a| a.split(": ") }.to_h, + body: body + } + end + + def check + puts "checking SSL for #{@address} #{@host} #{@port} #{@path}" + current = fetch(@address, @host, @port, @path) + r = [] + r << Xmon.compare(@status_code, current[:status_code]) if @status_code + r << Xmon.compare(@server, current.dig(:headers, "Server")) if @server + r << Xmon.compare(@cert_sn, current[:cert_sn]) if @cert_sn + r << Xmon.compare(@location, current.dig(:headers, "Location")) if @location + r + end + end +end diff --git a/lib/xmon/tcp.rb b/lib/xmon/tcp.rb new file mode 100644 index 0000000..8fb41ec --- /dev/null +++ b/lib/xmon/tcp.rb @@ -0,0 +1,49 @@ +require "nmap/command" +require "nmap/xml" + +module Xmon + class TCP < Description + def initialize(address, *args, **kwargs) + @address = address + @port = args[0] + @protocol = kwargs[:protocol] + end + + def key(type, value) + @keys ||= [] + @keys << {type: type, value: value} + end + + def fetch(host, ports, protocol = :tcp) + File.delete("nmap.xml") if File.exist?("nmap.xml") + Nmap::Command.new do |nmap| + nmap.skip_discovery = true + nmap.targets = host + nmap.udp_scan = true if protocol == :udp + nmap.ports = ports + nmap.syn_scan = false + nmap.service_scan = true + nmap.verbose = 0 + nmap.output_xml = "nmap.xml" + end.run_command + Nmap::XML.open("nmap.xml") do |xml| + @out = xml.hosts.map do |host| + host.ports.map do |port| + { + host: host.ip, + port: port.number, + protocol: port.protocol, + state: port.state + } + end + end + end + @out.flatten + end + + def check + checker = fetch(@address, @port, :tcp) + [Xmon.compare(@status, checker[0][:state])] + end + end +end diff --git a/lib/xmon/udp.rb b/lib/xmon/udp.rb new file mode 100644 index 0000000..ae11cb7 --- /dev/null +++ b/lib/xmon/udp.rb @@ -0,0 +1,9 @@ +module Xmon + class UDP < Description + def initialize(address, *args, **kwargs) + @address = address + @port = args[0] + @protocol = kwargs[:protocol] + end + end +end diff --git a/lib/xmon/whois.rb b/lib/xmon/whois.rb new file mode 100644 index 0000000..b66df79 --- /dev/null +++ b/lib/xmon/whois.rb @@ -0,0 +1,9 @@ +require "whois-parser" + +module Xmon + class Whois < Description + def fetch(record) + ::Whois.whois(record) + end + end +end diff --git a/xmon.gemspec b/xmon.gemspec index a4f09c5..eb9e798 100644 --- a/xmon.gemspec +++ b/xmon.gemspec @@ -8,17 +8,17 @@ Gem::Specification.new do |spec| spec.authors = ["Jiří Kubíček"] spec.email = ["jiri.kubicek@kraxnet.cz"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Yet another network monitoring tool" + spec.description = "Use DSL to describe your network and services, run periodic checks and get notified when something changes." + spec.homepage = "https://github.com/kraxnet/xmon" spec.license = "MIT" spec.required_ruby_version = ">= 2.6.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata["source_code_uri"] = "https://github.com/kraxnet/xmon.git" + spec.metadata["changelog_uri"] = "https://github.com/kraxnet/xmon/commits" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -32,8 +32,14 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + spec.add_dependency "slop" + spec.add_dependency "whois-parser" + spec.add_dependency "httparty" + spec.add_dependency "dnsruby" + spec.add_dependency "ruby-nmap" + spec.add_dependency "activesupport", "~> 7.0.8" + spec.add_dependency "colorize" + spec.add_development_dependency "pry" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html