diff options
author | Patrick J Cherry <patrick@bytemark.co.uk> | 2011-04-13 17:03:16 +0100 |
---|---|---|
committer | Patrick J Cherry <patrick@bytemark.co.uk> | 2011-04-13 17:03:16 +0100 |
commit | 89a67770e66d11740948e90a41db6cee0482cf8e (patch) | |
tree | be858515fb789a89d68f94975690ab019813726c /lib |
new version.
Diffstat (limited to 'lib')
39 files changed, 4590 insertions, 0 deletions
diff --git a/lib/canary.rb b/lib/canary.rb new file mode 100644 index 0000000..afe3a16 --- /dev/null +++ b/lib/canary.rb @@ -0,0 +1,55 @@ +# encoding: UTF-8 +require 'logger' + +# A little canary class to make sure that threads are are overloaded. +class Canary + + # Accessor. + attr_reader :sleep_time + + # Accessor. + attr_reader :threshold + + # Default constructor. + def initialize (st=1, log=nil) + if Float != st.class and Fixnum != st.class + raise ArgumentError.new( + "Expected either Fixnum or Float for time to sleep, got #{st.class}.") + end + @sleep_time = st + @threshold = (0.05 * @sleep_time) + @sleep_time + @logger = log + end + + # Runs the check. + def run + loop do + self.do_test() + end + end + + def do_test + time_start = Time.now + sleep(@sleep_time) + time_end = Time.now + time_elapsed = (time_end - time_start).abs + if @threshold < time_elapsed + @logger.fatal("Time elapsed is #{time_elapsed} > #{@threshold} therefore Canary is dead.") + return false + else + @logger.debug("Time elapsed is #{time_elapsed} < #{@threshold} therefore Canary is alive.") + return true + end + end + + # Starts a canary in a thread. + def self.start (st=1, log=nil) + #Thread.abort_on_exception = true + thr = Thread.new() do + Thread.current[:name] = "Canary Thread" + twiti = Canary.new(st, log) + twiti.run() + end + end + +end diff --git a/lib/dm-sqlite-adapter-with-mutex.rb b/lib/dm-sqlite-adapter-with-mutex.rb new file mode 100644 index 0000000..2842c5e --- /dev/null +++ b/lib/dm-sqlite-adapter-with-mutex.rb @@ -0,0 +1,28 @@ +# +# Add a mutex so that we can avoid the 'database is locked' Sqlite3Error +# exception. +# +require 'dm-sqlite-adapter' +require 'monitor' + +ADAPTER = DataMapper::Adapters::SqliteAdapter + +# better way to alias a private method? (other than "don't"? :) ) +ADAPTER.__send__(:alias_method, :initialize_old, :initialize) +ADAPTER.__send__(:undef_method, :initialize) +ADAPTER.__send__(:alias_method, :with_connection_old, :with_connection) +ADAPTER.__send__(:undef_method, :with_connection) + +class ADAPTER + + def initialize(*a) + extend(MonitorMixin) + initialize_old(*a) + end + + private + + def with_connection(&block) + synchronize { with_connection_old(&block) } + end +end diff --git a/lib/mauve/alert.rb b/lib/mauve/alert.rb new file mode 100644 index 0000000..374762d --- /dev/null +++ b/lib/mauve/alert.rb @@ -0,0 +1,399 @@ +require 'mauve/proto' +require 'mauve/alert_changed' +require 'mauve/datamapper' + + +module Mauve + class AlertEarliestDate + + include DataMapper::Resource + + property :id, Serial + property :alert_id, Integer + property :earliest, DateTime + belongs_to :alert, :model => "Alert" + + # 1) Shame we can't get this called automatically from DataMapper.auto_upgrade! + # + # 2) Can't use a neater per-connection TEMPORARY VIEW because the pooling + # function causes the connection to get dropped occasionally, and we can't + # hook the reconnect function (that I know of). + # + # http://www.mail-archive.com/datamapper@googlegroups.com/msg02314.html + # + def self.create_view! + the_distant_future = MauveTime.now + 86400000 # it is the year 2000 - the humans are dead + ["BEGIN TRANSACTION", + "DROP VIEW IF EXISTS mauve_alert_earliest_dates", + "CREATE VIEW + mauve_alert_earliest_dates + AS + SELECT + id AS alert_id, + NULLIF( + MIN( + IFNULL(will_clear_at, '#{the_distant_future}'), + IFNULL(will_raise_at, '#{the_distant_future}'), + IFNULL(will_unacknowledge_at, '#{the_distant_future}') + ), + '#{the_distant_future}' + ) AS earliest + FROM mauve_alerts + WHERE + will_clear_at IS NOT NULL OR + will_raise_at IS NOT NULL OR + will_unacknowledge_at IS NOT NULL + ", + "END TRANSACTION"].each do |statement| + repository(:default).adapter.execute(statement.gsub(/\s+/, " ")) + end + end + + end + + class Alert + def bytesize; 99; end + def size; 99; end + + include DataMapper::Resource + + property :id, Serial + property :alert_id, String, :required => true, :unique_index => :alert_index, :length=>256 + property :source, String, :required => true, :unique_index => :alert_index, :length=>512 + property :subject, String, :length=>512, :length=>512 + property :summary, String, :length=>1024 + property :detail, Text, :length=>65535 + property :importance, Integer, :default => 50 + + property :raised_at, DateTime + property :cleared_at, DateTime + property :updated_at, DateTime + property :acknowledged_at, DateTime + property :acknowledged_by, String + property :update_type, String + + property :will_clear_at, DateTime + property :will_raise_at, DateTime + property :will_unacknowledge_at, DateTime +# property :will_unacknowledge_after, Integer + + has n, :changes, :model => AlertChanged + has 1, :alert_earliest_date + + validates_with_method :check_dates + + def to_s + "#<Alert:#{id} #{alert_id} from #{source} update_type #{update_type}>" + end + + def check_dates + bad_dates = self.attributes.find_all do |key, value| + value.is_a?(DateTime) and not (DateTime.new(2000,1,1,0,0,0)..DateTime.new(2020,1,1,0,0,0)).include?(value) + end + + if bad_dates.empty? + true + else + [ false, "The dates "+bad_dates.collect{|k,v| "#{v.to_s} (#{k})"}.join(", ")+" are invalid." ] + end + end + + default_scope(:default).update(:order => [:source, :importance]) + + def logger + Log4r::Logger.new(self.class.to_s) + end + + def time_relative(secs) + secs = secs.to_i.abs + case secs + when 0..59 then "just now" + when 60..3599 then "#{secs/60}m ago" + when 3600..86399 then "#{secs/3600}h ago" + else + days = secs/86400 + days == 1 ? "yesterday" : "#{secs/86400} days ago" + end + end + + def summary_one_line + subject ? "#{subject} #{summary}" : "#{source} #{summary}" + end + + def summary_two_lines + msg = "" + msg += "from #{source} " if source != subject + if cleared_at + msg += "cleared #{time_relative(MauveTime.now - cleared_at.to_time)}" + elsif acknowledged_at + msg += "acknowledged #{time_relative(MauveTime.now - acknowledged_at.to_time)} by #{acknowledged_by}" + else + msg += "raised #{time_relative(MauveTime.now - raised_at.to_time)}" + end + [summary_one_line, msg] + end + + # Returns a better array with information about the alert. + # + # @return [Array] An array of three elements: status, message, source. + def summary_three_lines + status = String.new + if "cleared" == update_type + status += "CLEARED #{time_relative(MauveTime.now - cleared_at.to_time)}" + elsif "acknowledged" == update_type + status += "ACKNOWLEDGED #{time_relative(MauveTime.now - acknowledged_at.to_time)} by #{acknowledged_by}" + elsif "changed" == update_type + status += "CHANGED #{time_relative(MauveTime.now - updated_at.to_time)}" + else + status += "RAISED #{time_relative(MauveTime.now - raised_at.to_time)}" + end + src = (source != subject)? "from #{source}" : nil + return [status, summary_one_line, src] +=begin + status = String.new + if cleared_at + status += "CLEARED #{time_relative(MauveTime.now - cleared_at.to_time)}" + elsif acknowledged_at + status += "ACKNOWLEDGED #{time_relative(MauveTime.now - acknowledged_at.to_time)} by #{acknowledged_by}" + else + status += "RAISED #{time_relative(MauveTime.now - raised_at.to_time)}" + end + src = (source != subject)? "from #{source}" : nil + return [status, summary_one_line, src] +=end + end + + + def alert_group + AlertGroup.matches(self)[0] + end + + def subject + attribute_get(:subject) || source + end + + def subject=(subject); set_changed_if_different(:subject, subject); end + def summary=(summary); set_changed_if_different(:summary, summary); end + def detail=(detail); set_changed_if_different(:detail, detail); end + + protected + def set_changed_if_different(attribute, value) + return if self.__send__(attribute) == value + self.update_type ||= :changed + attribute_set(attribute.to_sym, value) + end + + public + + def acknowledge!(person) + self.acknowledged_by = person.username + self.acknowledged_at = MauveTime.now + self.update_type = :acknowledged + self.will_unacknowledge_at = MauveTime.parse(acknowledged_at.to_s) + + logger.error("Couldn't save #{self}") unless save + AlertGroup.notify([self]) + end + + def unacknowledge! + self.acknowledged_by = nil + self.acknowledged_at = nil + self.update_type = :raised + logger.error("Couldn't save #{self}") unless save + AlertGroup.notify([self]) + end + + def raise! + already_raised = raised? && !acknowledged? + self.acknowledged_by = nil + self.acknowledged_at = nil + self.will_unacknowledge_at = nil + self.will_raise_at = nil + self.update_type = :raised + self.raised_at = MauveTime.now + self.cleared_at = nil + logger.error("Couldn't save #{self}") unless save + AlertGroup.notify([self]) unless already_raised + end + + def clear!(notify=true) + already_cleared = cleared? + self.cleared_at = MauveTime.now + self.will_clear_at = nil + self.update_type = :cleared + logger.error("Couldn't save #{self}") unless save + AlertGroup.notify([self]) unless !notify || already_cleared + end + + # Returns the time at which a timer loop should call poll_event to either + # raise, clear or unacknowldge this event. + # + def due_at + o = [will_clear_at, will_raise_at, will_unacknowledge_at].compact.sort[0] + o ? o.to_time : nil + end + + def poll + raise! if will_unacknowledge_at && will_unacknowledge_at.to_time <= MauveTime.now || + will_raise_at && will_raise_at.to_time <= MauveTime.now + clear! if will_clear_at && will_clear_at.to_time <= MauveTime.now + end + + def raised? + !raised_at.nil? && cleared_at.nil? + end + + def acknowledged? + !acknowledged_at.nil? + end + + def cleared? + new? || !cleared_at.nil? + end + + class << self + + def all_current + all(:cleared_at => nil) + end + + # Returns the next Alert that will have a timed action due on it, or nil + # if none are pending. + # + def find_next_with_event + earliest_alert = AlertEarliestDate.first(:order => [:earliest]) + earliest_alert ? earliest_alert.alert : nil + end + + def all_overdue(at = MauveTime.now) + AlertEarliestDate.all(:earliest.lt => at, :order => [:earliest]).collect do |earliest_alert| + earliest_alert ? earliest_alert.alert : nil + end + end + + # Receive an AlertUpdate buffer from the wire. + # + def receive_update(update, reception_time = MauveTime.now) + update = Proto::AlertUpdate.parse_from_string(update) unless + update.kind_of?(Proto::AlertUpdate) + alerts_updated = [] + + logger.debug("Alert update received from wire: #{update.inspect.split.join(", ")}") + + # + # Transmission time helps us determine any time offset + # + if update.transmission_time + transmission_time = MauveTime.at(update.transmission_time) + else + transmission_time = reception_time + end + + time_offset = (reception_time - transmission_time).round + logger.debug("Update received from a host #{time_offset}s behind") if time_offset.abs > 0 + + # Update each alert supplied + # + update.alert.each do |alert| + # Infer some actions from our pure data structure (hmm, wonder if + # this belongs in our protobuf-derived class? + # + raise_time = alert.raise_time == 0 ? nil : MauveTime.at(alert.raise_time + time_offset) + clear_time = alert.clear_time == 0 ? nil : MauveTime.at(alert.clear_time + time_offset) + + logger.debug("received at #{reception_time}, transmitted at #{transmission_time}, raised at #{raise_time}, clear at #{clear_time}") + + do_clear = clear_time && clear_time <= reception_time + do_raise = raise_time && raise_time <= reception_time + + alert_db = first(:alert_id => alert.id, :source => update.source) || + new(:alert_id => alert.id, :source => update.source) + + pre_raised = alert_db.raised? + pre_cleared = alert_db.cleared? + pre_acknowledged = alert_db.acknowledged? + + alert_db.update_type = nil + + ## + # + # Allow a 15s offset in timings. + # + if raise_time + if raise_time <= (reception_time + 15) + alert_db.raised_at = raise_time + else + alert_db.will_raise_at = raise_time + end + end + + if clear_time + if clear_time <= (reception_time + 15) + alert_db.cleared_at = clear_time + else + alert_db.will_clear_at = clear_time + end + end + + # re-raise + if alert_db.cleared_at && alert_db.raised_at && alert_db.cleared_at < alert_db.raised_at + alert_db.cleared_at = nil + end + + if pre_cleared && alert_db.raised? + alert_db.update_type = :raised + elsif pre_raised && alert_db.cleared? + alert_db.update_type = :cleared + end + + # Changing any of these attributes causes the alert to be sent back + # out to the notification system with an update_type of :changed. + # + alert_db.subject = alert.subject if alert.subject && !alert.subject.empty? + alert_db.summary = alert.summary if alert.summary && !alert.summary.empty? + alert_db.detail = alert.detail if alert.detail && !alert.detail.empty? + + # These updates happen but do not sent the alert back to the + # notification system. + # + alert_db.importance = alert.importance if alert.importance != 0 + + # FIXME: this logic ought to be clearer as it may get more complicated + # + if alert_db.update_type + if alert_db.update_type.to_sym == :changed && !alert_db.raised? + # do nothing + else + alerts_updated << alert_db + end + else + alert_db.update_type = :changed + end + + logger.error "Couldn't save update #{alert} because of #{alert_db.errors}" unless alert_db.save + end + + # If this is a complete replacement update, find the other alerts + # from this source and clear them. + # + if update.replace + alert_ids_mentioned = update.alert.map { |alert| alert.id } + logger.debug "Replacing all alerts from #{update.source} except "+alert_ids_mentioned.join(",") + all(:source => update.source, + :alert_id.not => alert_ids_mentioned, + :cleared_at => nil + ).each do |alert_db| + logger.debug "Replace: clearing #{alert_db.id}" + alert_db.clear!(false) + alerts_updated << alert_db + end + end + + AlertGroup.notify(alerts_updated) + end + + def logger + Log4r::Logger.new("Mauve::Alert") + end + end + end +end diff --git a/lib/mauve/alert_changed.rb b/lib/mauve/alert_changed.rb new file mode 100644 index 0000000..4668fd7 --- /dev/null +++ b/lib/mauve/alert_changed.rb @@ -0,0 +1,144 @@ +# encoding: UTF-8 +require 'mauve/datamapper' +require 'log4r' + +module Mauve + class AlertChanged + include DataMapper::Resource + + # so .first always returns the most recent update + default_scope(:default).update(:order => [:at.desc, :id.desc]) + + property :id, Serial + property :alert_id, Integer, :required => true + property :person, String, :required => true + property :at, DateTime, :required => true + property :was_relevant, Boolean, :required => true, :default => true + property :level, String, :required => true + property :update_type, String, :required => true + property :remind_at, DateTime + property :updated_at, DateTime + + + def to_s + "#<AlertChanged:#{id} of #{alert_id} for #{person} update_type #{update_type}>" + end + + belongs_to :alert + + # There is a bug there. You could have two reminders for the same + # person if that person has two different notify clauses. + # + # See the test cases test_Bug_reminders_get_trashed() in ./test/ + after :create do + old_changed = AlertChanged.first( + :alert_id => alert_id, + :person => person, + :id.not => id, + :remind_at.not => nil + ) + if old_changed + if !old_changed.update(:remind_at => nil) + logger.error "Couldn't save #{old_changed}, will get duplicate reminders" + end + end + end + + def was_relevant=(value) + attribute_set(:was_relevant, value) + end + + def logger + Log4r::Logger.new self.class.to_s + end + +## def initialize +# logger = Log4r::Logger.new self.class.to_s +# end + + ## Checks to see if a raise was send to the person. + # + # @TODO: Recurence is broken in ruby, change this so that it does not + # use it. + # + # @author Matthew Bloch + # @return [Boolean] true if it was relevant, false otherwise. + def was_relevant_when_raised? + if :acknowledged == update_type.to_sym and true == was_relevant + return true + end + return was_relevant if update_type.to_sym == :raised + previous = AlertChanged.first(:id.lt => id, + :alert_id => alert_id, + :person => person) + if previous + previous.was_relevant_when_raised? + else + # a bug, but hardly inconceivable :) + logger.warn("Could not see that #{alert} was raised with #{person} "+ + "but further updates exist (e.g. #{self}) "+ + "- you may see spurious notifications as a result") + true + end + end + + # Sends a reminder about this alert state change, or forget about it if + # the alert has been acknowledged + # + def remind + logger.debug "Reminding someone about #{self.inspect}" + + alert_group = AlertGroup.matches(alert)[0] + + if !alert_group || alert.acknowledged? + logger.debug((alert_group ? + "Alert already acknowledged" : + "No alert group matches any more" + ) + ", no reminder due" + ) + self.remind_at = nil + save + else + saved = false + + alert_group.notifications.each do |notification| + notification.people.each do |person| + if person.username == self.person + person.remind(alert, level) + self.remind_at = notification.remind_at_next(alert) + save + saved = true + end + end + end + + if !saved + logger.warn("#{self.inspect} did not match any people, maybe configuration has changed but I'm going to delete this and not try to remind anyone again") + destroy! + end + end + end + + def due_at # mimic interface from Alert + remind_at ? remind_at.to_time : nil + end + + def poll # mimic interface from Alert + remind if remind_at.to_time <= MauveTime.now + end + + class << self + def next_reminder + first(:remind_at.not => nil, :order => [:remind_at]) + end + + def find_next_with_event # mimic interface from Alert + next_reminder + end + + def all_overdue(at = MauveTime.now) + all(:remind_at.not => nil, :remind_at.lt => at, :order => [:remind_at]).to_a + end + end + end +end diff --git a/lib/mauve/alert_group.rb b/lib/mauve/alert_group.rb new file mode 100644 index 0000000..d8156fa --- /dev/null +++ b/lib/mauve/alert_group.rb @@ -0,0 +1,104 @@ +# encoding: UTF-8 +require 'mauve/alert' +require 'log4r' + +module Mauve + class AlertGroup < Struct.new(:name, :includes, :acknowledgement_time, :level, :notifications) + def to_s + "#<AlertGroup:#{name} (level #{level})>" + end + + class << self + def matches(alert) + all.select { |alert_group| alert_group.matches_alert?(alert) } + end + + # If there is any significant change to a set of alerts, the Alert + # class sends the list here so that appropriate action can be taken + # for each one. We scan the list of alert groups to find out which + # alerts match which groups, then send a notification to each group + # object in turn. + # + def notify(alerts) + alerts.each do |alert| + groups = matches(alert) + + # + # Make sure we've got a matching group + # + logger.warn "no groups found for #{alert.id}" if groups.empty? + + # + # Notify each group. + # + groups.each do |grp| + logger.info("notifying group #{groups[0]} of AlertID.#{alert.id}.") + grp.notify(alert) + end + end + end + + def logger + Log4r::Logger.new self.to_s + end + + def all + Configuration.current.alert_groups + end + + # Find all alerts that match + # + # @deprecated Buggy method, use Alert.get_all(). + # + # This method returns all the alerts in all the alert_groups. Only + # the first one should be returned thus making this useless. If you want + # a list of all the alerts matching a level, use Alert.get_all(). + # + def all_alerts_by_level(level) + Configuration.current.alert_groups.map do |alert_group| + alert_group.level == level ? alert_group.current_alerts : [] + end.flatten.uniq + end + + end + + def initialize(name) + self.name = name + self.level = :normal + self.includes = Proc.new { true } + end + + # The list of current raised alerts in this group. + # + def current_alerts + Alert.all(:cleared_at => nil, :raised_at.not => nil).select { |a| matches_alert?(a) } + end + + # Decides whether a given alert belongs in this group according to its + # includes { } clause + # + # @param [Alert] alert An alert to test for belongness to group. + # @return [Boolean] Success or failure. + def matches_alert?(alert) + result = alert.instance_eval(&self.includes) + if true == result or + true == result.instance_of?(MatchData) + return true + end + return false + end + + def logger ; self.class.logger ; end + + # Signals that a given alert (which is assumed to belong in this group) + # has undergone a significant change. We resend this to every notify list. + # + def notify(alert) + notifications.each do |notification| + notification.alert_changed(alert) + end + end + + end + +end diff --git a/lib/mauve/auth_bytemark.rb b/lib/mauve/auth_bytemark.rb new file mode 100644 index 0000000..7419d10 --- /dev/null +++ b/lib/mauve/auth_bytemark.rb @@ -0,0 +1,47 @@ +# encoding: UTF-8 +require 'sha1' +require 'xmlrpc/client' +require 'timeout' + +class AuthSourceBytemark + + def initialize (srv='auth.bytemark.co.uk', port=443) + raise ArgumentError.new("Server must be a String, not a #{srv.class}") if String != srv.class + raise ArgumentError.new("Port must be a Fixnum, not a #{port.class}") if Fixnum != port.class + @srv = srv + @port = port + @timeout = 7 + end + + ## Not really needed. + def ping () + begin + MauveTimeout.timeout(@timeout) do + s = TCPSocket.open(@srv, @port) + s.close() + return true + end + rescue MauveTimeout::Error => ex + return false + rescue => ex + return false + end + return false + end + + def authenticate(login, password) + raise ArgumentError.new("Login must be a string, not a #{login.class}") if String != login.class + raise ArgumentError.new("Password must be a string, not a #{password.class}") if String != password.class + raise ArgumentError.new("Login or/and password is/are empty.") if login.empty? || password.empty? + client = XMLRPC::Client.new(@srv,"/",@port,nil,nil,nil,nil,true,@timeout).proxy("bytemark.auth") + begin + challenge = client.getChallengeForUser(login) + response = Digest::SHA1.new.update(challenge).update(password).hexdigest + client.login(login, response) + rescue XMLRPC::FaultException => fault + return "Fault code is #{fault.faultCode} stating #{fault.faultString}" + end + return true + end + +end diff --git a/lib/mauve/calendar_interface.rb b/lib/mauve/calendar_interface.rb new file mode 100644 index 0000000..08cfab3 --- /dev/null +++ b/lib/mauve/calendar_interface.rb @@ -0,0 +1,146 @@ +# encoding: UTF-8 +require 'log4r' +require 'net/http' +require 'net/https' +require 'uri' + +module Mauve + + # Interface to the Bytemark calendar. + # + # Nota Bene that this does not include a chaching mechanism. Some caching + # is implemented in the Person object. + # + # @see Mauve::Person + # @author yann Golanski. + class CalendarInterface + + TIMEOUT = 7 + + public + + # Gets a list of ssologin on support. + # + # Class method. + # + # @param [String] url A Calendar API url. + # @return [Array] A list of all the username on support. + def self.get_users_on_support(url) + result = get_URL(url) + logger = Log4r::Logger.new "mauve::CalendarInterface" + logger.debug("Cheching who is on support: #{result}") + return result + end + + # Check to see if the user is on support. + # + # Class method. + # + # @param [String] url A Calendar API url. + # @param [String] usr User single sign on. + # @return [Boolean] True if on support, false otherwise. + def self.is_user_on_support?(url, usr) + logger = Log4r::Logger.new "mauve::CalendarInterface" + list = get_URL(url) + if true == list.include?("nobody") + logger.error("Nobody is on support thus alerts are ignored.") + return false + end + result = list.include?(usr) + logger.debug("Cheching if #{usr} is on support: #{result}") + return result + end + + # Check to see if the user is on holiday. + # + # Class method. + # + # @param [String] url A Calendar API url. + # @param [String] usr User single sign on. + # @return [Boolean] True if on holiday, false otherwise. + def self.is_user_on_holiday?(url, usr) + list = get_URL(url) + return false if true == list.nil? or true == list.empty? + pattern = /[\d]{4}-[\d]{2}-[\d]{2}\s[\d]{2}:[\d]{2}:[\d]{2}/ + result = (list[0].match(pattern))? true : false + logger = Log4r::Logger.new "mauve::CalendarInterface" + logger.debug("Cheching if #{usr} is on holiday: #{result}") + return result + end + + + private + + # Gets a URL from the wide web. + # + # Must NOT crash Mauveserver. + # + # Class method. + # + # @TODO: boot this in its own class since list of ips will need it too. + # + # @param [String] url A Calendar API url. + # @retur [Array] An array of strings, each newline creates an new item. + def self.get_URL (uri_str, limit = 11) + + logger = Log4r::Logger.new "mauve::CalendarInterface" + + if 0 == limit + logger.warn("HTTP redirect deeper than 11 on #{uri_str}.") + return false + end + + begin + uri_str = 'http://' + uri_str unless uri_str.match(uri_str) + url = URI.parse(uri_str) + http = Net::HTTP.new(url.host, url.port) + http.open_timeout = TIMEOUT + http.read_timeout = TIMEOUT + if (url.scheme == "https") + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + response = nil + if nil == url.query + response = http.start { http.get(url.path) } + else + response = http.start { http.get("#{url.path}?#{url.query}") } + end + case response + when Net::HTTPRedirection + then + newURL = response['location'].match(/^http/)? + response['Location']: + uri_str+response['Location'] + self.getURL(newURL, limit-1) + else + return response.body.split("\n") + end + rescue Errno::EHOSTUNREACH => ex + logger.warn("no route to host.") + return Array.new + rescue MauveTimeout::Error => ex + logger.warn("time out reached.") + return Array.new + rescue ArgumentError => ex + unless uri_str.match(/\/$/) + logger.debug("Potential missing '/' at the end of hostname #{uri_str}") + uri_str += "/" + retry + else + str = "ArgumentError raise: #{ex.message} #{ex.backtrace.join("\n")}" + logger.fatal(str) + return Array.new + #raise ex + end + rescue => ex + str = "ArgumentError raise: #{ex.message} #{ex.backtrace.join("\n")}" + logger.fatal(str) + return Array.new + #raise ex + end + + end + end + +end diff --git a/lib/mauve/configuration.rb b/lib/mauve/configuration.rb new file mode 100644 index 0000000..b11e1b5 --- /dev/null +++ b/lib/mauve/configuration.rb @@ -0,0 +1,484 @@ +# encoding: UTF-8 +require 'object_builder' +require 'mauve/server' +require 'mauve/web_interface' +require 'mauve/person' +require 'mauve/notification' +require 'mauve/alert_group' +require 'mauve/people_list' +require 'mauve/source_list' + +# Seconds, minutes, hours, days, and weeks... More than that, we +# really should not need it. +class Integer + def seconds; self; end + def minutes; self*60; end + def hours; self*3600; end + def days; self*86400; end + def weeks; self*604800; end + alias_method :day, :days + alias_method :hour, :hours + alias_method :minute, :minutes + alias_method :week, :weeks +end + +module Mauve + + ## Configuration object for Mauve. + # + # + # @TODO Write some more documentation. This is woefully inadequate. + # + # + # == How to add a new class to the configuration? + # + # - Add a method to ConfigurationBuilder such that your new object + # maybe created. Call it created_NEW_OBJ. + # + # - Create a new class inheriting from ObjectBuilder with at least a + # builder_setup() method. This should create the new object you want. + # + # - Define attributes for the new class are defined as "is_attribute". + # + # - Methods for the new class are defined as methods or missing_method + # depending on what one wishes to do. Remember to define a method + # with "instance_eval(&block)" if you want to call said block within + # the new class. + # + # - Add a "is_builder "<name>", BuilderCLASS" clause in the + # ConfigurationBuilder class. + # + # That should be it. + # + # @author Matthew Bloch, Yann Golanski + class Configuration + + class << self + attr_accessor :current + end + + attr_accessor :server + attr_accessor :last_alert_group + attr_reader :notification_methods + attr_reader :people + attr_reader :alert_groups + attr_reader :people_lists + attr_reader :source_lists + attr_reader :logger + + def initialize + @notification_methods = {} + @people = {} + @people_lists = {} + @alert_groups = [] + @source_lists = SourceList.new() + @logger = Log4r::Logger.new("Mauve") + + end + + def close + server.close + end + + end + + class LoggerOutputterBuilder < ObjectBuilder + + def builder_setup(outputter) + @outputter = outputter.capitalize+"Outputter" + + begin + Log4r.const_get(@outputter) + rescue + require "log4r/outputter/#{@outputter.downcase}" + end + + @args = {} + end + + def result + @result ||= Log4r.const_get(@outputter).new("Mauve", @args) + end + + def format(f) + result.formatter = Log4r::PatternFormatter.new(:pattern => f) + end + + def method_missing(name, value=nil) + if value.nil? + result.send(name.to_sym) + else + @args[name.to_sym] = value + end + end + + end + + class LoggerBuilder < ObjectBuilder + + is_builder "outputter", LoggerOutputterBuilder + + def builder_setup + logger = Log4r::Logger.new("Mauve") + @default_format = nil + @default_level = Log4r::RootLogger.instance.level + end + + def result + @result = Log4r::Logger['Mauve'] + end + + def default_format(f) + @default_formatter = Log4r::PatternFormatter.new(:pattern => f) + # + # Set all current outputters + # + result.outputters.each do |o| + o.formatter = @default_formatter if o.formatter.is_a?(Log4r::DefaultFormatter) + end + end + + def default_level(l) + if Log4r::Log4rTools.valid_level?(l) + @default_level = l + else + raise "Bad default level set for the logger #{l}.inspect" + end + + result.outputters.each do |o| + o.level = @default_level if o.level == Log4r::RootLogger.instance.level + end + end + + def created_outputter(outputter) + # + # Set the formatter and level for any newly created outputters + # + if @default_formatter + outputter.formatter = @default_formatter if outputter.formatter.is_a?(Log4r::DefaultFormatter) + end + + if @default_level + outputter.level = @default_level if outputter.level == Log4r::RootLogger.instance.level + end + + result.outputters << outputter + end + + end + + class ProcessorBuilder < ObjectBuilder + is_attribute "sleep_interval" + + def builder_setup + @result = Processor.instance + end + + def method_missing(name, value) + @args[name] = value + end + end + + class UDPServerBuilder < ObjectBuilder + is_attribute "port" + is_attribute "ip" + is_attribute "sleep_interval" + + def builder_setup + @result = UDPServer.instance + end + + def method_missing(name, value) + @args[name] = value + end + end + + class TimerBuilder < ObjectBuilder + is_attribute "sleep_interval" + + def builder_setup + @result = Timer.instance + end + + def method_missing(name, value) + @args[name] = value + end + + + end + + class HTTPServerBuilder < ObjectBuilder + + is_attribute "port" + is_attribute "ip" + is_attribute "document_root" + + def builder_setup + @result = HTTPServer.instance + end + + def method_missing(name, value) + @args[name] = value + end + + end + + class NotifierBuilder < ObjectBuilder + is_attribute "sleep_interval" + + def builder_setup + @result = Notifier.instance + end + + def method_missing(name, value) + @args[name] = value + end + + end + + class ServerBuilder < ObjectBuilder + + is_builder "web_interface", HTTPServerBuilder + is_builder "listener", UDPServerBuilder + is_builder "processor", ProcessorBuilder + is_builder "timer", TimerBuilder + is_builder "notifier", NotifierBuilder + + def builder_setup + @args = {} + end + + def result + @result = Mauve::Server.instance + @result.configure(@args) + @result.web_interface = @web_interface + @result + end + + def method_missing(name, value) + @args[name] = value + end + + def created_web_interface(web_interface) + @web_interface = web_interface + end + + def created_listener(listener) + @listener = listener + end + + def created_processor(processor) + @processor = processor + end + + def created_notifier(notifier) + @notifier = notifier + end + end + + class NotificationMethodBuilder < ObjectBuilder + + def builder_setup(name) + @notification_type = name.capitalize + @name = name + provider("Default") + end + + + def provider(name) + notifiers_base = Mauve::Notifiers + notifiers_type = notifiers_base.const_get(@notification_type) + @provider_class = notifiers_type.const_get(name) + end + + def result + @result ||= @provider_class.new(@name) + end + + def method_missing(name, value=nil) + if value + result.send("#{name}=".to_sym, value) + else + result.send(name.to_sym) + end + end + + end + + class PersonBuilder < ObjectBuilder + + def builder_setup(username) + @result = Person.new(username) + end + + is_block_attribute "urgent" + is_block_attribute "normal" + is_block_attribute "low" + + def all(&block); urgent(&block); normal(&block); low(&block); end + + def password (pwd) + @result.password = pwd.to_s + end + + def holiday_url (url) + @result.holiday_url = url + end + + def suppress_notifications_after(h) + raise ArgumentError.new("notification_threshold must be specified as e.g. (10 => 1.minute)") unless + h.kind_of?(Hash) && h.keys[0].kind_of?(Integer) && h.values[0].kind_of?(Integer) + @result.notification_thresholds[h.values[0]] = Array.new(h.keys[0]) + end + + end + + class NotificationBuilder < ObjectBuilder + + def builder_setup(*who) + who = who.map do |username| + #raise BuildException.new("You haven't declared who #{username} is") unless + # @context.people[username] + #@context.people[username] + if @context.people[username] + @context.people[username] + elsif @context.people_lists[username] + @context.people_lists[username] + else + raise BuildException.new("You have not declared who #{username} is") + end + end + @result = Notification.new(who, @context.last_alert_group.level) + end + + is_attribute "every" + is_block_attribute "during" + ##is_attribute "hours_in_day" + ##is_attribute "unacknowledged" + + end + + class AlertGroupBuilder < ObjectBuilder + + def builder_setup(name=anonymous_name) + @result = AlertGroup.new(name) + @context.last_alert_group = @result + end + + is_block_attribute "includes" + is_attribute "acknowledgement_time" + is_attribute "level" + + is_builder "notify", NotificationBuilder + + def created_notify(notification) + @result.notifications ||= [] + @result.notifications << notification + end + + end + + # New list of persons. + # @author Yann Golanski + class PeopleListBuilder < ObjectBuilder + + # Create a new instance and adds it. + def builder_setup(label) + pp label + @result = PeopleList.new(label) + end + + is_attribute "list" + + end + + # New list of sources. + # @author Yann Golanski + class AddSourceListBuilder < ObjectBuilder + + # Create the temporary object. + def builder_setup(label) + @result = AddSoruceList.new(label) + end + + # List of IP addresses or hostnames. + is_attribute "list" + + end + + + # this should live in AlertGroupBuilder but can't due to + # http://briancarper.net/blog/ruby-instance_eval_constant_scoping_broken + # + module ConfigConstants + URGENT = :urgent + NORMAL = :normal + LOW = :low + end + + class ConfigurationBuilder < ObjectBuilder + + include ConfigConstants + + is_builder "server", ServerBuilder + is_builder "notification_method", NotificationMethodBuilder + is_builder "person", PersonBuilder + is_builder "alert_group", AlertGroupBuilder + is_builder "people_list", PeopleListBuilder + is_builder "add_source_list", AddSourceListBuilder + is_builder "logger", LoggerBuilder + + def initialize + @context = @result = Configuration.new + # FIXME: need to test blocks that are not immediately evaluated + end + + def created_server(server) + raise ArgumentError.new("Only one 'server' clause can be specified") if + @result.server + @result.server = server + end + + def created_notification_method(notification_method) + name = notification_method.name + raise BuildException.new("Duplicate notification '#{name}'") if + @result.notification_methods[name] + @result.notification_methods[name] = notification_method + end + + def created_person(person) + name = person.username + raise BuildException.new("Duplicate person '#{name}'") if + @result.people[name] + @result.people[person.username] = person + end + + def created_alert_group(alert_group) + name = alert_group.name + raise BuildException.new("Duplicate alert_group '#{name}'") unless + @result.alert_groups.select { |g| g.name == name }.empty? + @result.alert_groups << alert_group + end + + # Create a new instance of people_list. + # + # @param [PeopleList] people_list The new list of persons. + # @return [NULL] nada. + def created_people_list(people_list) + label = people_list.label + raise BuildException.new("Duplicate people_list '#{label}'") if @result.people_lists[label] + @result.people_lists[label] = people_list + end + + # Create a new list of sources. + # + # @param [] add_source_list + # @return [NULL] nada. + def created_add_source_list(add_source_list) + @result.source_lists.create_new_list(add_source_list.label, + add_source_list.list) + end + + end + +end diff --git a/lib/mauve/datamapper.rb b/lib/mauve/datamapper.rb new file mode 100644 index 0000000..27d89a3 --- /dev/null +++ b/lib/mauve/datamapper.rb @@ -0,0 +1,13 @@ +# +# +# Small loader to put all our Datamapper requirements together +# +# +require 'dm-core' +require 'dm-sqlite-adapter-with-mutex' +require 'dm-types' +require 'dm-serializer' +require 'dm-migrations' +require 'dm-validations' +require 'dm-timestamps' + diff --git a/lib/mauve/http_server.rb b/lib/mauve/http_server.rb new file mode 100644 index 0000000..b4ced32 --- /dev/null +++ b/lib/mauve/http_server.rb @@ -0,0 +1,106 @@ +# encoding: UTF-8 +# +# Bleuurrgggggh! Bleurrrrrgghh! +# +require 'digest/sha1' +require 'log4r' +require 'thin' +require 'rack' +require 'rack-flash' +require 'rack/handler/webrick' +require 'mauve/auth_bytemark' +require 'mauve/web_interface' +require 'mauve/mauve_thread' + +################################################################################ +# +# Bodge up thin logging. +# +module Thin + module Logging + + def log(m=nil) + # return if Logging.silent? + logger = Log4r::Logger.new "Mauve::HTTPServer" + logger.info(m || yield) + end + + def debug(m=nil) + # return unless Logging.debug? + logger = Log4r::Logger.new "Mauve::HTTPServer" + logger.debug(m || yield) + end + + def trace(m=nil) + return unless Logging.trace? + logger = Log4r::Logger.new "Mauve::HTTPServer" + logger.debug(m || yield) + end + + def log_error(e=$!) + logger = Log4r::Logger.new "Mauve::HTTPServer" + logger.error(e) + logger.debug(e.backtrace.join("\n")) + end + + end +end + +################################################################################ +# +# More logging hacks for Rack +# +# @see http://stackoverflow.com/questions/2239240/use-rackcommonlogger-in-sinatra +# +class RackErrorsProxy + + def initialize(l); @logger = l; end + + def write(msg) + #@logger.debug "NEXT LOG LINE COURTESY OF: "+caller.join("\n") + case msg + when String then @logger.info(msg.chomp) + when Array then @logger.info(msg.join("\n")) + else + @logger.error(msg.inspect) + end + end + + alias_method :<<, :write + alias_method :puts, :write + def flush; end +end + + + +################################################################################ +module Mauve + + # + # API to control the web server + # + class HTTPServer < MauveThread + + include Singleton + + attr_accessor :port, :ip, :document_root + attr_accessor :session_secret # not used yet + + def initialize + @port = 32761 + @ip = "127.0.0.1" + @document_root = "." + @session_secret = rand(2**100).to_s + end + + def main_loop + @server = ::Thin::Server.new(@ip, @port, Rack::CommonLogger.new(Rack::Chunked.new(Rack::ContentLength.new(WebInterface.new)), RackErrorsProxy.new(@logger)), :signals => false) + @server.start + end + + def stop + @server.stop + super + end + end +end diff --git a/lib/mauve/mauve_thread.rb b/lib/mauve/mauve_thread.rb new file mode 100644 index 0000000..f40c79c --- /dev/null +++ b/lib/mauve/mauve_thread.rb @@ -0,0 +1,133 @@ +require 'thread' +require 'singleton' + +module Mauve + + class MauveThread + + def initialize + end + + def logger + Log4r::Logger.new(self.class.to_s) + end + + def run_thread(interval = 0.2) + # + # Good to go. + # + @frozen = false + @stop = false + + logger.debug("Started") + + @sleep_interval ||= interval + + while !@stop do + # + # Schtop! + # + if @frozen + logger.debug("Frozen") + Thread.stop + logger.debug("Thawed") + end + + yield + + next if self.should_stop? + + Kernel.sleep(@sleep_interval) + end + + logger.debug("Stopped") + end + + def should_stop? + @frozen or @stop + end + + def freeze + logger.debug("Freezing") + + @frozen = true + + 20.times { Kernel.sleep 0.1 ; break if @thread.stop? } + + logger.debug("Thread has not frozen!") unless @thread.stop? + end + + def thaw + logger.debug("Thawing") + + @frozen = false + + @thread.wakeup if @thread.stop? + end + + def start + logger.debug("Starting") + @thread = Thread.new{ self.run_thread { self.main_loop } } + end + + def run + if self.alive? + self.thaw + else + self.start + end + end + + def alive? + @thread.is_a?(Thread) and @thread.alive? + end + + def join(ok_exceptions=[]) + begin + @thread.join if @thread.is_a?(Thread) + rescue StandardError => err + logger.debug "#{err.to_s} #{err.class}" + Kernel.raise err unless ok_exceptions.any?{|e| err.is_a?(e)} + end + end + + def raise(ex) + @thread.raise(ex) + end + + def restart + self.stop + self.start + end + + def stop + logger.debug("Stopping") + + @stop = true + + 10.times do + break unless self.alive? + Kernel.sleep 1 if self.alive? + end + + # + # OK I've had enough now. + # + self.kill if self.alive? + + self.join + end + + alias exit stop + + def kill + logger.debug("Killing") + @frozen = true + @thread.kill + logger.debug("Killed") + end + + end + +end + diff --git a/lib/mauve/mauve_time.rb b/lib/mauve/mauve_time.rb new file mode 100644 index 0000000..bf69d1b --- /dev/null +++ b/lib/mauve/mauve_time.rb @@ -0,0 +1,16 @@ + +require 'time' + +module Mauve + + class MauveTime < Time + + def to_s + self.iso8601 + end + + end + +end + + diff --git a/lib/mauve/notification.rb b/lib/mauve/notification.rb new file mode 100644 index 0000000..2220211 --- /dev/null +++ b/lib/mauve/notification.rb @@ -0,0 +1,165 @@ +# encoding: UTF-8 +require 'mauve/person' +require 'mauve/notifiers' + +module Mauve + # This class provides an execution context for the code found in 'during' + # blocks in the configuration file. This code specifies when an alert + # should cause notifications to be generated, and can access @time and + # @alert variables. There are also some helper methods to provide + # oft-needed functionality such as hours_in_day. + # + # e.g. to send alerts only between 10 and 11 am: + # + # during = Proc.new { @time.hour == 10 } + # + # ... later on ... + # + # DuringRunner.new(MauveTime.now, my_alert, &during).inside? + # + # ... or to ask when an alert will next be cued ... + # + # DuringRunner.new(MauveTime.now, my_alert, &during).find_next + # + # which will return a MauveTime object, or nil if the time period will + # not be valid again, at least not in the next week. + # + class DuringRunner + + def initialize(time, alert=nil, &during) + raise ArgumentError.new("Must supply time (not #{time.inspect})") unless time.is_a?(Time) + @time = time + @alert = alert + @during = during || Proc.new { true } + @logger = Log4r::Logger.new "mauve::DuringRunner" + end + + def now? + instance_eval(&@during) + end + + def find_next(interval) + interval = 300 if true == interval.nil? + offset = (@time.nil?)? MauveTime.now : @time + plus_one_week = MauveTime.now + 604800 # ish + while offset < plus_one_week + offset += interval + if DuringRunner.new(offset, @alert, &@during).now? + @logger.debug("Found reminder time of #{offset}") + return offset + end + end + @logger.info("Could not find a reminder time less than a week "+ + "for #{@alert}.") + nil # never again + end + + protected + def hours_in_day(*hours) + x_in_list_of_y(@time.hour, hours.flatten) + end + + def days_in_week(*days) + x_in_list_of_y(@time.wday, days.flatten) + end + + ## Return true if the alert has not been acknowledged within a certain time. + # + def unacknowledged(seconds) + @alert && + @alert.raised? && + !@alert.acknowledged? && + (@time - @alert.raised_at.to_time) > seconds + end + + def x_in_list_of_y(x,y) + y.any? do |range| + if range.respond_to?("include?") + range.include?(x) + else + range == x + end + end + end + + def working_hours? + now = (@time || MauveTime.now) + (8..17).include?(now.hour) and (1..5).include?(now.wday) + end + + # Return true in the dead zone between 3 and 7 in the morning. + # + # Nota bene that this is used with different times in the reminder section. + # + # @return [Boolean] Whether now is a in the dead zone or not. + def dead_zone? + now = (@time || MauveTime.now) + (3..6).include?(now.hour) + end + + end + + # A Notification is an instruction to notify a list of people, at a + # particular alert level, on a periodic basis, and optionally under + # certain conditions specified by a block of code. + # + class Notification < Struct.new(:people, :level, :every, :during, :list) + + def to_s + "#<Notification:of #{people.map { |p| p.username }.join(',')} at level #{level} every #{every}>" + end + + attr_reader :thread_list + + def initialize(people, level) + + self.level = level + self.every = 300 + self.people = people + end + + def logger ; Log4r::Logger.new self.class.to_s ; end + + + # Updated code, now takes account of lists of people. + # + # @TODO refactor so we can test this more easily. + # + # @TODO Make sure that if no notifications is send at all, we log this + # as an error so that an email is send to the developers. Hum, we + # could have person.alert_changed return true if a notification was + # send (false otherwise) and add it to a queue. Then, dequeue till + # we see a "true" and abort. However, this needs a timeout loop + # around it and we will slow down the whole notificatin since it + # will have to wait untill such a time as it gets a true or timeout. + # Not ideal. A quick fix is to make sure that the clause in the + # configuration has a fall back that will send an alert in all cases. + # + def alert_changed(alert) + + # Should we notificy at all? + is_relevant = DuringRunner.new(MauveTime.now, alert, &during).now? + + to_notify = people.collect do |person| + case person + when Person + person + when PeopleList + person.people + else + logger.warn "Unable to notify #{person} (unrecognised class #{person.class})" + [] + end + end.flatten.uniq.each do |person| + person.alert_changed(level, alert, is_relevant, remind_at_next(alert)) + end + end + + def remind_at_next(alert) + return nil unless alert.raised? + DuringRunner.new(MauveTime.now, alert, &during).find_next(every) + end + + end + +end diff --git a/lib/mauve/notifier.rb b/lib/mauve/notifier.rb new file mode 100644 index 0000000..e0692f6 --- /dev/null +++ b/lib/mauve/notifier.rb @@ -0,0 +1,66 @@ +require 'mauve/mauve_thread' +require 'mauve/notifiers' +require 'mauve/notifiers/xmpp' + +module Mauve + + class Notifier < MauveThread + + DEFAULT_XMPP_MESSAGE = "Mauve server started." + + include Singleton + + attr_accessor :buffer, :sleep_interval + + def initialize + @buffer = Queue.new + end + + def main_loop + + # + # Cycle through the buffer. + # + sz = @buffer.size + + logger.debug("Notifier buffer is #{sz} in length") if sz > 1 + + (sz > 10 ? 10 : sz).times do + person, level, alert = @buffer.pop + begin + person.do_send_alert(level, alert) + rescue StandardError => ex + logger.debug ex.to_s + logger.debug ex.backtrace.join("\n") + end + end + + end + + def start + super + + Configuration.current.notification_methods['xmpp'].connect if Configuration.current.notification_methods['xmpp'] + end + + def stop + Configuration.current.notification_methods['xmpp'].close + + super + end + + class << self + + def enq(a) + instance.buffer.enq(a) + end + + alias push enq + + end + + end + +end + + diff --git a/lib/mauve/notifiers.rb b/lib/mauve/notifiers.rb new file mode 100644 index 0000000..7ba1d71 --- /dev/null +++ b/lib/mauve/notifiers.rb @@ -0,0 +1,6 @@ +# encoding: UTF-8 +require 'mauve/notifiers/email' +require 'mauve/notifiers/sms_default' +require 'mauve/notifiers/sms_aql' +require 'mauve/notifiers/xmpp' + diff --git a/lib/mauve/notifiers/debug.rb b/lib/mauve/notifiers/debug.rb new file mode 100644 index 0000000..889a428 --- /dev/null +++ b/lib/mauve/notifiers/debug.rb @@ -0,0 +1,68 @@ +require 'fileutils' + +module Mauve + module Notifiers + # + # The Debug module adds two extra parameters to a notification method + # for debugging and testing. + # + module Debug + class << self + def included(base) + base.class_eval do + alias_method :send_alert_without_debug, :send_alert + alias_method :send_alert, :send_alert_to_debug_channels + + # Specifying deliver_to_file allows the administrator to ask for alerts + # to be delivered to a particular file, which is assumed to be perused + # by a person rather than a machine. + # + attr :deliver_to_file, true + + # Specifying deliver_to_queue allows a tester to ask for the send_alert + # parameters to be appended to a Queue object (or anything else that + # responds to <<). + # + attr :deliver_to_queue, true + end + end + end + + def disable_normal_delivery! + @disable_normal_delivery = true + end + + def send_alert_to_debug_channels(destination, alert, all_alerts, conditions = nil) + message = if respond_to?(:prepare_message) + prepare_message(destination, alert, all_alerts, conditions) + else + [destination, alert, all_alerts].inspect + end + + if deliver_to_file + #lock_file = "#{deliver_to_file}.lock" + #while File.exists?(lock_file) + # sleep 0.1 + #end + #FileUtils.touch(lock_file) + File.open("#{deliver_to_file}", "a+") do |fh| + fh.flock(File::LOCK_EX) + fh.print("#{MauveTime.now} from #{self.class}: " + message + "\n") + fh.flush() + end + #FileUtils.rm(lock_file) + end + + deliver_to_queue << [destination, alert, all_alerts, conditions] if deliver_to_queue + + if @disable_normal_delivery + true # pretend it happened OK if we're just testing + else + send_alert_without_debug(destination, alert, all_alerts, conditions) + end + end + + end + end +end + diff --git a/lib/mauve/notifiers/email.rb b/lib/mauve/notifiers/email.rb new file mode 100644 index 0000000..c445e09 --- /dev/null +++ b/lib/mauve/notifiers/email.rb @@ -0,0 +1,138 @@ +require 'time' +require 'net/smtp' +require 'rmail' +require 'mauve/notifiers/debug' + +module Mauve + module Notifiers + module Email + + + class Default + attr_reader :name + attr :server, true + attr :port, true + attr :username, true + attr :password, true + attr :login_method, true + attr :from, true + attr :subject_prefix, true + attr :email_suffix, true + + def username=(username) + @login_method ||= :plain + @username = username + end + + def initialize(name) + @name = name + @server = '127.0.0.1' + @port = 25 + @username = nil + @password = nil + @login_method = nil + @from = "mauve@localhost" + @hostname = "localhost" + @signature = "This is an automatic mailing, please do not reply." + @subject_prefix = "" + @suppressed_changed = nil + end + + def send_alert(destination, alert, all_alerts, conditions = nil) + message = prepare_message(destination, alert, all_alerts, conditions) + args = [@server, @port] + args += [@username, @password, @login_method.to_sym] if @login_method + begin + Net::SMTP.start(*args) do |smtp| + smtp.send_message(message, @from, destination) + end + rescue Errno::ECONNREFUSED => e + @logger = Log4r::Logger.new "mauve::email_send_alert" + @logger.error("#{e.class}: #{e.message} raised. " + + "args = #{args.inspect} " + ) + raise e + rescue => e + raise e + end + end + + protected + + def prepare_message(destination, alert, all_alerts, conditions = nil) + if conditions + @suppressed_changed = conditions[:suppressed_changed] + end + + other_alerts = all_alerts - [alert] + + m = RMail::Message.new + + m.header.subject = subject_prefix + + case @suppressed_changed + when true + "Suppressing notifications (#{all_alerts.length} total)" + + else + alert.summary_one_line.to_s + end + m.header.to = destination + m.header.from = @from + m.header.date = MauveTime.now + + summary_formatted = "" +# summary_formatted = " * "+alert.summary_two_lines.join("\n ") + + case alert.update_type.to_sym + when :cleared + m.body = "An alert has been cleared:\n"+summary_formatted+"\n\n" + when :raised + m.body = "An alert has been raised:\n"+summary_formatted+"\n\n" + when :acknowledged + m.body = "An alert has been acknowledged by #{alert.acknowledged_by}:\n"+summary_formatted+"\n\n" + when :changed + m.body = "An alert has changed in nature:\n"+summary_formatted+"\n\n" + else + raise ArgumentError.new("Unknown update_type #{alert.update_type}") + end + + # FIXME: include alert.detail as multipart mime + ##Thread.abort_on_exception = true + m.body += "\n" + '-'*10 + " This is the detail field " + '-'*44 + "\n\n" + #m.body += alert.get_details() + #m.body += alert.get_details_plain_text() + m.body += "\n" + '-'*80 + "\n\n" + + if @suppressed_changed == true + m.body += <<-END +IMPORTANT: I've been configured to suppress notification of individual changes +to alerts until their rate decreases. If you still need notification of evrey +single alert, you must watch the web front-end instead. + + END + elsif @suppressed_changed == false + m.body += "(Notifications have slowed down - you will now be notified of every change)\n\n" + end + + if other_alerts.empty? + m.body += (alert.update_type == :cleared ? "That was" : "This is")+ + " currently the only alert outstanding\n\n" + else + m.body += other_alerts.length == 1 ? + "There is currently one other alert outstanding:\n\n" : + "There are currently #{other_alerts.length} other alerts outstanding:\n\n" + +# other_alerts.each do |other| +# m.body += " * "+other.summary_two_lines.join("\n ")+"\n\n" +# end + end + + m.body += @email_suffix + + m.to_s + end + include Debug + end + end + end +end diff --git a/lib/mauve/notifiers/sms_aql.rb b/lib/mauve/notifiers/sms_aql.rb new file mode 100644 index 0000000..1cf8c04 --- /dev/null +++ b/lib/mauve/notifiers/sms_aql.rb @@ -0,0 +1,90 @@ +require 'mauve/notifiers/debug' +require 'cgi' + +module Mauve + module Notifiers + module Sms + + require 'net/https' + class AQL + GATEWAY = "https://gw1.aql.com/sms/sms_gw.php" + + attr :username, true + attr :password, true + attr :from, true + attr :max_messages_per_alert, true + attr_reader :name + + def initialize(name) + @name = name + end + + def send_alert(destination, alert, all_alerts, conditions = nil) + uri = URI.parse(GATEWAY) + + opts_string = { + :username => @username, + :password => @password, + :destination => normalize_number(destination), + :message => prepare_message(destination, alert, all_alerts, conditions), + :originator => @from, + :flash => @flash ? 1 : 0 + }.map { |k,v| "#{k}=#{CGI::escape(v.to_s)}" }.join("&") + + http = Net::HTTP.new(uri.host, uri.port) + if uri.port == 443 + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + response, data = http.post(uri.path, opts_string, { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Content-Length' => opts_string.length.to_s + }) + + raise response unless response.kind_of?(Net::HTTPSuccess) + end + + protected + def prepare_message(destination, alert, all_alerts, conditions=nil) + if conditions + @suppressed_changed = conditions[:suppressed_changed] + end + + txt = case @suppressed_changed + when true then "TOO MUCH NOISE! Last notification: " + when false then "BACK TO NORMAL: " + else + "" + end + + txt += "#{alert.update_type.upcase}: " + txt += alert.summary_one_line + + others = all_alerts-[alert] + if !others.empty? + txt += (1 == others.length)? + "and a lone other." : + "and #{others.length} others." + #txt += "and #{others.length} others: " + #txt += others.map { |alert| alert.summary_one_line }.join(", ") + end + + txt += "link: https://alert.bytemark.co.uk/alerts" + + ## @TODO: Add a link to acknowledge the alert in the text? + #txt += "Acknoweledge alert: "+ + # "https://alert.bytemark.co.uk/alert/acknowledge/"+ + # "#{alert.id}/#{alert.get_default_acknowledge_time} + + txt + end + + def normalize_number(n) + n.split("").select { |s| (?0..?9).include?(s[0]) }.join.gsub(/^0/, "44") + end + include Debug + end + end + end +end + diff --git a/lib/mauve/notifiers/sms_default.rb b/lib/mauve/notifiers/sms_default.rb new file mode 100644 index 0000000..5afeedd --- /dev/null +++ b/lib/mauve/notifiers/sms_default.rb @@ -0,0 +1,12 @@ +module Mauve + module Notifiers + module Sms + class Default + def initialize(*args) + raise ArgumentError.new("No default SMS provider, you must use the provider command to select one") + end + end + end + end +end + diff --git a/lib/mauve/notifiers/templates/email.html.erb b/lib/mauve/notifiers/templates/email.html.erb new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/mauve/notifiers/templates/email.html.erb diff --git a/lib/mauve/notifiers/templates/email.txt.erb b/lib/mauve/notifiers/templates/email.txt.erb new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/mauve/notifiers/templates/email.txt.erb diff --git a/lib/mauve/notifiers/templates/sms.txt.erb b/lib/mauve/notifiers/templates/sms.txt.erb new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/mauve/notifiers/templates/sms.txt.erb diff --git a/lib/mauve/notifiers/templates/xmpp.html.erb b/lib/mauve/notifiers/templates/xmpp.html.erb new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/mauve/notifiers/templates/xmpp.html.erb diff --git a/lib/mauve/notifiers/templates/xmpp.txt.erb b/lib/mauve/notifiers/templates/xmpp.txt.erb new file mode 100644 index 0000000..881c197 --- /dev/null +++ b/lib/mauve/notifiers/templates/xmpp.txt.erb @@ -0,0 +1 @@ +<%=arse %> diff --git a/lib/mauve/notifiers/xmpp-smack.rb b/lib/mauve/notifiers/xmpp-smack.rb new file mode 100644 index 0000000..a160a35 --- /dev/null +++ b/lib/mauve/notifiers/xmpp-smack.rb @@ -0,0 +1,395 @@ +# encoding: utf-8 + +# Ruby. +require 'pp' +require 'log4r' +require 'monitor' + +# Java. Note that paths are mangeled in jmauve_starter. +require 'java' +require 'smack.jar' +require 'smackx.jar' +include_class "org.jivesoftware.smack.XMPPConnection" +include_class "org.jivesoftware.smackx.muc.MultiUserChat" +include_class "org.jivesoftware.smack.RosterListener" + +module Mauve + + module Notifiers + + module Xmpp + + class XMPPSmackException < StandardError + end + + ## Main wrapper to smack java library. + # + # @author Yann Golanski + # @see http://www.igniterealtime.org/builds/smack/docs/3.1.0/javadoc/ + # + # This is a singleton which is not idea but works well for mauve's + # configuration file set up. + # + # In general, this class is meant to be intialized then the method + # create_slave_thread must be called. The latter will spawn a new + # thread that will do the connecting and sending of messages to + # the XMPP server. Once this is done, messages can be send via the + # send_msg() method. Those will be queued and depending on the load, + # should be send quickly enough. This is done so that the main thread + # can not worry about sending messages and can do important work. + # + # @example + # bot = Mauve::Notifiers::Xmpp::XMPPSmack.new() + # bot.run_slave_thread("chat.bytemark.co.uk", 'mauvealert', 'TopSecret') + # msg = "What fresh hell is this? -- Dorothy Parker." + # bot.send_msg("yann@chat.bytemark.co.uk", msg) + # bot.send_msg("muc:test@conference.chat.bytemark.co.uk", msg) + # + # @FIXME This won't quiet work with how mauve is set up. + # + class XMPPSmack + + # Globals are evil. + @@instance = nil + + # Default constructor. + # + # A queue (@queue) is used to pass information between master/slave. + def initialize () + extend(MonitorMixin) + @logger = Log4r::Logger.new "mauve::XMPP_smack<#{Process.pid}>" + @queue = Queue.new + @xmpp = nil + @name = "mauve alert" + @slave_thread = nil + @regexp_muc = Regexp.compile(/^muc\:/) + @regexp_tail = Regexp.compile(/\/.*$/) + @jid_created_chat = Hash.new() + @separator = '<->' + @logger.info("Created XMPPSmack singleton") + end + + # Returns the instance of the XMPPSmack singleton. + # + # @param [String] login The JID as a full address. + # @param [String] pwd The password corresponding to the JID. + # @return [XMPPSmack] The singleton instance. + def self.instance (login, pwd) + if true == @@instance.nil? + @@instance = XMPPSmack.new + jid, tmp = login.split(/@/) + srv, name = tmp.split(/\//) + name = "Mauve Alert Bot" if true == name.nil? + @@instance.run_slave_thread(srv, jid, pwd, name) + sleep 5 # FIXME: This really should be synced... But how? + end + return @@instance + end + + # Create the thread that sends messages to the server. + # + # @param [String] srv The server address. + # @param [String] jid The JID. + # @param [String] pwd The password corresponding to the JID. + # @param [String] name The bot name. + # @return [NULL] nada + def run_slave_thread (srv, jid, pwd, name) + @srv = srv + @jid = jid + @pwd = pwd + @name = name + @logger.info("Creating slave thread on #{@jid}@#{@srv}/#{@name}.") + @slave_thread = Thread.new do + self.create_slave_thread() + end + return nil + end + + # Returns whether instance is connected and authenticated. + # + # @return [Boolean] True or false. + def is_connected_and_authenticated? () + return false if true == @xmpp.nil? + return (@xmpp.isConnected() and @xmpp.isAuthenticated()) + end + + # Creates the thread that does the actual sending to XMPP. + # @return [NULL] nada + def create_slave_thread () + begin + @logger.info("Slave thread is now alive.") + self.open() + loop do + rcp, msg = @queue.deq().split(@separator, 2) + @logger.debug("New message for '#{rcp}' saying '#{msg}'.") + if rcp.match(@regexp_muc) + room = rcp.gsub(@regexp_muc, '').gsub(@regexp_tail, '') + self.send_to_muc(room, msg) + else + self.send_to_jid(rcp, msg) + end + end + rescue XMPPSmackException + @logger.fatal("Something is wrong") + ensure + @logger.info("XMPP bot disconnect.") + @xmpp.disconnect() + end + return nil + end + + # Send a message to the recipient. + # + # @param [String] rcp The recipent MUC or JID. + # @param [String] msg The message. + # @return [NULL] nada + def send_msg(rcp, msg) + #if @slave_thread.nil? or not self.is_connected_and_authenticated?() + # str = "There is either no slave thread running or a disconnect..." + # @logger.warn(str) + # self.reconnect() + #end + @queue.enq(rcp + @separator + msg) + return nil + end + + # Sends a message to a room. + # + # @param [String] room The name of the room. + # @param [String] mgs The message to send. + # @return [NULL] nada + def send_to_muc (room, msg) + if not @jid_created_chat.has_key?(room) + @jid_created_chat[room] = MultiUserChat.new(@xmpp, room) + @jid_created_chat[room].join(@name) + end + @logger.debug("Sending to MUC '#{room}' message '#{msg}'.") + @jid_created_chat[room].sendMessage(msg) + return nil + end + + # Sends a message to a jid. + # + # Do not destroy the chat, we can reuse it when the user log back in again. + # Maybe? + # + # @param [String] jid The JID of the recipient. + # @param [String] mgs The message to send. + # @return [NULL] nada + def send_to_jid (jid, msg) + if true == jid_is_available?(jid) + if not @jid_created_chat.has_key?(jid) + @jid_created_chat[jid] = @xmpp.getChatManager.createChat(jid, nil) + end + @logger.debug("Sending to JID '#{jid}' message '#{msg}'.") + @jid_created_chat[jid].sendMessage(msg) + end + return nil + end + + # Check to see if the jid is available or not. + # + # @param [String] jid The JID of the recipient. + # @return [Boolean] Whether we can send a message or not. + def jid_is_available?(jid) + if true == @xmpp.getRoster().getPresence(jid).isAvailable() + @logger.debug("#{jid} is available. Status is " + + "#{@xmpp.getRoster().getPresence(jid).getStatus()}") + return true + else + @logger.warn("#{jid} is not available. Status is " + + "#{@xmpp.getRoster().getPresence(jid).getStatus()}") + return false + end + end + + # Opens a connection to the xmpp server at given port. + # + # @return [NULL] nada + def open() + @logger.info("XMPP bot is being created.") + self.open_connection() + self.open_authentication() + self.create_roster() + sleep 5 + return nil + end + + # Connect to server. + # + # @return [NULL] nada + def open_connection() + @xmpp = XMPPConnection.new(@srv) + if false == self.connect() + str = "Connection refused" + @logger.error(str) + raise XMPPSmackException.new(str) + end + @logger.debug("XMPP bot connected successfully.") + return nil + end + + # Authenticat connection. + # + # @return [NULL] nada + def open_authentication() + if false == self.login(@jid, @pwd) + str = "Authentication failed" + @logger.error(str) + raise XMPPSmackException.new(str) + end + @logger.debug("XMPP bot authenticated successfully.") + return nil + end + + # Create a new roster and listener. + # + # @return [NULL] nada + def create_roster + @xmpp.getRoster().addRosterListener(RosterListener.new()) + @xmpp.getRoster().reload() + @xmpp.getRoster().getPresence(@xmpp.getUser).setStatus( + "Purple alert! Purple alert!") + @logger.debug("XMPP bot roster aquired successfully.") + return nil + end + + # Connects to the server. + # + # @return [Boolean] true (aka sucess) or false (aka failure). + def connect () + @xmpp.connect() + return @xmpp.isConnected() + end + + # Login onto the server. + # + # @param [String] jid The JID. + # @param [String] pwd The password corresponding to the JID. + # @return [Boolean] true (aka sucess) or false (aka failure). + def login (jid, pwd) + @xmpp.login(jid, pwd, @name) + return @xmpp.isAuthenticated() + end + + # Reconnects in case of errors. + # + # @return [NULL] nada + def reconnect() + @xmpp.disconnect + @slave_thread = Thread.new do + self.create_slave_thread() + end + return nil + end + + def presenceChanged () + end + + end # XMPPSmack + + + ## This is the class that gets called in person.rb. + # + # This class is a wrapper to XMPPSmack which does the hard work. It is + # done this way to conform to the mauve configuration file way of + # defining notifications. + # + # @author Yann Golanski + class Default + + # Name of the class. + attr_reader :name + + # Atrtribute. + attr_accessor :jid + + # Atrtribute. + attr_accessor :password + + # Atrtribute. + attr_accessor :initial_jid + + # Atrtribute. + attr_accessor :initial_messages + + # Default constructor. + # + # @param [String] name The name of the notifier. + def initialize (name) + extend(MonitorMixin) + @name = name + @logger = Log4r::Logger.new "mauve::XMPP_default<#{Process.pid}>" + end + + # Sends a message to the relevant jid or muc. + # + # We have no way to know if a messages was recieved, only that + # we send it. + # + # @param [String] destionation + # @param [Alert] alert A mauve alert class + # @param [Array] all_alerts subset of current alerts + # @param [Hash] conditions Supported conditions, see above. + # @return [Boolean] Whether a message can be send or not. + def send_alert(destination, alert, all_alerts, conditions = nil) + synchronize { + client = XMPPSmack.instance(@jid, @password) + if not destination.match(/^muc:/) + if false == client.jid_is_available?(destination.gsub(/^muc:/, '')) + return false + end + end + client.send_msg(destination, convert_alert_to_message(alert)) + return true + } + end + + # Takes an alert and converts it into a message. + # + # @param [Alert] alert The alert to convert. + # @return [String] The message, either as HTML. + def convert_alert_to_message(alert) + arr = alert.summary_three_lines + str = arr[0] + ": " + arr[1] + str += " -- " + arr[2] if false == arr[2].nil? + str += "." + return str + #return alert.summary_two_lines.join(" -- ") + #return "<p>" + alert.summary_two_lines.join("<br />") + "</p>" + end + + # This is so unit tests can run fine. + include Debug + + end # Default + + end + end +end + +# This is a simple example of usage. Run with: +# ../../../jmauve_starter.rb xmpp-smack.rb +# Clearly, the mauve jabber password is not correct. +# +# /!\ WARNING: DO NOT COMMIT THE REAL PASSWORD TO MERCURIAL!!! +# +def send_msg() + bot = Mauve::Notifiers::Xmpp::XMPPSmack.instance( + "mauvealert@chat.bytemark.co.uk/testing1234", '') + msg = "What fresh hell is this? -- Dorothy Parker." + bot.send_msg("yann@chat.bytemark.co.uk", msg) + bot.send_msg("muc:test@conference.chat.bytemark.co.uk", msg) + sleep 2 +end + +if __FILE__ == './'+$0 + Thread.abort_on_exception = true + logger = Log4r::Logger.new('mauve') + logger.level = Log4r::DEBUG + logger.add Log4r::Outputter.stdout + send_msg() + send_msg() + logger.info("START") + logger.info("END") +end diff --git a/lib/mauve/notifiers/xmpp.rb b/lib/mauve/notifiers/xmpp.rb new file mode 100644 index 0000000..d216788 --- /dev/null +++ b/lib/mauve/notifiers/xmpp.rb @@ -0,0 +1,296 @@ +require 'log4r' +require 'xmpp4r' +require 'xmpp4r/xhtml' +require 'xmpp4r/roster' +require 'xmpp4r/muc/helper/simplemucclient' +require 'mauve/notifiers/debug' +#Jabber::debug = true + +module Mauve + module Notifiers + module Xmpp + + class CountingMUCClient < Jabber::MUC::SimpleMUCClient + + attr_reader :participants + + def initialize(*a) + super(*a) + @participants = 0 + self.on_join { @participants += 1 } + self.on_leave { @participants -= 1 } + end + + end + + class Default + + include Jabber + + # Atrtribute. + attr_reader :name + + # Atrtribute. + attr_accessor :jid, :password + + # Atrtribute. + attr_accessor :initial_jid + + # Atrtribute. + attr_accessor :initial_messages + + def initialize(name) + @name = name + @mucs = {} + @roster = nil + end + + def logger + @logger ||= Log4r::Logger.new self.class.to_s + end + + def reconnect + if @client + begin + logger.debug "Jabber closing old client connection" + @client.close + @client = nil + @roster = nil + rescue Exception => ex + logger.error "#{ex} when reconnecting" + end + end + + logger.debug "Jabber starting connection to #{@jid}" + @client = Client.new(JID::new(@jid)) + @client.connect + logger.debug "Jabber authentication" + + @client.auth_nonsasl(@password, false) + @roster = Roster::Helper.new(@client) + + # Unconditionally accept all roster add requests, and respond with a + # roster add + subscription request of our own if we're not subscribed + # already + @roster.add_subscription_request_callback do |ri, stanza| + Thread.new do + logger.debug("Accepting subscription request from #{stanza.from}") + @roster.accept_subscription(stanza.from) + ensure_roster_and_subscription!(stanza.from) + end.join + end + + @roster.wait_for_roster + logger.debug "Jabber authenticated, setting presence" + + @client.send(Presence.new.set_type(:available)) + @mucs = {} + + logger.debug "Jabber is ready in theory" + end + + def reconnect_and_retry_on_error + @already_reconnected = false + begin + yield + rescue StandardError => ex + logger.error "#{ex} during notification\n" + logger.debug ex.backtrace + if !@already_reconnected + reconnect + @already_reconnected = true + retry + else + raise ex + end + end + end + + def connect + self.reconnect_and_retry_on_error { self.send_msg(@initial_jid, "Hello!") } + end + + def close + self.send_msg(@initial_jid, "Goodbye!") + @client.close + end + + # Takes an alert and converts it into a message. + # + # @param [Alert] alert The alert to convert. + # @return [String] The message, either as HTML. + def convert_alert_to_message(alert) + arr = alert.summary_three_lines + str = arr[0] + ": " + arr[1] + str += " -- " + arr[2] if false == arr[2].nil? + str += "." + return str + #return alert.summary_two_lines.join(" -- ") + #return "<p>" + alert.summary_two_lines.join("<br />") + "</p>" + end + + # Attempt to send an alert using XMPP. + # +destination+ is the JID you're sending the alert to. This should be + # a bare JID in the case of an individual, or muc:<room>@<server> for + # chatrooms (XEP0045). The +alert+ object is turned into a pretty + # message and sent to the destination as a message, if the +conditions+ + # are met. all_alerts are currently ignored. + # + # The only suported condition at the moment is :if_presence => [choices] + # which checks whether the jid in question has a presence matching one + # or more of the choices - see +check_jid_has_presence+ for options. + + def send_alert(destination, alert, all_alerts, conditions = nil) + #message = Message.new(nil, alert.summary_two_lines.join("\n")) + message = Message.new(nil, convert_alert_to_message(alert)) + + if conditions + @suppressed_changed = conditions[:suppressed_changed] + end + + # MUC JIDs are prefixed with muc: - we need to strip this out. + destination_is_muc, dest_jid = self.is_muc?(destination) + + begin + xhtml = XHTML::HTML.new("<p>" + + convert_alert_to_message(alert)+ +# alert.summary_three_lines.join("<br />") + + #alert.summary_two_lines.join("<br />") + + "</p>") + message.add_element(xhtml) + rescue REXML::ParseException => ex + logger.warn("Can't send XMPP alert as valid XHTML-IM, falling back to plaintext") + logger.debug(ex) + end + + logger.debug "Jabber sending #{message} to #{destination}" + reconnect unless @client + + ensure_roster_and_subscription!(dest_jid) unless destination_is_muc + + if conditions && !check_alert_conditions(dest_jid, conditions) + logger.debug("Alert conditions not met, not sending XMPP alert to #{jid}") + return false + end + + if destination_is_muc + if !@mucs[dest_jid] + @mucs[dest_jid] = CountingMUCClient.new(@client) + @mucs[dest_jid].join(JID.new(dest_jid)) + end + reconnect_and_retry_on_error { @mucs[dest_jid].send(message, nil) ; true } + else + message.to = dest_jid + reconnect_and_retry_on_error { @client.send(message) ; true } + end + end + + # Sends a message to the destionation. + # + # @param [String] destionation The (full) JID to send to. + # @param [String] msg The (formatted) message to send. + # @return [NIL] nada. + def send_msg(destination, msg) + reconnect unless @client + message = Message.new(nil, msg) + destination_is_muc, dest_jid = self.is_muc?(destination) + if destination_is_muc + if !@mucs[dest_jid] + @mucs[dest_jid] = CountingMUCClient.new(@client) + @mucs[dest_jid].join(JID.new(dest_jid)) + end + reconnect_and_retry_on_error { @mucs[dest_jid].send(message, nil) ; true } + else + message.to = dest_jid + reconnect_and_retry_on_error { @client.send(message) ; true } + end + return nil + end + + protected + + # Checks whether the destination JID is a MUC. + # Returns [true/false, destination] + def is_muc?(destination) + if /^muc:(.*)/.match(destination) + [true, $1] + else + [false, destination] + end + end + + # Checks to see if the JID is in our roster, and whether we are + # subscribed to it or not. Will add to the roster and subscribe as + # is necessary to ensure both are true. + def ensure_roster_and_subscription!(jid) + jid = JID.new(jid) + ri = @roster.find(jid)[jid] + if ri.nil? + @roster.add(jid, nil, true) + else + ri.subscribe unless [:to, :both, :remove].include?(ri.subscription) + end + rescue Exception => ex + logger.error("Problem ensuring that #{jid} is subscribed and in mauve's roster: #{ex.inspect}") + end + + def check_alert_conditions(destination, conditions) + any_failed = conditions.keys.collect do |key| + case key + when :if_presence : check_jid_has_presence(destination, conditions[:if_presence]) + else + #raise ArgumentError.new("Unknown alert condition, #{key} => #{conditions[key]}") + # FIXME - clean up this use of :conditions to pass arbitrary + # parameters to notifiers; for now we need to ignore this. + true + end + end.include?(false) + !any_failed + end + + # Checks our roster to see whether the jid has a resource with at least + # one of the included presences. Acceptable +presence+ types and their + # meanings for individuals: + # + # :online, :offline - user is logged in or out + # :available - jabber status is nil (available) or chat + # :unavailable - - jabber status is away, dnd or xa + # :unknown - don't know (not in roster) + # + # For MUCs: TODO + # Returns true if at least one of the presence specifiers for the jid + # is met, false otherwise. Note that if the alerter can't see the alertee's + # presence, only 'unknown' will match - generally, you'll want [:online, :unknown] + def check_jid_has_presence(jid, presence_or_presences) + return true if jid.match(/^muc:/) + + reconnect unless @client + + presences = [presence_or_presences].flatten + roster_item = @roster.find(jid) + roster_item = roster_item[roster_item.keys[0]] + resource_presences = [] + roster_item.each_presence {|p| resource_presences << p.show } if roster_item + + results = presences.collect do |need_presence| + case need_presence + when :online : (roster_item && [:to, :both].include?(roster_item.subscription) && roster_item.online?) + when :offline : (roster_item && [:to, :both].include?(roster_item.subscription) && !roster_item.online?) + when :available : (roster_item && [:to, :both].include?(roster_item.subscription) && (resource_presences.include?(nil) || + resource_presences.include?(:chat))) + # No resources are nil or chat + when :unavailable : (roster_item && [:to, :both].include?(roster_item.subscription) && (resource_presences - [:away, :dnd, :xa]).empty?) + # Not in roster or don't know subscription + when :unknown : (roster_item.nil? || [:none, :from].include?(roster_item.subscription)) + else + raise ArgumentError.new("Unknown presence possibility: #{need_presence}") + end + end + results.include?(true) + end + + end + end + end +end + diff --git a/lib/mauve/people_list.rb b/lib/mauve/people_list.rb new file mode 100644 index 0000000..2e4c737 --- /dev/null +++ b/lib/mauve/people_list.rb @@ -0,0 +1,44 @@ +# encoding: UTF-8 +require 'log4r' +require 'mauve/calendar_interface' + +module Mauve + + # Stores a list of name. + # + # @author Yann Golanski + class PeopleList < Struct.new(:label, :list) + + # Default contrustor. + def initialize (*args) + super(*args) + end + + def label + self[:label] + end + + alias username label + + def list + self[:list] + end + + # + # Set up the logger + def logger + @logger ||= Log4r::Logger.new self.class + end + + # + # Return the array of people + # + def people + list.collect do |name| + Configuration.current.people.has_key?(name) ? Configuration.current.people[name] : nil + end.reject{|person| person.nil?} + end + + end + +end diff --git a/lib/mauve/person.rb b/lib/mauve/person.rb new file mode 100644 index 0000000..6e9fcb4 --- /dev/null +++ b/lib/mauve/person.rb @@ -0,0 +1,230 @@ +# encoding: UTF-8 +require 'timeout' +require 'log4r' + +module Mauve + class Person < Struct.new(:username, :password, :holiday_url, :urgent, :normal, :low) + + attr_reader :notification_thresholds + + def initialize(*args) + @notification_thresholds = { 60 => Array.new(10) } + @suppressed = false + super(*args) + end + + def logger ; @logger ||= Log4r::Logger.new self.class.to_s ; end + + def suppressed? + @suppressed + end + + # This class implements an instance_eval context to execute the blocks + # for running a notification block for each person. + # + class NotificationCaller + + def initialize(alert, other_alerts, notification_methods, base_conditions={}) + logger = Log4r::Logger.new "mauve::NotificationCaller" + @alert = alert + @other_alerts = other_alerts + @notification_methods = notification_methods + @base_conditions = base_conditions + end + + def method_missing(name, destination, *args) + conditions = @base_conditions.merge(args[0] ? args[0] : {}) + notification_method = @notification_methods[name.to_s] + + unless notification_method + raise NoMethodError.new("#{name} not defined as a notification method") + end + # Methods are expected to return true or false so the user can chain + # them together with || as fallbacks. So we have to catch exceptions + # and turn them into false. + # + notification_method.send_alert(destination, @alert, @other_alerts, conditions) + end + + end + + ## Deals with changes in an alert. + # + # == Old comments by Matthew. + # + # An AlertGroup tells a Person that an alert has changed. Within + # this alert group, the alert may or may not be "relevant" to this + # person, but it is ultimately up to the Person to decide whether to + # send a notification. (i.e. notification of acks/clears should + # always go out to a Person who was notified of the original alert, + # even if the alert is no longer relevant to them). + # + # == New comment + # + # The old code works like this: An alert arrives, with a relevance. An + # AlertChanged is created and the alert may or may not be send. The + # problem is that alerts can be relevant AFTER the initial raise and this + # code (due to AlertChange.was_relevant_when_raised?()) will ignore it. + # This is wrong. + # + # + # The Thread.exclusive wrapper around the AlertChanged creation makes + # sure that two AlertChanged are not created at the same time. This + # caused both instances to set the remind_at time of the other to nil. + # Thus reminders were never seen which is clearly wrong. This bug was + # only showing on jruby due to green threads in MRI. + # + # + # @author Matthew Bloch, Yann Golanski + # @param [symb] level Level of the alert. + # @param [Alert] alert An alert object. + # @param [Boolean] Whether the alert is relevant as defined by notification + # class. + # @param [MauveTime] When to send remind. + # @return [NULL] nada + def alert_changed(level, alert, is_relevant=true, remind_at=nil) + # User should get notified but will not since on holiday. + str = String.new +# if is_on_holiday? +# is_relevant = false +# str = ' (user on holiday)' +# end + + # Deals with AlertChange database entry. + last_change = AlertChanged.first(:alert_id => alert.id, :person => username) + if not last_change.nil? + if not last_change.remind_at.nil? and not remind_at.nil? + if last_change.remind_at.to_time < remind_at + remind_at = last_change.remind_at.to_time + end + end + end + + new_change = AlertChanged.create( + :level => level.to_s, + :alert_id => alert.id, + :at => MauveTime.now, + :person => username, + :update_type => alert.update_type, + :remind_at => remind_at, + :was_relevant => is_relevant) + + # We need to look at the AlertChanged objects to reset them to + # the right value. What is the right value? Well... + if true == is_relevant + last_change.was_relevant = true if false == last_change.nil? + end + + # Send the notification is need be. + if !last_change || last_change.update_type.to_sym == :cleared + # Person has never heard of this alert before, or previously cleared. + # + # We don't send any alert if such a change isn't relevant to this + # Person at this time. + send_alert(level, alert) if is_relevant and [:raised, :changed].include?(alert.update_type.to_sym) + + else + # Relevance is determined by whether the user heard of this alert + # being raised. + send_alert(level, alert) if last_change.was_relevant_when_raised? + end + end + end + + def remind(alert, level) + logger.debug("Reminder for #{alert} send at level #{level}.") + send_alert(level, alert) + end + + # + # This just wraps send_alert by sending the job to a queue. + # + def send_alert(level, alert) + Notifier.push([self, level, alert]) + end + + def do_send_alert(level, alert) + now = MauveTime.now + suppressed_changed = nil + threshold_breached = @notification_thresholds.any? do |period, previous_alert_times| + first = previous_alert_times.first + first.is_a?(MauveTime) and (now - first) < period + end + + this_alert_suppressed = false + + if Server.instance.started_at > alert.updated_at.to_time and (Server.instance.started_at + Server.instance.initial_sleep) > MauveTime.now + logger.warn("Alert last updated in prior run of mauve -- ignoring for initial sleep period.") + this_alert_suppressed = true + elsif threshold_breached + unless suppressed? + logger.warn("Suspending notifications to #{username} until further notice.") + suppressed_changed = true + end + @suppressed = true + else + if suppressed? + suppressed_changed = false + logger.warn "Starting to send notifications again for #{username}." + else + logger.info "Notifying #{username} of #{alert} at level #{level}" + end + @suppressed = false + end + + return if suppressed? or this_alert_suppressed + + result = NotificationCaller.new( + alert, + current_alerts, + Configuration.current.notification_methods, + :suppressed_changed => suppressed_changed + ).instance_eval(&__send__(level)) + + if result + # + # Remember that we've sent an alert + # + @notification_thresholds.each do |period, previous_alert_times| + @notification_thresholds[period].replace(previous_alert_times[1..period-1] + [now]) + end + + logger.info("Notification for #{username} of #{alert} at level #{level} has been successful") + else + logger.error("Failed to notify #{username} about #{alert} at level #{level}") + end + end + + # Returns the subset of current alerts that are relevant to this Person. + # + def current_alerts + Alert.all_current.select do |alert| + my_last_update = AlertChanged.first(:person => username, :alert_id => alert.id) + my_last_update && my_last_update.update_type != :cleared + end + end + + protected + # Remembers that an alert has been sent so that we can later check whether + # too many alerts have been sent in a particular period. + # + def remember_alert(now=MauveTime.now) + end + + # Returns time period over which "too many" alerts have been sent, or nil + # if none. + # + def threshold_breached(now=MauveTime.now) + end + + # Whether the person is on holiday or not. + # + # @return [Boolean] True if person on holiday, false otherwise. + def is_on_holiday? () + return false if true == holiday_url.nil? or '' == holiday_url + return CalendarInterface.is_user_on_holiday?(holiday_url, username) + end + + end + +end diff --git a/lib/mauve/processor.rb b/lib/mauve/processor.rb new file mode 100644 index 0000000..414f640 --- /dev/null +++ b/lib/mauve/processor.rb @@ -0,0 +1,109 @@ +# encoding: UTF-8 + +require 'mauve/mauve_thread' + +module Mauve + + class Processor < MauveThread + + include Singleton + + attr_accessor :buffer, :transmission_cache_expire_time, :sleep_interval + + def initialize + # Buffer for UDP socket packets. + @buffer = Queue.new + + # Set the logger up + @logger = Log4r::Logger.new(self.class.to_s) + + # + # Set up the transmission id cache + # + @transmission_id_cache = {} + @transmission_cache_expire_time = 300 + @sleep_interval = 1 + end + + def main_loop + + sz = @buffer.size + + return if sz == 0 + + Timer.instance.freeze + + logger.info("Buffer has #{sz} packets waiting...") + + triplets = [] + # + # Only do the loop a maximum of 10 times every @sleep_interval seconds + # + (sz > 20 ? 20 : sz).times do + triplets += @buffer.deq + end + + triplets.each do |data, client, received_at| + + @logger.debug("Got #{data.inspect} from #{client.inspect}") + + ip_source = "#{client[3]}:#{client[1]}" + update = Proto::AlertUpdate.new + + begin + update.parse_from_string(data) + + if @transmission_id_cache[update.transmission_id.to_s] + @logger.debug("Ignoring duplicate transmission id #{data.transmission_id}") + # + # Continue with next packet. + # + next + end + + @logger.debug "Update #{update.transmission_id} sent at #{update.transmission_time} from "+ + "'#{update.source}'@#{ip_source} alerts #{update.alert.length}" + + Alert.receive_update(update, received_at) + + rescue Protobuf::InvalidWireType, + NotImplementedError, + DataObjects::IntegrityError => ex + + @logger.error "#{ex} (#{ex.class}) while parsing #{data.length} bytes "+ + "starting '#{data[0..16].inspect}' from #{ip_source}" + + @logger.debug ex.backtrace.join("\n") + + ensure + @transmission_id_cache[update.transmission_id.to_s] = MauveTime.now + + end + end + Timer.instance.thaw + end + + def expire_transmission_id_cache + now = MauveTime.now + to_delete = [] + + @transmission_id_cache.each do |tid, received_at| + to_delete << tid if (now - received_at) > @transmission_cache_expire_time + end + + to_delete.each do |tid| + @transmission_id_cache.delete(tid) + end + end + + class << self + + def enq(a) + instance.buffer.enq(a) + end + + alias push enq + + end + end +end diff --git a/lib/mauve/proto.rb b/lib/mauve/proto.rb new file mode 100644 index 0000000..6c22609 --- /dev/null +++ b/lib/mauve/proto.rb @@ -0,0 +1,116 @@ +### Generated by rprotoc. DO NOT EDIT! +### <proto file: mauve.proto> +# package mauve.proto; +# +# // An alert is a notification of an event in your business, project or +# // enterprise for which someone might want to stop what they're doing and +# // attend to. +# // +# // Alerts +# // +# message Alert { +# // Every separate alert must have a unique Id attached. When sending a +# // repeated or altered alert, using the same alert id will overwrite +# // the previous settings. +# // +# required string id = 1; +# +# // The UNIX time at which this alert was or will be raised. If set to zero it +# // means 'this alert is assumed to be raised already'. +# // +# optional uint64 raise_time = 2; +# +# // The UNIX time at which this alert was or will be cleared. If set to zero +# // it means 'do not clear automatically'. Messages with clear times set before +# // alert times are not valid, and will be ignored. +# // +# optional uint64 clear_time = 3; +# +# // The subject is the name of the server/device/entity that is being alerted +# // about. If not supplied, assumed to be the same as source. +# // +# optional string subject = 4; +# +# // The summary is a summary of an alert (100 characters or less) that +# // can be fitted into a pager or SMS message, along with the source & subject. +# // +# optional string summary = 5; +# +# // The detail can be an arbitrary HTML fragment for display on suitable +# // devices giving fuller information about the alert. +# // +# optional string detail = 6; +# +# // The importance of this alert (relative to others from this source). Zero +# // is 'unspecified importance' which will use the server's default. +# // +# optional uint32 importance = 7; +# +# } +# +# // The AlertUpdate is the unit of communication from an alerting source; +# // it consists of one or more alerts, which can either replace, or supplement +# // the alert data for that source. +# // +# message AlertUpdate { +# // Random number with each transmission, so that destinations can easily +# // identify and discard duplicate transmissions that are inherent to the +# // protocol. +# // +# required uint64 transmission_id = 1; +# +# // The source of an alert represents the sender - each possible sender +# // should set this consistently (e.g. the name of the monitoring system +# // that is generating a particular class of alerts). +# // +# required string source = 2; +# +# // When set to true, signals that this update should completely replace +# // all current data for this source (so unlisted previous alerts are deemed +# // to be cleared). +# // +# required bool replace = 3 [ default = false ]; +# +# // Alert data follows +# // +# repeated Alert alert = 4; +# +# // Signature to authenticate this data - no scheme defined currently, maybe +# // SHA1(alert.raw + password) ? +# // +# optional bytes signature = 5; +# +# // The UNIX time at which the packet was sent by the server. +# // +# optional uint64 transmission_time = 8; +# } +# + +require 'protobuf/message/message' +require 'protobuf/message/enum' +require 'protobuf/message/service' +require 'protobuf/message/extend' + +module Mauve + module Proto + class Alert < ::Protobuf::Message + defined_in __FILE__ + required :string, :id, 1 + optional :uint64, :raise_time, 2 + optional :uint64, :clear_time, 3 + optional :string, :subject, 4 + optional :string, :summary, 5 + optional :string, :detail, 6 + optional :uint32, :importance, 7 + end + class AlertUpdate < ::Protobuf::Message + defined_in __FILE__ + required :uint64, :transmission_id, 1 + required :string, :source, 2 + required :bool, :replace, 3, :default => false + repeated :Alert, :alert, 4 + optional :bytes, :signature, 5 + optional :uint64, :transmission_time, 8 + end + end +end
\ No newline at end of file diff --git a/lib/mauve/sender.rb b/lib/mauve/sender.rb new file mode 100644 index 0000000..f27efe5 --- /dev/null +++ b/lib/mauve/sender.rb @@ -0,0 +1,94 @@ +# encoding: UTF-8 +require 'ipaddr' +require 'resolv' +require 'socket' +require 'mauve/mauve_time' + +module Mauve + class Sender + DEFAULT_PORT = 32741 + include Resolv::DNS::Resource::IN + + def initialize(*destinations) + destinations = destinations.flatten + + destinations = begin + File.read("/etc/mauvealert/mauvesend.destination").split(/\s+/) + rescue Errno::ENOENT => notfound + [] + end if destinations.empty? + + if !destinations || destinations.empty? + raise ArgumentError.new("No destinations specified, and could not read any destinations from /etc/mauvealert/mauvesend.destination") + end + + @destinations = destinations.map do |spec| + next_spec = begin + # FIXME: for IPv6 + port = spec.split(":")[1] || DEFAULT_PORT + IPAddr.new(spec.split(":")[0]) + ["#{spec}:#{port}"] + rescue ArgumentError => not_an_ip_address + Resolv::DNS.open do |dns| + srv_spec = spec[0] == ?_ ? spec : "_mauvealert._udp.#{spec}" + list = dns.getresources(srv_spec, SRV).map do |srv| + srv.target.to_s + ":#{srv.port}" + end + list = [spec] if list.empty? + list.map do |spec2| + spec2_addr, spec2_port = spec2.split(":") + spec2_port ||= DEFAULT_PORT + dns.getresources(spec2_addr, A).map do |a| + "#{a.address}:#{spec2_port}" + end + end + end + end.flatten + + error "Can't resolve destination #{spec}" if next_spec.empty? + + next_spec + end. + flatten. + uniq + end + + def send(update, verbose=0) + + # + # Must have a source, so default to hostname if user doesn't care + update.source ||= `hostname -f`.chomp + + # + # Make sure all alerts default to "-r now" + # + update.alert.each do |alert| + next if alert.raise_time || alert.clear_time + alert.raise_time = MauveTime.now.to_i + end + + # + # Make sure we set the transmission time + # + update.transmission_time = MauveTime.now.to_i + + data = update.serialize_to_string + + + if verbose == 1 + print "#{update.transmission_id}\n" + elsif verbose >= 2 + print "Sending #{update.inspect.chomp} to #{@destinations.join(", ")}\n" + end + + @destinations.each do |spec| + UDPSocket.open do |sock| + ip = spec.split(":")[0] + port = spec.split(":")[1].to_i + sock.send(data, 0, ip, port) + end + end + end + end +end + diff --git a/lib/mauve/server.rb b/lib/mauve/server.rb new file mode 100644 index 0000000..30536bb --- /dev/null +++ b/lib/mauve/server.rb @@ -0,0 +1,142 @@ +# encoding: UTF-8 +require 'yaml' +require 'socket' +# require 'mauve/datamapper' +require 'mauve/proto' +require 'mauve/alert' +require 'mauve/mauve_thread' +require 'mauve/mauve_time' +require 'mauve/timer' +require 'mauve/udp_server' +require 'mauve/processor' +require 'mauve/http_server' +require 'log4r' + + +module Mauve + + class Server + + DEFAULT_CONFIGURATION = { + :ip => "127.0.0.1", + :port => 32741, + :database => "sqlite3:///./mauvealert.db", + :log_file => "stdout", + :log_level => 1, + :transmission_cache_expire_time => 600 + } + + + # + # This is the order in which the threads should be started. + # + THREAD_CLASSES = [UDPServer, HTTPServer, Processor, Notifier, Timer] + + attr_accessor :web_interface + attr_reader :stopped_at, :started_at, :initial_sleep + + include Singleton + + def initialize + # Set the logger up + @logger = Log4r::Logger.new(self.class.to_s) + + # Sleep time between pooling the @buffer buffer. + @sleep = 1 + + @freeze = false + @stop = false + + @stopped_at = MauveTime.now + @started_at = MauveTime.now + @initial_sleep = 300 + + @config = DEFAULT_CONFIGURATION + end + + def configure(config_spec = nil) + # + # Update the configuration + # + if config_spec.nil? + # Do nothing + elsif config_spec.kind_of?(String) and File.exists?(config_spec) + @config.update(YAML.load_file(config_spec)) + elsif config_spec.kind_of?(Hash) + @config.update(config_spec) + else + raise ArgumentError.new("Unknown configuration spec "+config_spec.inspect) + end + + # + DataMapper.setup(:default, @config[:database]) + DataObjects::Sqlite3.logger = Log4r::Logger.new("Mauve::DataMapper") + + # + # Update any tables. + # + Alert.auto_upgrade! + AlertChanged.auto_upgrade! + Mauve::AlertEarliestDate.create_view! + + # + # Work out when the server was last stopped + # + @stopped_at = self.last_heartbeat + end + + def last_heartbeat + # + # Work out when the last update was + # + [ Alert.last(:order => :updated_at.asc), + AlertChanged.last(:order => :updated_at.asc) ]. + reject{|a| a.nil?}. + collect{|a| a.updated_at.to_time}. + sort. + last + end + + def freeze + @frozen = true + end + + def thaw + @thaw = true + end + + def stop + @stop = true + + THREAD_CLASSES.reverse.each do |klass| + klass.instance.stop + end + + @logger.info("All threads stopped") + end + + def run + loop do + THREAD_CLASSES.each do |klass| + next if @frozen or @stop + + unless klass.instance.alive? + # ugh something has died. + klass.instance.join + klass.instance.start unless @stop + end + + end + + break if @stop + + sleep 1 + end + logger.debug("Thread stopped") + end + + alias start run + + end + +end diff --git a/lib/mauve/source_list.rb b/lib/mauve/source_list.rb new file mode 100644 index 0000000..4ffef15 --- /dev/null +++ b/lib/mauve/source_list.rb @@ -0,0 +1,105 @@ +# encoding: UTF-8 +require 'log4r' +require 'resolv-replace' + +module Mauve + + # A simple construct to match sources. + # + # This class stores mamed lists of IP addresses. It stores them in a hash + # indexed by the name of the list. One can pass IPv4, IPv6 and hostnames + # as list elements but they will all be converted into IP addresses at + # the time of the list creation. + # + # One can ask if an IPv4, IPv6, hostname or url (match on hostname only) is + # contained within a list. If the query is not an IP address, it will be + # converted into one before the checks are made. + # + # Note that the matching is greedy. When a hostname maps to several IP + # addresses and only one of tbhose is included in the list, a match + # will occure. + # + # @author Yann Golanski + class SourceList + + # Accessor, read only. Use create_new_list() to create lists. + attr_reader :hash + + ## Default contructor. + def initialize () + @logger = Log4r::Logger.new "mauve::SourceList" + @hash = Hash.new + @http_head = Regexp.compile(/^http[s]?:\/\//) + @http_tail = Regexp.compile(/\/.*$/) + end + + ## Return whether or not a list contains a source. + # + # @param [String] lst The list name. + # @param [String] src The hostname or IP of the source. + # @return [Boolean] true if there is such a source, false otherwise. + def does_list_contain_source?(lst, src) + raise ArgumentError.new("List name must be a String, not a #{lst.class}") if String != lst.class + raise ArgumentError.new("Source name must be a String, not a #{src.class}") if String != src.class + raise ArgumentError.new("List #{lst} does not exist.") if false == @hash.has_key?(lst) + if src.match(@http_head) + src = src.gsub(@http_head, '').gsub(@http_tail, '') + end + begin + Resolv.getaddresses(src).each do |ip| + return true if @hash[lst].include?(ip) + end + rescue Resolv::ResolvError, Resolv::ResolvMauveTimeout => e + @logger.warn("#{lst} could not be resolved because #{e.message}.") + return false + rescue => e + @logger.error("Unknown exception raised: #{e.class} #{e.message}") + return false + end + return false + end + + ## Create a list. + # + # Note that is no elements give IP addresses, we have an empty list. + # This gets logged but otherwise does not stop mauve from working. + # + # @param [String] name The name of the list. + # @param [Array] elem A list of source either hostname or IP. + def create_new_list(name, elem) + raise ArgumentError.new("Name of list is not a String but a #{name.class}") if String != name.class + raise ArgumentError.new("Element list is not an Array but a #{elem.class}") if Array != elem.class + raise ArgumentError.new("A list called #{name} already exists.") if @hash.has_key?(name) + arr = Array.new + elem.each do |host| + begin + Resolv.getaddresses(host).each do |ip| + arr << ip + end + rescue Resolv::ResolvError, Resolv::ResolvMauveTimeout => e + @logger.warn("#{host} could not be resolved because #{e.message}.") + rescue => e + @logger.error("Unknown exception raised: #{e.class} #{e.message}") + end + end + @hash[name] = arr.flatten.uniq.compact + if true == @hash[name].empty? + @logger.error("List #{name} is empty! "+ + "Nothing from element list '#{elem}' "+ + "has resolved to anything useable.") + end + end + + end + + ## temporary object to convert from configuration file to the SourceList class + class AddSoruceList < Struct.new(:label, :list) + + # Default constructor. + def initialize (*args) + super(*args) + end + + end + +end diff --git a/lib/mauve/timer.rb b/lib/mauve/timer.rb new file mode 100644 index 0000000..5355dcc --- /dev/null +++ b/lib/mauve/timer.rb @@ -0,0 +1,86 @@ +# encoding: UTF-8 +require 'mauve/alert' +require 'mauve/notifier' +require 'mauve/mauve_thread' +require 'thread' +require 'log4r' + +module Mauve + + class Timer < MauveThread + + include Singleton + + attr_accessor :sleep_interval, :last_run_at + + def initialize + @logger = Log4r::Logger.new self.class.to_s + @logger.info("Timer singleton created.") + @initial_sleep = 300 + @initial_sleep_threshold = 300 + end + + def main_loop + @logger.debug "hello" + # + # Get the next alert. + # + next_alert = Alert.find_next_with_event + + # + # If we didn't find an alert, or the alert we found is due in the future, + # look for the next alert_changed object. + # + if next_alert.nil? or next_alert.due_at > MauveTime.now + @logger.debug("Next alert was #{next_alert} due at #{next_alert.due_at}") unless next_alert.nil? + next_alert_changed = AlertChanged.find_next_with_event + end + + if next_alert_changed.nil? and next_alert.nil? + next_to_notify = nil + + elsif next_alert.nil? or next_alert_changed.nil? + next_to_notify = (next_alert || next_alert_changed) + + else + next_to_notify = ( next_alert.due_at < next_alert_changed.due_at ? next_alert : next_alert_changed ) + + end + + # + # Nothing to notify? + # + if next_to_notify.nil? + # + # Sleep indefinitely + # + @logger.debug("Nothing to notify about -- snoozing indefinitely.") + else + # + # La la la nothing to do. + # + @logger.debug("Next to notify: #{next_to_notify} -- snoozing until #{next_to_notify.due_at}") + end + + # + # Ah-ha! Sleep with a break clause. + # + while next_to_notify.nil? or MauveTime.now <= next_to_notify.due_at + # + # Start again if the situation has changed. + # + break if self.should_stop? + # + # This is a rate-limiting step for alerts. + # + Kernel.sleep 0.2 + end + + return if self.should_stop? or next_to_notify.nil? + + next_to_notify.poll + end + + end + +end diff --git a/lib/mauve/udp_server.rb b/lib/mauve/udp_server.rb new file mode 100644 index 0000000..a570e8a --- /dev/null +++ b/lib/mauve/udp_server.rb @@ -0,0 +1,109 @@ +# encoding: UTF-8 +require 'yaml' +require 'socket' +require 'mauve/datamapper' +require 'mauve/proto' +require 'mauve/alert' +require 'log4r' + +module Mauve + + class UDPServer < MauveThread + + include Singleton + + attr_accessor :ip, :port, :sleep_interval + + def initialize + # + # Set the logger up + # + @logger = Log4r::Logger.new(self.class.to_s) + @ip = "127.0.0.1" + @port = 32741 + @socket = nil + @closing_now = false + @sleep_interval = 0 + end + + def open_socket + @socket = UDPSocket.new + @closing_now = false + + @logger.debug("Trying to increase Socket::SO_RCVBUF to 10M.") + old = @socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF).unpack("i").first + + @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 10*1024*1024) + new = @socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF).unpack("i").first + + raise "Could not increase Socket::SO_RCVBUF. Had #{old} ended up with #{new}!" if old > new + + @logger.debug("Successfully increased Socket::SO_RCVBUF from #{old} to #{new}.") + + @socket.bind(@ip, @port) + + @logger.debug("Successfully opened UDP socket on #{@ip}:#{@port}") + end + + def close_socket + return if @socket.nil? or @socket.closed? + + begin + @socket.close + rescue IOError => ex + # Just in case there is some sort of explosion! + @logger.debug("Caught IOError #{ex.to_s}") + end + + @logger.debug("Successfully closed UDP socket") + end + + def main_loop + return if self.should_stop? + + open_socket if @socket.nil? or @socket.closed? + + return if self.should_stop? + + # + # TODO: why is/isn't this non-block? + # + begin + # packet = @socket.recvfrom_nonblock(65535) + packet = @socket.recvfrom(65535) + received_at = MauveTime.now + rescue Errno::EAGAIN, Errno::EWOULDBLOCK + IO.select([@socket]) + retry unless self.should_stop? + end + + return if packet.nil? + + @logger.debug("Got new packet: #{packet.inspect}") + + # + # If we get a zero length packet, and we've been flagged to stop, we stop! + # + if packet.first.length == 0 and self.should_stop? + self.close_socket + return + end + + + + Processor.push([[packet[0], packet[1], received_at]]) + end + + def stop + @stop = true + # + # Triggers loop to close socket. + # + UDPSocket.open.send("", 0, @socket.addr[2], @socket.addr[1]) unless @socket.closed? + + super + end + + end + +end diff --git a/lib/mauve/web_interface.rb b/lib/mauve/web_interface.rb new file mode 100644 index 0000000..4569cb6 --- /dev/null +++ b/lib/mauve/web_interface.rb @@ -0,0 +1,334 @@ +# encoding: UTF-8 +require 'sinatra/base' +require 'sinatra-partials' +require 'haml' +require 'rack' +require 'rack-flash' + +if !defined?(JRUBY_VERSION) + require 'thin' +end + +module Mauve + # Our Sinatra app proper + # + class WebInterface < Sinatra::Base + + class PleaseAuthenticate < Exception; end + + use Rack::Session::Cookie, :expire_after => 604800 # 7 days in seconds + + enable :sessions + + use Rack::Flash + + set :root, "/usr/share/mauve" + set :views, "#{root}/views" + set :public, "#{root}/static" + set :static, true + set :show_exceptions, true + + logger = Log4r::Logger.new("Mauve::WebInterface") + + set :logging, true + set :logger, logger + set :dump_errors, true # ...will dump errors to the log + set :raise_errors, false # ...will not let exceptions out to main program + set :show_exceptions, false # ...will not show exceptions + + ######################################################################## + + before do + @person = Configuration.current.people[session['username']] + @title = "Mauve alert panel" + end + + get '/' do + redirect '/alerts' + end + + ######################################################################## + + ## Checks the identity of the person via a password. + # + # The password can be either the SSO or a local one defined + # in the configuration file. + # + post '/login' do + usr = params['username'] + pwd = params['password'] + ret_sso = helper_auth_SSO(usr, pwd) + ret_loc = helper_auth_local(usr, pwd) + if "success" == ret_sso or "success" == ret_loc + session['username'] = usr + else + flash['error'] =<<__MSG +<hr /> <img src="/images/error.png" /> <br /> +ACCESS DENIED <br /> +#{ret_sso} <br /> +#{ret_loc} <hr /> +__MSG + end + redirect '/alerts' + end + + get '/logout' do + session.delete('username') + redirect '/alerts' + end + + get '/alerts' do + #now = MauveTime.now.to_f + please_authenticate() + find_active_alerts() + #pp MauveTime.now.to_f - now + haml(:alerts2) + end + + get '/_alert_summary' do + find_active_alerts; partial("alert_summary") + end + + get '/_alert_counts' do + find_active_alerts; partial("alert_counts") + end + + get '/_head' do + find_active_alerts() + partial("head") + end + + get '/alert/:id/detail' do + please_authenticate + + content_type("text/html") # I think + Alert.get(params[:id]).detail + end + + get '/alert/:id' do + please_authenticate + find_active_alerts + @alert = Alert.get(params['id']) + haml :alert + end + + post '/alert/:id/acknowledge' do + please_authenticate + + alert = Alert.get(params[:id]) + if alert.acknowledged? + alert.unacknowledge! + else + alert.acknowledge!(@person, 0) + end + content_type("application/json") + alert.to_json + end + + # Note that :until must be in seconds. + post '/alert/acknowledge/:id/:until' do + #now = MauveTime.now.to_f + please_authenticate + + alert = Alert.get(params[:id]) + alert.acknowledge!(@person, params[:until].to_i()) + + #print "Acknowledge request was processed in #{MauveTime.now.to_f - now} seconds\n" + content_type("application/json") + alert.to_json + end + + post '/alert/:id/raise' do + #now = MauveTime.now.to_f + please_authenticate + + alert = Alert.get(params[:id]) + alert.raise! + #print "Raise request was processed in #{MauveTime.now.to_f - now} seconds\n" + content_type("application/json") + alert.to_json + end + + post '/alert/:id/clear' do + please_authenticate + + alert = Alert.get(params[:id]) + alert.clear! + content_type("application/json") + alert.to_json + end + + post '/alert/:id/toggleDetailView' do + please_authenticate + + alert = Alert.get(params[:id]) + if nil != alert + id = params[:id].to_i() + session[:display_alerts][id] = (true == session[:display_alerts][id])? false : true + content_type("application/json") + 'all is good'.to_json + end + end + + post '/alert/fold/:subject' do + please_authenticate + + session[:display_folding][params[:subject]] = (true == session[:display_folding][params[:subject]])? false : true + content_type("application/json") + 'all is good'.to_json + end + + ######################################################################## + + get '/preferences' do + please_authenticate + find_active_alerts + haml :preferences + end + + ######################################################################## + + get '/events' do + please_authenticate + find_active_alerts + find_recent_alerts + haml :events + end + + ######################################################################## + + helpers do + include Sinatra::Partials + + def please_authenticate + raise PleaseAuthenticate.new unless @person + end + + def find_active_alerts + + # FIXME: make sure alerts only appear once some better way + #@urgent = AlertGroup.all_alerts_by_level(:urgent) + #@normal = AlertGroup.all_alerts_by_level(:normal) - @urgent + #@low = AlertGroup.all_alerts_by_level(:low) - @normal - @urgent + ook = Alert.get_all() + @urgent = ook[:urgent] + @normal = ook[:normal] + @low = ook[:low] + + # Get groups of alerts and count those acknowledged. + @grouped_ack_urgent = Hash.new() + @grouped_ack_normal = Hash.new() + @grouped_ack_low = Hash.new() + @grouped_new_urgent = Hash.new() + @grouped_new_normal = Hash.new() + @grouped_new_low = Hash.new() + @count_ack = Hash.new() + @count_ack[:urgent] = self.group_alerts(@grouped_ack_urgent, + @grouped_new_urgent, + @urgent) + @count_ack[:normal] = self.group_alerts(@grouped_ack_normal, + @grouped_new_normal, + @normal) + @count_ack[:low] = self.group_alerts(@grouped_ack_low, + @grouped_new_low, + @low) + @grouped_ack = Hash.new() + @grouped_new = Hash.new() + @grouped_ack_urgent.each_pair {|k,v| @grouped_ack[k] = v} + @grouped_ack_normal.each_pair {|k,v| @grouped_ack[k] = v} + @grouped_ack_low.each_pair {|k,v| @grouped_ack[k] = v} + @grouped_new_urgent.each_pair {|k,v| @grouped_new[k] = v} + @grouped_new_normal.each_pair {|k,v| @grouped_new[k] = v} + @grouped_new_low.each_pair {|k,v| @grouped_new[k] = v} + end + + ## Fill two hashs with alerts that are acknowledged or not. + # @param [Hash] ack Acknowledge hash. + # @param [Hash] new Unacknowledged (aka new) hash. + # @param [List] list List of alerts. + # @return [Fixnum] The count of acknowledged alerts. + def group_alerts(ack, new, list) + count = 0 + list.each do |alert| + #key = alert.source + '::' + alert.subject + key = alert.subject + if true == alert.acknowledged? + count += 1 + ack[key] = Array.new() if false == ack.has_key?(key) + ack[key] << alert + else + new[key] = Array.new() if false == new.has_key?(key) + new[key] << alert + end + if false == session[:display_alerts].has_key?(alert.id) + session[:display_alerts][alert.id] = false + end + if false == session[:display_folding].has_key?(key) + session[:display_folding][key] = false + end + #session[:display_alerts][alert.id] = true if false == session[:display_alerts].has_key?(alert.id) + #session[:display_folding][key] = true if false == session[:display_folding].has_key?(key) + new.each_key {|k| new[k].sort!{|a,b| a.summary <=> b.summary} } + ack.each_key {|k| ack[k].sort!{|a,b| a.summary <=> b.summary} } + end + return count + end + + def find_recent_alerts + since = params['since'] ? MauveTime.parse(params['since']) : (MauveTime.now-86400) + @alerts = Alert.all(:updated_at.gt => since, :order => [:raised_at.desc, :cleared_at.desc, :acknowledged_at.desc, :updated_at.desc, ]) + end + + def cycle(*list) + @cycle ||= 0 + @cycle = (@cycle + 1) % list.length + list[@cycle] + end + + ## Test for authentication with SSO. + # + def helper_auth_SSO (usr, pwd) + auth = AuthSourceBytemark.new() + begin + return "success" if true == auth.authenticate(usr,pwd) + return "SSO did not regcognise your login/password combination." + rescue ArgumentError => ex + return "SSO argument error: #{ex.message}" + rescue => ex + return "SSO generic error: #{ex.message}" + end + end + + ## Test for authentication with configuration file parameter. + # + def helper_auth_local (usr, pwd) + person = Configuration.current.people[params['username']] + return "I did not recognise your local login details." if !person + return "I did not recognise your local password." if Digest::SHA1.hexdigest(params['password']) != person.password + return "success" + end + + end + + ######################################################################## + + error PleaseAuthenticate do + status 403 + session[:display_alerts] = Hash.new() + session[:display_folding] = Hash.new() + haml :please_authenticate + end + + ######################################################################## + # @see http://stackoverflow.com/questions/2239240/use-rackcommonlogger-in-sinatra + def call(env) + if true == @logger.nil? + @logger = Log4r::Logger.new("mauve::Rack") + end + env['rack.errors'] = RackErrorsProxy.new(@logger) + super(env) + end + + end + +end diff --git a/lib/object_builder.rb b/lib/object_builder.rb new file mode 100644 index 0000000..7cb808c --- /dev/null +++ b/lib/object_builder.rb @@ -0,0 +1,113 @@ +# ObjectBuilder is a class to help you build Ruby-based configuration syntaxes. +# You can use it to make "builder" classes to help build particular types +# of objects, typically translating simple command-based syntax to creating +# classes and setting attributes. e.g. here is a description of a day at +# the zoo: +# +# person "Alice" +# person "Matthew" +# +# zoo("London") { +# enclosure("Butterfly House") { +# +# has_roof +# allow_visitors +# +# animals("moth", 10) { +# wings 2 +# legs 2 +# } +# +# animals("butterfly", 200) { +# wings 2 +# legs 2 +# } +# } +# +# enclosure("Aquarium") { +# no_roof +# +# animal("killer whale") { +# called "Shamu" +# wings 0 +# legs 0 +# tail +# } +# } +# } +# +# Here is the basic builder class for a Zoo... +# +# TODO: finish this convoluted example, if it kills me +# +class ObjectBuilder + class BuildException < Exception; end + + attr_reader :result + + def initialize(context, *args) + @context = context + builder_setup(*args) + end + + def anonymous_name + @@sequence ||= 0 # not inherited, don't want it to be + @@sequence += 1 + "anon.#{Time.now.to_i}.#{@@sequence}" + end + + class << self + + def is_builder(word, clazz) + define_method(word.to_sym) do |*args, &block| + builder = clazz.new(*([@context] + args)) + builder.instance_eval(&block) if block + ["created_#{word}", "created"].each do |created_method| + created_method = created_method.to_sym + if respond_to?(created_method) + __send__(created_method, builder.result) + break + end + end + end + end + + # FIXME: implement is_builder_deferred to create object at end of block? + + def is_block_attribute(word) + define_method(word.to_sym) do |*args, &block| + @result.__send__("#{word}=".to_sym, block) + end + end + + def is_attribute(word) + define_method(word.to_sym) do |*args, &block| + @result.__send__("#{word}=".to_sym, args[0]) + end + end + + def is_flag_attribute(word) + define_method(word.to_sym) do |*args, &block| + @result.__send__("#{word}=".to_sym, true) + end + end + + def load(file) + builder = self.new + builder.instance_eval(File.read(file), file) + builder.result + end + + def inherited(*args) + initialize_class + end + + def initialize_class + @words = {} + end + end + + initialize_class +end + + diff --git a/lib/rack-flash.rb b/lib/rack-flash.rb new file mode 100644 index 0000000..bc95b21 --- /dev/null +++ b/lib/rack-flash.rb @@ -0,0 +1,171 @@ +# +# borrowed from http://github.com/nakajima/rack-flash - thanks! +# + +require 'rack/builder' + +module Rack + class Builder + attr :ins + def use(middleware, *args, &block) + middleware.instance_variable_set "@rack_builder", self + def middleware.rack_builder + @rack_builder + end + @ins << lambda { |app| + middleware.new(app, *args, &block) + } + end + + def run(app) + klass = app.class + klass.instance_variable_set "@rack_builder", self + def klass.rack_builder + @rack_builder + end + @ins << app #lambda { |nothing| app } + end + + def leaf_app + ins.last + end + end +end + +module Rack + class Flash + # Raised when the session passed to FlashHash initialize is nil. This + # is usually an indicator that session middleware is not in use. + class SessionUnavailable < StandardError; end + + # Implements bracket accessors for storing and retrieving flash entries. + class FlashHash + attr_reader :flagged + + def initialize(store, opts={}) + raise Rack::Flash::SessionUnavailable \ + .new('Rack::Flash depends on session middleware.') unless store + + @opts = opts + @store = store + + if accessors = @opts[:accessorize] + accessors.each { |opt| def_accessor(opt) } + end + end + + # Remove an entry from the session and return its value. Cache result in + # the instance cache. + def [](key) + key = key.to_sym + cache[key] ||= values.delete(key) + end + + # Store the entry in the session, updating the instance cache as well. + def []=(key,val) + key = key.to_sym + cache[key] = values[key] = val + end + + # Store a flash entry for only the current request, swept regardless of + # whether or not it was actually accessed. Useful for AJAX requests, where + # you want a flash message, even though you're response isn't redirecting. + def now + cache + end + + # Checks for the presence of a flash entry without retrieving or removing + # it from the cache or store. + def has?(key) + [cache, values].any? { |store| store.keys.include?(key.to_sym) } + end + alias_method :include?, :has? + + # Mark existing entries to allow for sweeping. + def flag! + @flagged = values.keys + end + + # Remove flagged entries from flash session, clear flagged list. + def sweep! + Array(flagged).each { |key| values.delete(key) } + flagged.clear + end + + # Hide the underlying :__FLASH__ session key and only expose values stored + # in the flash. + def inspect + '#<FlashHash @values=%s @cache=%s>' % [values.inspect, cache.inspect] + end + + # Human readable for logging. + def to_s + values.inspect + end + + private + + # Maintain an instance-level cache of retrieved flash entries. These + # entries will have been removed from the session, but are still available + # through the cache. + def cache + @cache ||= {} + end + + # Helper to access flash entries from :__FLASH__ session value. This key + # is used to prevent collisions with other user-defined session values. + def values + @store[:__FLASH__] ||= {} + end + + # Generate accessor methods for the given entry key if :accessorize is true. + def def_accessor(key) + raise ArgumentError.new('Invalid entry type: %s' % key) if respond_to?(key) + + class << self; self end.class_eval do + define_method(key) { |*args| val = args.first; val ? (self[key]=val) : self[key] } + define_method("#{key}=") { |val| self[key] = val } + define_method("#{key}!") { |val| cache[key] = val } + end + end + end + + # ------------------------------------------------------------------------- + # - Rack Middleware implementation + + def initialize(app, opts={}) + if klass = app_class(app, opts) + klass.class_eval do + def flash; env['x-rack.flash'] end + end + end + + @app, @opts = app, opts + end + + def call(env) + env['x-rack.flash'] ||= Rack::Flash::FlashHash.new(env['rack.session'], @opts) + + if @opts[:sweep] + env['x-rack.flash'].flag! + end + + res = @app.call(env) + + if @opts[:sweep] + env['x-rack.flash'].sweep! + end + + res + end + + private + + def app_class(app, opts) + return nil if opts.has_key?(:helper) and not opts[:helper] + opts[:flash_app_class] || + defined?(Sinatra::Base) && Sinatra::Base || + self.class.rack_builder.leaf_app.class + end + end +end diff --git a/lib/sinatra-partials.rb b/lib/sinatra-partials.rb new file mode 100644 index 0000000..e16313c --- /dev/null +++ b/lib/sinatra-partials.rb @@ -0,0 +1,25 @@ +# +# stolen from http://github.com/cschneid/irclogger/blob/master/lib/partials.rb +# and made a lot more robust by me +# +# this implementation uses erb by default. if you want to use any other template mechanism +# then replace `erb` on line 13 and line 17 with `haml` or whatever +# + +module Sinatra::Partials + def partial(template, *args) + template_array = template.to_s.split('/') + template = template_array[0..-2].join('/') + "/_#{template_array[-1]}" + options = args.last.is_a?(Hash) ? args.pop : {} + options.merge!(:layout => false) + if collection = options.delete(:collection) then + collection.inject([]) do |buffer, member| + buffer << erb(:"#{template}", options.merge(:layout => + false, :locals => {template_array[-1].to_sym => member})) + end.join("\n") + else + haml(:"#{template}", options) + end + end +end + |