Click here to Skip to main content
15,867,308 members
Articles / Programming Languages / C#
Article

Router IP Address Monitor Service

Rate me:
Please Sign up or sign in to vote.
4.57/5 (9 votes)
18 Oct 20057 min read 124.4K   3.3K   57   20
A notification service for router IP address changes.

Introduction

Our office network is connected to the internet with a Linksys router and broadband DSL (Digital Subscriber Line) service. The router provides a firewall and allows us to forward specific external port requests to an internal network address. The DSL service that we purchased provides a single dynamic IP address that periodically changes. In order to access our Virtual Private Network or internal production web server from outside the office, we needed to be able to keep track of the router's IP address since we don't have a published DNS record. So awhile back I wrote a simple service that runs on our internal server that polls the router's IP address periodically and sends out an e-mail notification when it changes.

Background

The Linksys BEFxx41 router that we use handles the DSL IP address acquisition from our internet service provider. It doesn't provide any method to ascertain this address except by browsing to the router's local gateway address from the intranet, logging in and viewing its status page. The status page is an HTML document. So I needed to periodically request the router's status page, parse the returned HTML to obtain the current WAN IP address, compare it to the previous value and send out an e-mail to interested parties if it has changed.

The key to this code is Chris Lovett's outstanding SgmlReader. It's available on GotDotNet here. Basically it provides an XmlReader API over any SGML document including HTML. The version that I'm using is 1.2. I see on the GotDotNet site that the current version is 1.5. The SgmlReader allows me to convert the HTML status page to an XPathDocument which I can query and navigate to obtain the current IP address value.

Here's a rough copy of the Linksys router's status page. I browsed to the router's local IP address on our intranet in IE, logged in and did a View Source. Note that some of the information has been sanitized (forged) for this article:

Image 1

In the browser it doesn't look exactly like this; capturing the source of the status page using the View Source context menu doesn't include the graphics that are being served by the router. However this is close enough to follow the article.

So how do we query the router for this status page in our service? We use the HttpWebRequest / HttpWebResponse objects:

C#
// poll the router for its status page...
HttpWebRequest hwrPoll = null;
HttpWebResponse hwrResult = null;
try
{
    hwrPoll = (HttpWebRequest)
               WebRequest.Create(appSettings.routerStatusQueryURL);
    hwrPoll.Timeout        = 60 * 1000; // one minute timeout
    hwrPoll.KeepAlive    = false;
    hwrPoll.AllowAutoRedirect    = true;
    hwrPoll.Credentials    = new
       NetworkCredential(appSettings.routerUserName,
       appSettings.routerPassword);

    hwrResult = (HttpWebResponse)hwrPoll.GetResponse();
}
catch(WebException we)
{
    this.Log("Router Status Page error: " + we.Message);
}
// Q - response status OK ?
if (hwrResult.StatusCode != HttpStatusCode.OK)
{
    // no - log error
    this.Log("Router Status returned: " +
             hwrResult.StatusCode.ToString());
    continue;    // skip further processing
}

Note that the router prompts for Username / Password authentication credentials with a web dialog presented by the browser. We provide a NetworkCredential initialized with a username and password from the configuration file to satisfy the requirement and avoid the dialog box prompt. One potential sticking point here is that a blank username credential must be presented as an HTML space or &#32. An empty string does not suffice and will not be accepted.

Now we convert the HttpWebResponse to XML using the SgmlReader object. We basically pipe the HttpWebResponse stream into another UTF-8 encoded stream which is then input to the SgmlReader. The reader is then called repeatedly to return the HTML nodes that are added to a new XML text stream. Nodes that are labeled as whitespace are discarded. Notice that we save a temporary copy of the unconverted response stream in strResponse to use as the HTML body of the notification e-mail if it's required:

C#
Stream        hwrStream = null;
Encoding        encoding = null;
StreamReader    readStream = null;
String        strResponse = null;
SgmlReader    readSgml = null;
StringWriter    writeString = null;
XmlTextWriter    writeXml = null;
try
{
    hwrStream = hwrResult.GetResponseStream();
    encoding = System.Text.Encoding.GetEncoding("utf-8");

    // pipe the stream to a higher level stream reader with
    // the required encoding format.
    readStream = new StreamReader(hwrStream, encoding);

    // hold onto the response for possible later e-mailing
    strResponse = readStream.ReadToEnd();

    // convert the received HTML to XML for parsing...

    readSgml = new SgmlReader();
    readSgml.DocType = "HTML";
    readSgml.InputStream = new StringReader(strResponse);
    writeString = new StringWriter();
    writeXml = new XmlTextWriter(writeString);
    writeXml.Formatting = Formatting.Indented;

    while (readSgml.Read())
    {
        if (readSgml.NodeType != XmlNodeType.Whitespace)
        {
            writeXml.WriteNode(readSgml, true);
        }
    }

    hwrResult.Close();
    readStream.Close();
}
catch (Exception e)
{
    this.Log("Received HTML parsing error: " + e.Message);
    continue;    // skip further processing...
}

Now let's look at a portion of the XML that was produced from parsing the HTML status page.

This is the parsed XML corresponding to the HTML in the right hand column beneath the LAN: section of the status page. I manually indented it to show the structure. Notice the table column <TD> that has the "IP Address:" content, with the actual value of the LAN (intranet IP address) in the subsequent column:

HTML
<TABLE WIDTH=\"90%\" ID="Table8">
    <TR>
        <TD BGCOLOR=\"6666cc\" WIDTH=\"47%\">
            <FONT COLOR=\"white\" FACE=\"verdana\" SIZE=\"2\">IP Address:</FONT>
        </TD>
        <TD>
            <FONT FACE=\"verdana\" SIZE=\"2\">192.168.1.1</FONT>
        </TD>
    </TR>
    <TR>
        <TD BGCOLOR=\"6666cc\">
            <FONT COLOR=\"white\" FACE=\"verdana\" SIZE=\"2\">Subnet Mask:</FONT>
        </TD>
        <TD>
            <FONT FACE=\"verdana\" SIZE=\"2\">255.255.255.0</FONT>
        </TD>
    </TR>
    <TR>
        <TD BGCOLOR=\"6666cc\">
            <FONT COLOR=\"white\" FACE=\"verdana\" SIZE=\"2\">DHCP server:</FONT>
        </TD>
        <TD>
            <FONT FACE=\"verdana\" SIZE=\"2\">Enabled</FONT>
        </TD>
    </TR>
</TABLE>

Unfortunately, the table column <TD> that we're looking for is later down in the XML following the next "IP Address:" content. So we use the XPathDocument.CreateNavigator() to create an XPathNavigator, then compile an XPath query that returns a node set containing the information that we're looking for; the second occurrence of the <TR><TD><FONT> nodes containing "IP Address:". The values of the nodes at this level are concatenated together, and then the "IP Address:" portion is removed yielding the value of the WAN (Internet) IP Address: field:

C#
StringBuilder    strXml = null;
XPathDocument    xDoc = null;
XPathNavigator    xNav = null;
XPathNodeIterator    xNode = null;
XPathExpression    xExpr = null;
String        strNewIpAddress = null;
try
{
    strXml = new StringBuilder();
    xDoc = new XPathDocument(new
           StringReader(writeString.ToString()));
    xNav = xDoc.CreateNavigator();
    xExpr = xNav.Compile("descendant::TR[TD" +
            "/FONT='IP Address:'][2]");
    if ((xNode = xNav.Select(xExpr)) != null)
    {
        if (xNode.MoveNext())
        {
            strXml.Append(xNode.Current.Value);
        }
    }

    // remove the 'IP Address:' label
    strNewIpAddress = strXml.ToString().Remove(0,
         strXml.ToString().LastIndexOf(':') + 1);
}
catch(Exception e)
{
    this.Log("Can't find Router IP Address: " + e.Message);
    continue;
}

Experts in XPath can probably write a more complex query that will provide the same results. After some minimal reading and experimentation, this is the approach that I arrived at.

Now the router's current WAN IP address is compared to the previous value saved in a configuration file, and, if they are different, a notification e-mail is sent. The body of the e-mail is the unconverted response stream strResponse that was saved above. Then the configuration file is updated with the new IP address so that the next change can be detected:

C#
// Q - is the newly retrieved address different from the prior address ?
if (strNewIpAddress != appSettings.routerIpAddress)
{
    // yes - send a notification e-mail...

    MailMessage    mmIpChanged = null;
    try
    {
        mmIpChanged = new MailMessage();
        mmIpChanged.To = appSettings.notificationToEmailAddress;
        mmIpChanged.From = appSettings.notificationFromEmailAddress;
        mmIpChanged.BodyEncoding = Encoding.UTF8;
        mmIpChanged.BodyFormat = MailFormat.Html;
        mmIpChanged.Body = strResponse;
        mmIpChanged.Subject = appSettings.notificationEmailSubjectLine;

        SmtpMail.SmtpServer = appSettings.smtpServerIpAddress;
        SmtpMail.Send(mmIpChanged);
    }
    catch (Exception e)
    {
        this.Log("Send e-mail error: " + e.ToString() +
                   ", " + e.InnerException.ToString());
    }

    this.Log("Router IP Address changed to: " + strNewIpAddress);

    // now update the prior value in the settings xml file...

    appSettings.routerIpAddress       = strNewIpAddress;
    xsAppSettingsSerializer           = null;
    TextWriter twAppSettingsWriter    = null;
    try
    {
        xsAppSettingsSerializer = new XmlSerializer(typeof(ServiceSettings));
        twAppSettingsWriter = new
                       StreamWriter(RouterIpMonitorSettingsFilename, false);
        xsAppSettingsSerializer.Serialize(twAppSettingsWriter, appSettings);
        twAppSettingsWriter.Close();
        xsAppSettingsSerializer = null;
    }
    catch (XmlException xe)
    {
        this.Log("XML Write settings error: " +
                 xe.ToString() + ", " +
                 xe.InnerException.ToString());
        break;
    }
    catch (Exception e)
    {
        this.Log("XML Write settings error: " + e.ToString() +
                          ", " + e.InnerException.ToString());
        break;
    }
}

Fairly simple and straightforward - right?

Of course, to make this useful we wrap it in a thread that is controlled by the service. The ServiceMain() thread is created and started by the service's Start() method. The thread loads the configuration settings, then enters a while loop. At the top of the while loop, the thread waits on a ManualResetEvent that is signaled by either a TimeSpan expiration or by being Set() from the services' Stop() method. If it was signaled by the ManualResetEvent.Set(), the thread exits ServiceMain(), otherwise it proceeds to query the router for its current IP address. By waiting at the top of the thread, we delay any communication with the router when the service is started while the system is booting.

Here's the code for the Start() and Stop() service methods:

C#
/// <summary>
/// Start service
/// starts up a thread to do the Router Ip Monitoring...
/// </summary>
protected override void Start()
{
    // create threadstart object for ServiceMain
    ThreadStart ts = new ThreadStart(this.ServiceMain);

    // create an un-signaled shutdown event
    _shutdownEvent = new ManualResetEvent(false);

    // create and start the worker thread
    _thread = new Thread(ts);
    _thread.Start();

    // log startup event
    this.Log("Service started");
}

/// <summary>
/// Stop service
/// stops the Router Ip Monitoring thread...
/// </summary>
protected override void Stop()
{
    // signal the thread to stop
    _shutdownEvent.Set();

    // wait for thread to stop for up to 10 seconds
    _thread.Join(10000);

    // log stopping event
    this.Log("Service stopped");
}

and here's the remainder of the ServiceMain() code:

C#
    /// <summary>
    /// ServiceMain
    /// This is the worker thread that is started and stopped by
    /// the service control manager via Start() and Stop(). When 
    /// started, it reads the service settings file then enters a
    /// loop where it sleeps for an interval, polls the router for
    /// it's status, parses the response, compares the parsed IP
    /// address to the previous value and if its different sends
    /// a notification e-mail.
    /// </summary>
    protected void ServiceMain()
    {
        // retrieve the settings (and prior
        // router IP address) stored as XML...

        String RouterIpMonitorSettingsFilename = 
               "RouterIpMonitorSettings.xml";

        // instantiate a settings object
        ServiceSettings appSettings = new ServiceSettings();

        // read (serialize in) the XML settings file...

        TextReader trAppSettingsReader = null;
        XmlSerializer xsAppSettingsSerializer = null;
        try
        {
            trAppSettingsReader = 
              new StreamReader(RouterIpMonitorSettingsFilename);
            xsAppSettingsSerializer = 
              new XmlSerializer(typeof(ServiceSettings));
            appSettings = (ServiceSettings)
              xsAppSettingsSerializer.Deserialize(trAppSettingsReader);
            trAppSettingsReader.Close();
            xsAppSettingsSerializer = null;
        }
        catch (XmlException xe)
        {
            this.Log("Read XML settings error: " + xe.ToString());

            // problem with settings file, use defaults
            appSettings.Default();
        }
        catch (FileNotFoundException e)
        {
            this.Log(RouterIpMonitorSettingsFilename + 
              " not found. Using default values. " + e.ToString());

            // no settings file found, use defaults
            appSettings.Default();
        }

        // get router poll interval from settings file
        int nDelayMinutes;
        try
        {
            nDelayMinutes = Int32.Parse(appSettings.routerPollIntervalMinutes);
        }
        catch (Exception e)
        {
            this.Log("routerPollIntervalMinutes error. " + 
                  "Using default value. " + e.ToString());
            nDelayMinutes = 5;
        }

        // initialize delay object with hours, minutes, seconds
        _threadDelay = new TimeSpan(0, nDelayMinutes, 0);

        // main service loop...

        bool    bSignaled = false;
        while (true)
        {
            // wait for time delay or signal
            bSignaled = _shutdownEvent.WaitOne(_threadDelay, true);

            // Q - were we signaled to terminate ?
            if (bSignaled == true)
            {
                // yes - exit the while forever loop...
                break;
            }

#region Router Polling Code
#endregion

        } // while (true)
    } // ServiceMain()

Using the code

When I originally wrote this code, I wrote a console application to debug the logic. When I had that working, I generated a Windows Service project and added the code into the Start() method. I then manually installed it, tested it, and forgot about it. I use it on my home server as well to allow me to access that network from the office. It has been working for us for almost two years. Sometimes we go for three months or more without the WAN IP address changing, but it never fails that when you go to access the office or home remotely, the IP address has changed.

In the back of my mind, I toyed with the idea of documenting this and writing an article for CodeProject. Then along came David Hoyt's article: Self installing .NET service using Win32 API and I was inspired. I downloaded the most recent code from his website, added it to my project in the HoytSoft folder and was up and running right away; with the bonus that I didn't have to write a service installer, and could debug the code "in situ".

The service configuration is handled by an XML file: RouterIpMonitorSettings.xml that is located in the same directory as the service .EXE file. If this file doesn't exist when the service is started, a default file is created the first time the service successfully queries the router and finds an IP address that is different from the default value. I have supplied a sanitized version of my settings file in the download. I recommend that you manually edit the XML file to contain your settings. When you have it working, you might want to update the default method in the ServiceSettings class to contain these settings.

Notification e-mails are sent using the MailMessage object which should work on Windows 2000, Windows XP Professional and Windows Server 2003 family installs. It uses CDO.Message and requires access to an SMTP server. If the SMTP server is not located on the service machine, the server must be configured to accept mail relay from the service machine. Authentication requirements are not addressed.

Events of interest are written to the System Event log under Services. This occurs even when debugging, so check the event log to get additional information about what's happened.

Points of Interest

There's plenty of room for improvement. After all, this was my second C# programming foray after HelloWorld.cs:

  • The embedded configuration file handling in the ServiceSettings class should be split out into its own file.
  • The XPath query could probably be made configurable to allow use with different routers.
  • The notification mechanism could be something other than e-mail.
  • A Windows Forms control panel application could be written for handling the service configuration.
  • Event logging could be made configurable.

History

  • 1.0 - 01/12/04 - First written.
  • 1.1 - 04/30/05 - Increased poll delay from 1 minute to 5 minutes.
  • 1.2 - 10/12/05 - Rewritten to use HoytSoft "Self-Installing .NET Service using Win32 API". Made poll interval configurable. Improved commenting for CodeProject article. Wrote the article.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here



Comments and Discussions

 
GeneralMy vote of 5 Pin
Thomas ktg30-Mar-14 20:05
Thomas ktg30-Mar-14 20:05 
Generalwhere to embed this code & how to set up this code to be run....... Pin
Ravis26-Mar-08 1:20
Ravis26-Mar-08 1:20 
GeneralPublic IP Monitor Pin
Member 395094429-Jan-08 8:13
Member 395094429-Jan-08 8:13 
Questionwhere is the console version? Pin
AlbertuzMagnuz17-Sep-07 16:23
AlbertuzMagnuz17-Sep-07 16:23 
GeneralBlank Username Pin
Santanu Biswas1-Jul-07 11:47
Santanu Biswas1-Jul-07 11:47 
GeneralMethod I prefer Pin
sides_dale29-Oct-05 19:38
sides_dale29-Oct-05 19:38 
GeneralRe: Method I prefer Pin
Steven J. Ackerman30-Oct-05 4:48
Steven J. Ackerman30-Oct-05 4:48 
GeneralRe: Method I prefer Pin
sides_dale7-Nov-05 15:46
sides_dale7-Nov-05 15:46 
GeneralYet another possibility. Pin
Charlie Tame26-Oct-05 16:19
Charlie Tame26-Oct-05 16:19 
GeneralRe: Yet another possibility. Pin
Steven J. Ackerman27-Oct-05 3:40
Steven J. Ackerman27-Oct-05 3:40 
GeneralRe: Yet another possibility. Pin
acpian@hotmail.com20-Apr-06 8:21
acpian@hotmail.com20-Apr-06 8:21 
GeneralAlternative Pin
Adam Byrne18-Oct-05 22:37
Adam Byrne18-Oct-05 22:37 
GeneralAnother Way Pin
Matt Brunk18-Oct-05 17:23
Matt Brunk18-Oct-05 17:23 
NewsThe better way Pin
leppie18-Oct-05 11:46
leppie18-Oct-05 11:46 
GeneralRe: The better way Pin
Steven J. Ackerman18-Oct-05 14:58
Steven J. Ackerman18-Oct-05 14:58 
AnswerRe: The better way Pin
leppie18-Oct-05 18:44
leppie18-Oct-05 18:44 
GeneralRe: The better way Pin
Steven J. Ackerman19-Oct-05 2:38
Steven J. Ackerman19-Oct-05 2:38 
AnswerRe: The better way Pin
leppie19-Oct-05 3:04
leppie19-Oct-05 3:04 
GeneralRe: The better way Pin
Steven J. Ackerman19-Oct-05 3:13
Steven J. Ackerman19-Oct-05 3:13 
AnswerRe: The better way Pin
leppie19-Oct-05 3:26
leppie19-Oct-05 3:26 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.