using System; using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Security.Authentication; using System.Threading; using System.Threading.Tasks; namespace PleaseIgnore.IntelMap { /// /// Provides a component to monitor EVE log files and report new /// entries to the TEST Intel Map for distribution. /// [DefaultEvent("IntelReported")] public class IntelReporter : Component, INotifyPropertyChanged { // The default value for RetryAuthenticationPeriod private const string defaultRetryAuthenticationPeriod = "01:00:00"; // The default value for ChannelIdlePeriod private const string defaultSessionTimeout = "00:10:00"; // The default value for KeepAlivePeriod private const string defaultKeepAlivePeriod = "00:01:00"; // The default value for RetryPeriod private const string defaultRetryPeriod = "00:00:30"; // Protect internal state private readonly object syncRoot = new object(); // List of active IntelChannel objects private readonly IntelChannelContainer channels; // Timer to use when maintaining the session keepalive private readonly Timer timerSession; // The instance of ISynchronizeInvoke to use for messaging [ContractPublicPropertyName("SynchronizingObject")] private ISynchronizeInvoke synchronizingObject; // The current channel processing state [ContractPublicPropertyName("Status")] private IntelStatus status; // The time to wait between sending keep alives [ContractPublicPropertyName("KeepAliveInterval")] private TimeSpan keepAlivePeriod = TimeSpan.Parse( defaultKeepAlivePeriod, CultureInfo.InvariantCulture); // Field backing the ChannelIdlePeriod property [ContractPublicPropertyName("SessionTimeout")] private TimeSpan sessionTimeout = TimeSpan.Parse( defaultSessionTimeout, CultureInfo.InvariantCulture); // Field backing the RetryAuthenticationPeriod property [ContractPublicPropertyName("AuthenticationRetryTimeout")] private TimeSpan authRetryTimeout = TimeSpan.Parse( defaultRetryAuthenticationPeriod, CultureInfo.InvariantCulture); // Field backing the ServiceUri property [ContractPublicPropertyName("ServiceUri")] private Uri serviceUri = IntelExtensions.ReportUrl; // The session used to contact the intel server private IntelSession session; // Field backing the Username property private string username; // Field backing the PasswordHash property private string passwordHash; // Date and time the last intel was reported to the server private DateTime lastIntel; // The last time an authentication attempt failed private DateTime? lastAuthFailure; /// /// Initializes a new instance of the /// class. /// public IntelReporter() : this(null) { Contract.Ensures(Container == null); Contract.Ensures(Status == IntelStatus.Stopped); Contract.Ensures(!IsRunning); } /// /// Initializes a new instance of the /// class and adds it to the specified . /// public IntelReporter(IContainer container) { Contract.Ensures(this.Container == container); Contract.Ensures(Status == IntelStatus.Stopped); Contract.Ensures(!IsRunning); this.channels = new IntelChannelContainer(); this.channels.IntelReported += channels_IntelReported; this.channels.PropertyChanged += channels_PropertyChanged; this.timerSession = new Timer(this.timer_Callback); if (container != null) { container.Add(this); } } /// /// Occurs when a property value changes. /// /// /// The event can indicate all properties /// on the object have changed by using either or /// as the property name in the /// . /// public event PropertyChangedEventHandler PropertyChanged; /// /// Raised when a new intel report has been parsed from a log file. /// /// /// This call will be made from the . The /// consumer will need synchronize to their local threads as /// appropriate. /// public event EventHandler IntelReported; /// /// Gets a value indicating the operating state of this /// . /// public IntelStatus Status { get { return IntelExtensions.Combine(this.status, this.channels.Status); } private set { if (this.status != value) { var running = this.IsRunning; this.status = value; this.OnPropertyChanged("Status"); if (this.IsRunning != running) { this.OnPropertyChanged("IsRunning"); } } } } /// /// Gets a value indicating whether this /// is a normal operating state. /// public bool IsRunning { get { return this.status.IsRunning(); } } /// /// Gets an instance of /// /// /// Calling on an /// will remove it from /// . It may be readded when the /// channel list is redownloaded. /// public IntelChannelCollection Channels { get { return this.channels.Channels; } } /// /// Gets or sets the object used to marshal the event handler /// calls issued as a result of a /// or event. /// [DefaultValue((object)null)] public ISynchronizeInvoke SynchronizingObject { get { return this.synchronizingObject; } set { Contract.Requires(!this.IsRunning); if (this.synchronizingObject != value) { this.synchronizingObject = value; this.OnPropertyChanged("SynchronizingObject"); } } } /// /// Gets or sets the directory path to find EVE chat logs. /// /// /// The specified directory does not exist. /// [AmbientValue((string)null), Category("Behavior")] public string Path { get { return this.channels.Path; } set { this.channels.Path = value; } } /// /// Gets or sets the TEST Alliance AUTH username. /// /// /// Changes to the /// or will not be used until the next /// time the client tries to reauthenticate. To verify that the /// authentication information is correct, use the /// or /// methods. /// [DefaultValue((String)null), Category("Behavior")] public string Username { get { return this.username; } set { if (this.username != value) { this.username = value; this.lastAuthFailure = null; this.OnPropertyChanged("Username"); } } } /// /// Gets or sets the hashed services password for the user. /// /// /// Changes to the /// or will not be used until the next /// time the client tries to reauthenticate. To verify that the /// authentication information is correct, use the /// or /// methods. /// [DefaultValue((String)null), Category("Behavior")] public string PasswordHash { get { return this.passwordHash; } set { if (this.passwordHash != value) { this.passwordHash = value; this.lastAuthFailure = null; this.OnPropertyChanged("PasswordHash"); } } } /// /// Gets the number of events sent to the intel reporting server. /// public int IntelSent { get; private set; } /// /// Gets the number of events that were dropped due to network /// or server errors. /// public int IntelDropped { get; private set; } /// /// Gets the number of users currently connected to the server. /// public int Users { get { Contract.Ensures(Contract.Result() >= 0); var session = this.session; return (session != null) ? session.Users : 0; } } /// /// Gets or sets the time between downloads of the intel /// channel list. /// [DefaultValue(typeof(TimeSpan), IntelChannelContainer.defaultUpdateInterval)] [Category("Behavior")] public TimeSpan? ChannelUpdateInterval { get { Contract.Ensures(!Contract.Result().HasValue || (Contract.Result().Value > TimeSpan.Zero)); return this.channels.ChannelUpdateInterval; } set { Contract.Requires( !value.HasValue || (value.Value > TimeSpan.Zero), "value"); Contract.Requires(!IsRunning); this.channels.ChannelUpdateInterval = value; } } /// /// Gets or sets the time between server keep alives sent /// to the server while a session is open. /// [DefaultValue(typeof(TimeSpan), defaultKeepAlivePeriod)] [Category("Behavior")] public TimeSpan KeepAliveInterval { get { Contract.Ensures(Contract.Result() > TimeSpan.Zero); return this.keepAlivePeriod; } set { Contract.Requires( value > TimeSpan.Zero, "value"); Contract.Requires(!IsRunning); if (this.keepAlivePeriod != value) { this.keepAlivePeriod = value; this.OnPropertyChanged("KeepAliveInterval"); } } } /// /// Gets or sets the time to hold the /// open when not reporting fresh intel. /// [DefaultValue(typeof(TimeSpan), defaultSessionTimeout)] [Category("Behavior")] public TimeSpan SessionTimeout { get { Contract.Ensures(Contract.Result() > TimeSpan.Zero); return this.sessionTimeout; } set { Contract.Requires( value > TimeSpan.Zero, "value"); Contract.Requires(!IsRunning); if (this.sessionTimeout != value) { this.sessionTimeout = value; this.OnPropertyChanged("SessionTimeout"); } } } /// /// Gets or sets the time to wait after authentication fails /// before trying again. /// /// /// In some situations, a server may reject a login due to reasons other /// than invalid password (e.g. an EVE API screw-up, database issue, /// etc.). Instead of permanently rejecting the credentials, it will /// periodically try to log in. /// [DefaultValue(typeof(TimeSpan), defaultRetryAuthenticationPeriod)] [Category("Behavior")] public TimeSpan AuthenticationRetryTimeout { get { Contract.Ensures(Contract.Result() > TimeSpan.Zero); return this.authRetryTimeout; } set { Contract.Requires( value > TimeSpan.Zero, "value"); Contract.Requires(!IsRunning); Contract.Ensures(this.AuthenticationRetryTimeout == value); if (this.authRetryTimeout != value) { this.authRetryTimeout = value; this.OnPropertyChanged("AuthenticationRetryTimeout"); } } } /// /// Gets or sets the to use when downloading /// the channel list. /// [AmbientValue((string)null)] public string ChannelListUri { get { Contract.Ensures(!String.IsNullOrEmpty(Contract.Result())); return this.channels.ChannelListUri; } set { Contract.Requires(!IsRunning); this.channels.ChannelListUri = value; } } /// /// Gets or sets the to use when accessing the /// intel reporting service. /// [AmbientValue((string)null)] public string ServiceUri { get { Contract.Ensures(!String.IsNullOrEmpty(Contract.Result())); return (this.serviceUri ?? IntelExtensions.ChannelsUrl).OriginalString; } set { Contract.Requires(!IsRunning); var uri = (value != null) ? new Uri(value) : IntelExtensions.ChannelsUrl; if (!uri.IsAbsoluteUri) { // TODO: Proper exception throw new ArgumentException(); } if (this.serviceUri != uri) { this.serviceUri = uri; this.OnPropertyChanged(new PropertyChangedEventArgs("ServiceUri")); } } } /// /// Downloads the channel list and begins the acquisition of log /// entries from the EVE chat logs. This method enables /// events. /// public void Start() { Contract.Requires( this.Status != IntelStatus.Disposed, null); Contract.Requires( this.Status != IntelStatus.FatalError); Contract.Ensures(this.IsRunning); lock (this.syncRoot) { try { if (this.Status == IntelStatus.Stopped) { this.Status = IntelStatus.Starting; this.channels.Start(); try { this.GetSession(true); } catch (WebException) { this.Status = IntelStatus.NetworkError; } catch (AuthenticationException) { this.Status = IntelStatus.AuthenticationError; } } } catch { this.Status = IntelStatus.FatalError; throw; } } } /// /// Stops the from providing /// location data and events. /// events will no longer be raised. /// public void Stop() { Contract.Ensures(!this.IsRunning); lock (this.syncRoot) { try { if (this.IsRunning) { this.Status = IntelStatus.Stopping; if (this.session != null) { this.session.Dispose(); this.session = null; } this.channels.Stop(); this.Status = IntelStatus.Stopped; } } catch { this.Status = IntelStatus.FatalError; throw; } } } /// protected override void Dispose(bool disposing) { if (disposing) { // Clean up lock (this.syncRoot) { try { if (this.Status != IntelStatus.Disposed) { this.Status = IntelStatus.Disposing; if (this.session != null) { this.session.Dispose(); this.session = null; } this.channels.Dispose(); } } finally { this.Status = IntelStatus.Disposed; base.Dispose(disposing); } } } else { // Cannot safely clean up this.status = IntelStatus.Disposed; base.Dispose(disposing); } } /// /// Gets the instance of to use when /// contacting the intel server. /// /// /// to create a new instance of /// if there is currently not an active /// session; othwerise, return if no session /// is currently active. /// protected virtual IntelSession GetSession(bool create) { Contract.Requires(IsRunning); Contract.Ensures((Contract.Result() != null) || !create); var session = this.session; if ((session != null) && session.IsConnected) { // Existing session is still valid return session; } else if (create && (String.IsNullOrEmpty(this.username) || String.IsNullOrEmpty(this.passwordHash))) { // Can't login without a username/password throw new AuthenticationException(); } else if (create && this.lastAuthFailure.HasValue && (this.lastAuthFailure + this.authRetryTimeout > DateTime.UtcNow)) { // Wait longer before trying to log in again throw new AuthenticationException(); } else if (create) { // Safe to create a new instance of IntelSession this.session = new IntelSession(this.username, this.passwordHash, this.serviceUri); this.timerSession.Change(this.keepAlivePeriod, this.keepAlivePeriod); this.lastIntel = DateTime.UtcNow; this.Status = IntelStatus.Active; this.OnPropertyChanged("Users"); return this.session; } else if (session != null) { // Session has expired this.session = null; timerSession.Change(Timeout.Infinite, Timeout.Infinite); return null; } else { // No session open return null; } } /// /// Raises the event. /// internal protected virtual void OnIntelReported(IntelEventArgs e) { Contract.Requires(e != null, "e"); // Raise the event (as appropriate) this.lastIntel = DateTime.UtcNow; var handler = this.IntelReported; if (handler != null) { var sync = this.synchronizingObject; WaitCallback callback = (state) => handler(this, e); if ((sync != null) && sync.InvokeRequired) { sync.BeginInvoke(callback, new object[] { null }); } else { ThreadPool.QueueUserWorkItem(callback); } } // Report the intel try { lock (this.syncRoot) { // Race condition avoidance if (!this.IsRunning) { return; } // Report as necessary var session = this.GetSession(true); if (session.Report(e)) { this.Status = IntelStatus.Active; ++this.IntelSent; this.OnPropertyChanged("IntelSent"); } else { ++this.IntelDropped; this.OnPropertyChanged("IntelDropped"); } } } catch (WebException) { this.Status = IntelStatus.NetworkError; ++this.IntelDropped; this.OnPropertyChanged("IntelDropped"); } catch (AuthenticationException) { this.Status = IntelStatus.AuthenticationError; ++this.IntelDropped; this.OnPropertyChanged("IntelDropped"); } } /// /// Raises the event. /// internal protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) { Contract.Requires(e != null, "e"); Debug.Assert(String.IsNullOrEmpty(e.PropertyName) || (this.GetType().GetProperty(e.PropertyName) != null)); // Raise the event (as appropriate) var handler = this.PropertyChanged; if (handler != null) { var sync = this.synchronizingObject; WaitCallback callback = (state) => handler(this, e); if ((sync != null) && sync.InvokeRequired) { sync.BeginInvoke(callback, new object[] { null }); } else { ThreadPool.QueueUserWorkItem(callback); } } } /// /// Called every while the server /// connection is open. /// protected virtual void OnKeepAlive() { var session = this.GetSession(false); if (session != null) { if (this.lastIntel + this.sessionTimeout < DateTime.UtcNow) { // Session has expired session.Dispose(); this.Status = IntelStatus.Waiting; this.OnPropertyChanged("Users"); } else { // Maintain the session try { var users = session.Users; if (session.KeepAlive()) { this.Status = IntelStatus.Active; } if (users != session.Users) { this.OnPropertyChanged("Users"); } } catch (WebException) { this.Status = IntelStatus.NetworkError; } } } } /// /// Event handler for . /// private void channels_IntelReported(object sender, IntelEventArgs e) { Contract.Requires(e != null); this.OnIntelReported(e); } /// /// Event handler for . /// private void channels_PropertyChanged(object sender, PropertyChangedEventArgs e) { Contract.Requires(e != null); if ((e.PropertyName == "ChannelUpdateInterval") || (e.PropertyName == "Channels") || (e.PropertyName == "Status") || (e.PropertyName == "Path") || (e.PropertyName == "ChannelListUri")) { // Property we forward this.OnPropertyChanged(e); } } /// /// Periodically sends keep-alives to the session. /// private void timer_Callback(object state) { lock (this.syncRoot) { if (this.IsRunning) { this.OnKeepAlive(); } if (!this.IsRunning || (this.GetSession(false) == null)) { this.timerSession.Change(Timeout.Infinite, Timeout.Infinite); } } } /// /// Helper function to automatically construct a /// for . /// private void OnPropertyChanged(string propertyName) { this.OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); } /// /// Updates the user's credentials and forces the service to /// reauthenticate with the reporting service. /// /// /// The TEST auth username. /// /// /// The plaintext TEST services password. It will be hashed /// automatically. /// /// /// The credentials provided by the user are incorrect. /// public void Authenticate(string username, string password) { Contract.Requires(!string.IsNullOrEmpty(username)); Contract.Requires(!string.IsNullOrEmpty(password)); Contract.Requires(this.Status != IntelStatus.Disposed); Contract.Requires(this.Status != IntelStatus.FatalError); // Verify the password is correct before saving anything var passwordHash = IntelSession.HashPassword(password); IntelSession testSession; try { testSession = new IntelSession(username, passwordHash, this.serviceUri); } catch (WebException) { lock (this.syncRoot) { this.Status = IntelStatus.NetworkError; throw; } } // Update the program state lock (this.syncRoot) { this.username = username; this.passwordHash = passwordHash; if (this.session != null) { this.session.Dispose(); } this.session = testSession; this.lastIntel = DateTime.UtcNow; this.lastAuthFailure = null; this.Status = IntelStatus.Active; this.timerSession.Change(this.keepAlivePeriod, this.keepAlivePeriod); this.OnPropertyChanged("Username"); this.OnPropertyChanged("PasswordHash"); this.OnPropertyChanged("Users"); } } [ContractInvariantMethod] private void ObjectInvariant() { Contract.Invariant(this.sessionTimeout > TimeSpan.Zero); Contract.Invariant(this.keepAlivePeriod > TimeSpan.Zero); Contract.Invariant(this.channels != null); Contract.Invariant(this.IntelSent >= 0); Contract.Invariant(this.IntelDropped >= 0); } } }