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:
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:
HttpWebRequest hwrPoll = null;
HttpWebResponse hwrResult = null;
try
{
hwrPoll = (HttpWebRequest)
WebRequest.Create(appSettings.routerStatusQueryURL);
hwrPoll.Timeout = 60 * 1000;
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);
}
if (hwrResult.StatusCode != HttpStatusCode.OK)
{
this.Log("Router Status returned: " +
hwrResult.StatusCode.ToString());
continue;
}
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  . 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:
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");
readStream = new StreamReader(hwrStream, encoding);
strResponse = readStream.ReadToEnd();
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;
}
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:
<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:
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);
}
}
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:
if (strNewIpAddress != appSettings.routerIpAddress)
{
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);
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:
protected override void Start()
{
ThreadStart ts = new ThreadStart(this.ServiceMain);
_shutdownEvent = new ManualResetEvent(false);
_thread = new Thread(ts);
_thread.Start();
this.Log("Service started");
}
protected override void Stop()
{
_shutdownEvent.Set();
_thread.Join(10000);
this.Log("Service stopped");
}
and here's the remainder of the ServiceMain()
code:
protected void ServiceMain()
{
String RouterIpMonitorSettingsFilename =
"RouterIpMonitorSettings.xml";
ServiceSettings appSettings = new ServiceSettings();
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());
appSettings.Default();
}
catch (FileNotFoundException e)
{
this.Log(RouterIpMonitorSettingsFilename +
" not found. Using default values. " + e.ToString());
appSettings.Default();
}
int nDelayMinutes;
try
{
nDelayMinutes = Int32.Parse(appSettings.routerPollIntervalMinutes);
}
catch (Exception e)
{
this.Log("routerPollIntervalMinutes error. " +
"Using default value. " + e.ToString());
nDelayMinutes = 5;
}
_threadDelay = new TimeSpan(0, nDelayMinutes, 0);
bool bSignaled = false;
while (true)
{
bSignaled = _shutdownEvent.WaitOne(_threadDelay, true);
if (bSignaled == true)
{
break;
}
#region Router Polling Code
#endregion
}
}
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.