Just a short, simple blog for Bob to share his thoughts.
29 June 2012 • by Bob • FTP, IIS
I had a great question from a customer earlier today, and I thought that it was worth blogging about. The problem that he was running into was that he was seeing the following error when he was trying to query the runtime state for the FTP service in an application that he was writing:
Class not registered (Exception from HRESULT: 0x80040154 (REGDB_E_CLASSNOTREG))
He was using Visual Basic, and his code looked okay to me, so for the moment I was stumped.
I'm more of a C# guy, and I remembered that I had written the following blog many years ago:
Viewing current FTP7 sessions using C#
I copied the code from that blog into a new Visual Studio project, and I got the same error that he was seeing when I ran my code - this had me a little more confused. Have you ever said to yourself, "Darn - I know that worked the other day...?" ;-]
I knew that there is more than one way to access the runtime state, so I rewrote my sample application using two different approaches:
Method #1:
AppHostAdminManager objAdminManager = new AppHostAdminManager(); IAppHostElement objSitesElement = objAdminManager.GetAdminSection("system.applicationHost/sites", "MACHINE/WEBROOT/APPHOST"); uint intSiteCount = objSitesElement.Collection.Count; for (int intSite = 0; intSite < intSiteCount; ++intSite) { IAppHostElement objFtpSite = objSitesElement.Collection[intSite]; Console.WriteLine("Name: " + objFtpSite.Properties["name"].StringValue); IAppHostElement objFtpSiteElement = objFtpSite.ChildElements["ftpServer"]; IAppHostPropertyCollection objProperties = objFtpSiteElement.Properties; try { IAppHostProperty objState = objProperties["state"]; string ftpState = objState.StringValue; Console.WriteLine("State: " + ftpState); } catch (System.Exception ex) { Console.WriteLine("\r\nError: {0}", ex.Message); } }
Method #2:
ServerManager manager = new ServerManager(); foreach (Site site in manager.Sites) { Console.WriteLine("Name: " + site.Name); ConfigurationElement ftpServer = site.GetChildElement("ftpServer"); try { foreach (ConfigurationAttribute attrib in ftpServer.Attributes) { Console.WriteLine(attrib.Name + ": " + attrib.Value); } } catch (System.Exception ex) { Console.WriteLine("\r\nError: {0}", ex.Message); } }
Both of these methods returned the same COM error, so this was getting weird for me. Hmm...
The FTP runtime state is exposed through a COM interface, and that is implemented in a DLL that is named "ftpconfigext.dll
". That file should be registered when you install IIS, and I re-registered it on my system just for good measure, but that didn't resolve the issue.
I had a brief conversation with one of my coworkers, Eok Kim, about the error that I was seeing. He also suggested re-registering the DLL, but something else that he said about searching the registry for the InprocServer32 entry made me wonder if the whole problem was related to the bitness of my application.
To make a long story short - that was the whole problem.
Both the customer and I were creating 32-bit .NET applications, and the COM interface for the FTP runtime state is implemented in a 64-bit-only DLL. Once we both changed our projects to compile for 64-bit platforms, we were both able to get the code to run. (Coincidentally, all I had was a 32-bit system when I wrote my original blog, so I probably would have run into this sooner if I had owned a 64-bit system way back then. ;-])
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
25 May 2012 • by Bob • IIS, LogParser
In Part 4 of this series, I'll show you how you can do a couple of cool things:
For the data source for my custom plug-in, I thought that it would be cool to consume the data from FTP 7's Runtime Status (RSCA). If you've followed some of my old blogs, you would have seen that around five years ago I wrote the following pair of blogs about programmatically viewing FTP 7 sessions:
I'm going to recycle some of the FTP RSCA concepts from those blogs in order to create my COM plug-in.
If you're like me, you already realize that the existing features of Log Parser simply rock. But what most people don't realize is that Log Parser lets you extend the functionality by adding new input formats, so you can consume the data from any place where you feel compelled to sit down and write your own Log Parser module.
As a quick reminder, Log Parser supports the following built-in input formats:
This last input format, COM, is how you interface with Log Parser in order to create your own input formats. When you install Log Parser, there are a few COM-based samples in the Log Parser directory, and you can take a look at those when you get the chance.
To start with, your COM plug-in has to support a few public methods - and each of these will be more clear when I create my plug-in later:
Method Name | Description |
---|---|
OpenInput |
Opens your data source and sets up any initial environment settings. |
GetFieldCount |
Returns the number of fields that your plug-in will provide. |
GetFieldName |
Returns the name of a specified field. |
GetFieldType |
Returns the datatype of a specified field. |
GetValue |
Returns the value of a specified field. |
ReadRecord |
Reads the next record from your data source. |
CloseInput |
Closes your data source and cleans up any environment settings. |
After you've created and registered your COM plug-in, you will call it by using something like the following syntax:
logparser "SELECT * FROM FOO" -i:COM -iProgID:BAR
In this example, FOO is some data source that makes sense to your plug-in, and BAR is the COM class name for your plug-in.
I'm going to demonstrate how to create a COM component as a scriptlet, and then I'll call that from Log Parser to process the data. I chose to use a scriptlet for this demo because they are quick to design and they're easily portable. Since no compilation is required, updates take place on the fly. All of that being said, if I were writing a real COM plug-in for Log Parser, I would use C# or C++.
To create the sample COM plug-in, copy the following code into a text file, and save that file as "MSUtil.LogQuery.FtpRscaScriptlet.sct" to your computer. (Note: The *.SCT file extension tells Windows that this is a scriptlet file.)
<SCRIPTLET> <registration Description="FTP RSCA for Log Parser Scriptlet" Progid="MSUtil.LogQuery.FtpRscaScriptlet" Classid="{4e616d65-6f6e-6d65-6973-526f62657274}" Version="1.00" Remotable="False" /> <comment> EXAMPLE 1: logparser "SELECT * FROM ftp.example.com" -i:COM -iProgID:MSUtil.LogQuery.FtpRscaScriptlet EXAMPLE 2: logparser "SELECT * FROM 1" -i:COM -iProgID:MSUtil.LogQuery.FtpRscaScriptlet </comment> <implements id="Automation" type="Automation"> <method name="OpenInput"> <parameter name="strValue"/> </method> <method name="GetFieldCount" /> <method name="GetFieldName"> <parameter name="intFieldIndex"/> </method> <method name="GetFieldType"> <parameter name="intFieldIndex"/> </method> <method name="ReadRecord" /> <method name="GetValue"> <parameter name="intFieldIndex"/> </method> <method name="CloseInput"> <parameter name="blnAbort"/> </method> </implements> <SCRIPT LANGUAGE="VBScript"> Option Explicit Dim objAdminManager,objSessionDictionary Dim objSitesSection,objSitesCollection Dim objSiteElement,objFtpServerElement Dim objSessionsElement,objSessionElement Dim intSiteElementPos,intSession,intRecordIndex Dim clsSession intRecordIndex = -1 ' -------------------------------------------------------------------------------- ' Open an input session that reads FTP RSCA data and stores it in a dictionary object. ' -------------------------------------------------------------------------------- Public Function OpenInput(strValue) Set objSessionDictionary = CreateObject("Scripting.Dictionary") Set objAdminManager = CreateObject("Microsoft.ApplicationHost.WritableAdminManager") objAdminManager.CommitPath = "MACHINE/WEBROOT/APPHOST" Set objSitesSection = objAdminManager.GetAdminSection("system.applicationHost/sites", "MACHINE/WEBROOT/APPHOST") Set objSitesCollection = objSitesSection.Collection If IsNumeric(strValue) Then intSiteElementPos = FindElement(objSitesCollection, "site", Array("id", strValue)) Else intSiteElementPos = FindElement(objSitesCollection, "site", Array("name", strValue)) End If If intSiteElementPos > -1 Then Set objSiteElement = objSitesCollection.Item(intSiteElementPos) Set objFtpServerElement = objSiteElement.ChildElements.Item("ftpServer") Set objSessionsElement = objFtpServerElement.ChildElements.Item("sessions").Collection For intSession = 0 To CLng(objSessionsElement.Count)-1 Set objSessionElement = objSessionsElement.Item(intSession) Set clsSession = New Session clsSession.CurrentDateTime = GetUtcDate() clsSession.ClientIp = objSessionElement.GetPropertyByName("clientIp").Value clsSession.SessionId = objSessionElement.GetPropertyByName("sessionId").Value clsSession.SessionStartTime = objSessionElement.GetPropertyByName("sessionStartTime").Value clsSession.UserName = objSessionElement.GetPropertyByName("userName").Value clsSession.CurrentCommand = objSessionElement.GetPropertyByName("currentCommand").Value clsSession.PreviousCommand = objSessionElement.GetPropertyByName("previousCommand").Value clsSession.CommandStartTime = objSessionElement.GetPropertyByName("commandStartTime").Value clsSession.BytesSent = objSessionElement.GetPropertyByName("bytesSent").Value clsSession.BytesReceived = objSessionElement.GetPropertyByName("bytesReceived").Value clsSession.LastErrorStatus = objSessionElement.GetPropertyByName("lastErrorStatus").Value objSessionDictionary.Add intSession,clsSession Next End If End Function ' -------------------------------------------------------------------------------- ' Close the input session. ' -------------------------------------------------------------------------------- Public Function CloseInput(blnAbort) intRecordIndex = -1 objSessionDictionary.RemoveAll End Function ' -------------------------------------------------------------------------------- ' Return the count of fields. ' -------------------------------------------------------------------------------- Public Function GetFieldCount() GetFieldCount = 11 End Function ' -------------------------------------------------------------------------------- ' Return the specified field's name. ' -------------------------------------------------------------------------------- Public Function GetFieldName(intFieldIndex) Select Case intFieldIndex Case 0 GetFieldName = "currentDateTime" Case 1 GetFieldName = "clientIp" Case 2 GetFieldName = "sessionId" Case 3 GetFieldName = "sessionStartTime" Case 4 GetFieldName = "userName" Case 5 GetFieldName = "currentCommand" Case 6 GetFieldName = "previousCommand" Case 7 GetFieldName = "commandStartTime" Case 8 GetFieldName = "bytesSent" Case 9 GetFieldName = "bytesReceived" Case 10 GetFieldName = "lastErrorStatus" End Select End Function ' -------------------------------------------------------------------------------- ' Return the specified field's type. ' -------------------------------------------------------------------------------- Public Function GetFieldType(intFieldIndex) Const TYPE_INTEGER = 1 Const TYPE_REAL = 2 Const TYPE_STRING = 3 Const TYPE_TIMESTAMP = 4 Const TYPE_NULL = 5 Select Case intFieldIndex Case 0 GetFieldType = TYPE_STRING Case 1 GetFieldType = TYPE_STRING Case 2 GetFieldType = TYPE_STRING Case 3 GetFieldType = TYPE_STRING Case 4 GetFieldType = TYPE_STRING Case 5 GetFieldType = TYPE_STRING Case 6 GetFieldType = TYPE_STRING Case 7 GetFieldType = TYPE_STRING Case 8 GetFieldType = TYPE_INTEGER Case 9 GetFieldType = TYPE_INTEGER Case 10 GetFieldType = TYPE_INTEGER End Select End Function ' -------------------------------------------------------------------------------- ' Return the specified field's value. ' -------------------------------------------------------------------------------- Public Function GetValue(intFieldIndex) If objSessionDictionary.Count > 0 Then Select Case intFieldIndex Case 0 GetValue = objSessionDictionary(intRecordIndex).CurrentDateTime Case 1 GetValue = objSessionDictionary(intRecordIndex).ClientIp Case 2 GetValue = objSessionDictionary(intRecordIndex).SessionId Case 3 GetValue = objSessionDictionary(intRecordIndex).SessionStartTime Case 4 GetValue = objSessionDictionary(intRecordIndex).UserName Case 5 GetValue = objSessionDictionary(intRecordIndex).CurrentCommand Case 6 GetValue = objSessionDictionary(intRecordIndex).PreviousCommand Case 7 GetValue = objSessionDictionary(intRecordIndex).CommandStartTime Case 8 GetValue = objSessionDictionary(intRecordIndex).BytesSent Case 9 GetValue = objSessionDictionary(intRecordIndex).BytesReceived Case 10 GetValue = objSessionDictionary(intRecordIndex).LastErrorStatus End Select End If End Function ' -------------------------------------------------------------------------------- ' Read the next record, and return true or false if there is more data. ' -------------------------------------------------------------------------------- Public Function ReadRecord() If objSessionDictionary.Count > 0 Then If intRecordIndex < (objSessionDictionary.Count-1) Then intRecordIndex = intRecordIndex + 1 ReadRecord = True Else ReadRecord = False End If End If End Function ' -------------------------------------------------------------------------------- ' Return the current UTC date/time. ' -------------------------------------------------------------------------------- Private Function GetUtcDate() Dim dtmNow,dtmUtc,strUtc Dim objShell,lngActiveTimeBias dtmNow = Now() Set objShell = CreateObject("WScript.Shell") lngActiveTimeBias = CLng(objShell.RegRead("HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\TimeZoneInformation\ActiveTimeBias")) dtmUtc = DateAdd("n",lngActiveTimeBias,dtmNow) strUtc = Year(dtmUtc) & "-" & _ Right("0" & Month(dtmUtc),2) & "-" & _ Right("0" & Day(dtmUtc),2) & "T" & _ Right("0" & Hour(dtmUtc),2) & ":" & _ Right("0" & Minute(dtmUtc),2) & ":" & _ Right("0" & Second(dtmUtc),2) & ".000Z" GetUtcDate = strUtc End Function ' -------------------------------------------------------------------------------- ' Return an element's position in a collection. ' -------------------------------------------------------------------------------- Private Function FindElement(objCollection, strElementTagName, arrValuesToMatch) Dim i,elem,matches,j,prop,value For i = 0 To CInt(objCollection.Count) - 1 Set elem = objCollection.Item(i) If elem.Name = strElementTagName Then matches = True For j = 0 To UBound(arrValuesToMatch) Step 2 Set prop = elem.GetPropertyByName(arrValuesToMatch(j)) value = prop.Value If Not IsNull(value) Then value = CStr(value) End If If Not value = CStr(arrValuesToMatch(j + 1)) Then matches = False Exit For End If Next If matches Then Exit For End If End If Next If matches Then FindElement = i Else FindElement = -1 End If End Function ' -------------------------------------------------------------------------------- ' Define a generic class for holding session data. ' -------------------------------------------------------------------------------- Class Session Public CurrentDateTime Public ClientIp Public SessionId Public SessionStartTime Public UserName Public CurrentCommand Public PreviousCommand Public CommandStartTime Public BytesSent Public BytesReceived Public LastErrorStatus End Class </SCRIPT> </SCRIPTLET>
After you've saved the scriptlet code to your computer, you will register it by using the following syntax:
regsvr32 MSUtil.LogQuery.FtpRscaScriptlet.sct
At the very minimum, you can now use the COM plug-in with Log Parser by using syntax like the following:
logparser "SELECT * FROM ftp.example.com" -i:COM -iProgID:MSUtil.LogQuery.FtpRscaScriptlet
Next, we'll analyze what the scriptlet does.
Here are the different parts of the scriptlet and what they do:
OpenInput()
method opens the FTP RSCA data for a specific FTP site:SELECT * FROM ftp.example.com
"SELECT * FROM 1
"CloseInput()
method doesn't do much in this script, but your COM plug-ins may require more clean up depending on your data source.GetFieldCount()
method simply returns the number of data fields in each record of your data.GetFieldName()
method returns the name of a field that is passed to the method as a number.GetFieldType()
method returns the data type of a field that is passed to the method as a number; Log Parser supports the following five data types for COM plug-ins:TYPE_INTEGER
TYPE_REAL
TYPE_STRING
TYPE_TIMESTAMP
TYPE_NULL
GetValue()
method returns the data value of a field that is passed to the method as a number.ReadRecord()
method moves to the next record in your data set; this method returns True if there is data to read, or False when the end of data is reached.GetUtcDate()
method returns the current date and time in Universal Coordinated Time (UTC) format.FindElement()
method locates a specified element's position within an IIS collection, or -1 if the element cannot be found. This method is used to determine the specified FTP site within the IIS configuration.Session
class is a generic construct to hold the information for a single FTP RSCA data record.This wraps up the description of how the scriptlet works as a COM plug-in, in the next part of my blog we'll look at how to actually use it.
Earlier I showed you how you can use the COM plug-in with Log Parser by using syntax like the following:
logparser "SELECT * FROM ftp.example.com" -i:COM -iProgID:MSUtil.LogQuery.FtpRscaScriptlet
This will return output that resembles something like the following:
currentDateTime |
clientIp |
sessionId |
sessionStartTime |
userName |
currentCommand |
previousCommand |
commandStartTime |
bytesSent |
bytesReceived |
lastErrorStatus |
---|---|---|---|---|---|---|---|---|---|---|
---------------- |
-------- |
--------- |
---------------- |
-------- |
-------------- |
--------------- |
---------------- |
--------- |
------------- |
--------------- |
2012-05-25T11:42:11.000Z |
10.121.75.26 |
3950d1e5-3e94-4734-a89a-9768c52aa924 |
2012-05-25T10:08:09.861Z |
robert |
PASS |
USER |
2012-05-25T11:42:06.080Z |
6049 |
1193 |
0 |
2012-05-25T11:42:11.000Z |
10.121.75.26 |
d1591fa8-3b09-4afd-b2c0-950421ba79fe |
2012-05-25T10:08:18.184Z |
robert |
RETR |
NLST |
2012-05-25T11:42:07.172Z |
5887 |
1169 |
0 |
2012-05-25T11:42:11.000Z |
10.121.75.26 |
0f92b5ed-920a-441d-a15d-39056a36f2a4 |
2012-05-25T10:08:22.327Z |
robert |
NOOP |
NLST |
2012-05-25T11:41:40.917Z |
5857 |
1163 |
0 |
2012-05-25T11:42:11.000Z |
10.121.75.26 |
16925f0d-1fc5-4cb7-be19-ab33face2da9 |
2012-05-25T10:08:48.756Z |
NLST |
SYST |
2012-05-25T11:41:44.770Z |
6026 |
1192 |
0 |
|
2012-05-25T11:42:11.000Z |
10.121.75.26 |
aeb68389-869b-4afc-8c81-47b578e74824 |
2012-05-25T10:08:54.214Z |
USER |
HOST |
2012-05-25T11:41:42.087Z |
5864 |
1168 |
0 |
|
2012-05-25T11:42:11.000Z |
10.121.75.26 |
4ed55569-ee25-47d1-8388-12cdb90a1c07 |
2012-05-25T10:12:31.555Z |
alice |
RETR |
NLST |
2012-05-25T11:42:01.789Z |
5780 |
1138 |
0 |
2012-05-25T11:42:11.000Z |
10.121.75.26 |
d6b16bb4-cb65-492d-a9fa-fbd6b72de0f3 |
2012-05-25T10:12:54.591Z |
bob |
NOOP |
NLST |
2012-05-25T11:41:46.563Z |
5748 |
1130 |
0 |
Statistics: |
||||||||||
----------- |
||||||||||
Elements processed: |
7 |
|||||||||
Elements output: |
7 |
|||||||||
Execution time: |
0.12 seconds |
That information is something of a jumbled mess, and we can clean that up a bit by simply choosing the fields that we might be interested in:
userName |
currentCommand |
commandStartTime |
---|---|---|
-------- |
-------------- |
---------------- |
robert |
PASS |
2012-05-25T11:42:06.080Z |
robert |
RETR |
2012-05-25T11:42:07.172Z |
robert |
NOOP |
2012-05-25T11:41:40.917Z |
NLST |
2012-05-25T11:41:44.770Z |
|
USER |
2012-05-25T11:41:42.087Z |
|
alice |
RETR |
2012-05-25T11:42:01.789Z |
bob |
NOOP |
2012-05-25T11:41:46.563Z |
Statistics: |
||
----------- |
||
Elements processed: |
7 |
|
Elements output: |
7 |
|
Execution time: |
0.12 seconds |
Now let's look at some interesting data - one of the main focuses for this blog series is charting with Log Parser, so let's look at doing something useful with the data. To start with, here's how to create a pie chart that counts the number of sessions by user name:
logparser "SELECT
This will generate a chart like the following:
Here's a variation on that script that illustrates how to create a pie chart that counts the number of authenticated sessions versus anonymous sessions:
logparser "SELECT
This will generate a chart like the following:
We can also do line, bar, and column charts with the data:
logparser "SELECT
The above code sample will generate a chart like the following:
There's a lot more that we could do with this, but eventually I have to get some sleep, so I think that's enough fun for the day.
In this blog post, I've shown you how to add your own custom input format to Log Parser by creating scriptlet as a COM plug-in. I hope that you take this information and create some great Log Parser plug-ins of your own.
;-]
26 April 2012 • by Bob • FTP, Scripting
We had a customer question the other day about configuring FTP Client Certificate Authentication in FTP 7.0 and in FTP 7.5. It had been a while since the last time that I had configured those settings on an FTP server, so I thought that it would be great to re-familiarize myself with that feature. To my initial dismay, it was a little more difficult than I had remembered, because there are a lot of parts to be configured.
That being said, there are a few primary activities that you need to know about and configure correctly:
I will explain each of those in this blog, although I will defer some of the details for Active Directory mapping to an excellent blog series that I discovered by Vivek Kumbhar.
There are several settings that you need to configure for the FTP server; unfortunately there is no user interface for those settings, so you might want to familiarize yourself with the following settings:
At first I had made a batch file that was configuring these settings by using AppCmd, but I eventually abandoned that script and wrote the following VBScript code to configure all of the settings at one time - the only parts that you need to change is your site name and the hash value your SSL certificate, which are highlighted in yellow:
Set adminManager = CreateObject("Microsoft.ApplicationHost.WritableAdminManager") adminManager.CommitPath = "MACHINE/WEBROOT/APPHOST" Set sitesSection = adminManager.GetAdminSection("system.applicationHost/sites", "MACHINE/WEBROOT/APPHOST") Set sitesCollection = sitesSection.Collection siteElementPos = FindElement(sitesCollection, "site", Array("name", "ftp.contoso.com")) If (addElementPos = -1) Then WScript.Echo "Element not found!" WScript.Quit End If Set siteElement = sitesCollection.Item(siteElementPos) Set ftpServerElement = siteElement.ChildElements.Item("ftpServer") Set securityElement = ftpServerElement.ChildElements.Item("security") Set sslClientCertificatesElement = securityElement.ChildElements.Item("sslClientCertificates") sslClientCertificatesElement.Properties.Item("clientCertificatePolicy").Value = "CertRequire" sslClientCertificatesElement.Properties.Item("useActiveDirectoryMapping").Value = True Set authenticationElement = securityElement.ChildElements.Item("authentication") Set clientCertAuthenticationElement = authenticationElement.ChildElements.Item("clientCertAuthentication") clientCertAuthenticationElement.Properties.Item("enabled").Value = True Set sslElement = securityElement.ChildElements.Item("ssl") sslElement.Properties.Item("serverCertHash").Value = "57686f6120447564652c2049495320526f636b73" sslElement.Properties.Item("controlChannelPolicy").Value = "SslRequire" sslElement.Properties.Item("dataChannelPolicy").Value = "SslRequire" adminManager.CommitChanges Function FindElement(collection, elementTagName, valuesToMatch) For i = 0 To CInt(collection.Count) - 1 Set element = collection.Item(i) If element.Name = elementTagName Then matches = True For iVal = 0 To UBound(valuesToMatch) Step 2 Set property = element.GetPropertyByName(valuesToMatch(iVal)) value = property.Value If Not IsNull(value) Then value = CStr(value) End If If Not value = CStr(valuesToMatch(iVal + 1)) Then matches = False Exit For End If Next If matches Then Exit For End If End If Next If matches Then FindElement = i Else FindElement = -1 End If End Function
Once you have configured your FTP settings, you should have an FTP site that resembles the following in your ApplicationHost.config file:
<site name="ftp.contoso.com" id="2"> <application path="/"> <virtualDirectory path="/" physicalPath="c:\inetpub\ftproot" /> </application> <bindings> <binding protocol="ftp" bindingInformation="*:21:" /> </bindings> <ftpServer> <security> <ssl serverCertHash="57686f6120447564652c2049495320526f636b73" ssl128="false" controlChannelPolicy="SslRequire" dataChannelPolicy="SslRequire" /> <authentication> <basicAuthentication enabled="false" /> <anonymousAuthentication enabled="false" /> <clientCertAuthentication enabled="true" /> </authentication> <sslClientCertificates clientCertificatePolicy="CertRequire" useActiveDirectoryMapping="true" /> </security> </ftpServer> </site>
More details about these settings can be found in the configuration reference articles that I mentioned in the beginning of this blog post, and additional information about configuring FTP over SSL can be found in the following walkthrough:
The next part of this process is kind of tricky; you need to accomplish all of the following:
That makes it all sound so easy, but it can be very tricky. That being said, as I mentioned earlier, as I was putting together my notes to write this blog, I stumbled across a great blog series by Vivek Kumbhar, where he goes into great detail when describing all of the steps to set up the Active Directory mapping. With that in mind, instead of trying to rewrite what Vivek has already documented, I will include links to his blog series:
I have to give Vivek full credit where it's due - he wrote a truly great blog series, and he included a lot more detail in his blog series than I had originally planned to include in this blog. (In my humble opinion, Vivek's blog series is the best documentation that I have seen for this feature.)
To test out client certificates, I used both the SmartFTP GUI-based FTP client and the MOVEit-Freely command-line FTP client; both of which I discussed in my FTP Clients blog series some time ago.
To configure the SmartFTP client, I just needed to enable and specify the correct client certificate in the properties for my connection:
For the MOVEit-Freely FTP client, I just needed to specify the correct parameters on the command line:
ftps.exe -z -e:on -pfxfile:administrator.pfx -pfxpw:"P@ssw0rd" -user:anonymous -password:"someone@contoso.com"
The important settings are the pfxfile
and pfxpw
values, where pfxfile
is the name of the PFX file that holds your client certificate, and pfxpw
is the password for the PFX file. (The username
and password
values will be ignored for the most part, because you will actually be logged in through your client certificate, so you can leave those as anonymous.)
For more information about these two FTP clients, see the following blog posts:
FTP client certificates are definitely a bit of a challenge to configure correctly, but it's not an impossible task to get this feature working.
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
20 April 2012 • by Bob • IIS, FTP, Extensibility
A few years ago I wrote a blog that was titled "FTP 7.5 Service Extensibility References", in which I discussed the extensibility APIs that we added in FTP 7.5. Over the next couple of years I followed that initial blog with a series of walkthroughs on IIS.net and several related blog posts. Here are just a few examples:
In today's blog I'd like to discuss some of the extensibility features that we added in FTP 8.0, and show you how you can use those in your FTP providers.
In FTP 7.5 we provided interfaces for IFtpAuthenticationProvider
and IFtpRoleProvider
, which respectively allowed developers to create FTP providers that performed user and role lookups. In FTP 8.0 we added a logical extension to that API set with IFtpAuthorizationProvider
interface, which allows developers to create FTP providers that perform authorization tasks.
With that in mind, I wrote the following walkthrough on the IIS.net web site:
The title pretty much says it all: the provider that I describe in that walkthrough will walk you through the steps that are required to create an FTP provider that provides custom user authentication, verification of role memberships, and authorization lookups on a per-path basis.
In FTP 7.5 if you wanted your provider to respond to specific user activity, the best way to do so was to implement the IFtpLogProvider.Log()
interface and use that to provide a form of pseudo-event handling. In FTP 8.0 we add two event handling interfaces, IFtpPreprocessProvider
and IFtpPostprocessProvider
, which respectively allow developers to write providers that implement functionality before or after events have occurred.
With that in mind, I wrote the following walkthrough on the IIS.net web site:
Once again, the title says it all: the provider that I describe in that walkthrough will walk you through the steps that are required to create an FTP provider that prevents FTP clients from downloading more files per-session than you have allowed in your configuration settings.
Happy coding!
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
19 March 2012 • by Bob • FTP, IIS
One of the biggest asks from our customers over the years was to provide a way to prevent brute-force password attacks on the FTP service. On several of the FTP sites that I host, I used to see a large number of fraudulent logon requests from hackers that were trying to guess a username/password combination. My first step in trying to prevent these kinds of attacks, like most good administrators, was to implement strong password requirements and password lockout policies. This was a good first step, but there is an unfortunate downside to password lockout policies - once a hacker locks out a user account, that means that a valid user is locked out of their account. What's more, a hacker can continue your server.
The FTP service has had a feature to block IP addresses, but this required something of a manual process to discover malicious behavior. To accomplish this, you had to query your log files for excessive activity, and then added the IP addresses from potential hackers to your blacklist of banned IP addresses. Besides the manual nature of this process, another big drawback to this approach is the fact that it isn't real-time, so a malicious client could be attacking your system for some time before you discover their activity.
With that in mind, my next step was to go after the hackers and block their IP addresses from accessing my server. To that end, I created the custom authentication provider for the FTP 7.5 service that I documented in the following walkthrough:
How to Use Managed Code (C#) to Create an FTP Authentication Provider with Dynamic IP Restrictions
That was pretty effective, but it was really intended to be a stop-gap measure while we were working on a built-in feature for the FTP service that ships with IIS 8, which allows you to block malicious logon attempts.
Here's the way this feature works - at the server level, you configure the maximum number of failed logon attempts that you will allow within a given time period; if someone fails to logon within that time frame, the FTP service will drop the connection, and the client will be blocked from accessing your server until the time frame has passed.
Additional details are available in the walkthrough that I wrote at the following URL:
IIS 8.0 FTP Logon Attempt Restrictions
If you'd like to try out the new FTP Logon Restrictions feature, you can download the Windows Server 8 Beta from the following URL:
http://www.microsoft.com/en-us/server-cloud/windows-server/v8-default.aspx
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
01 December 2011 • by Bob • FTP, Extensibility, IIS
Many IIS 7 FTP developers may not have noticed, but all custom FTP 7 extensibility providers execute through COM+ in a DLLHOST.exe process, which runs as NETWORK SERVICE by default. That being said, NETWORK SERVICE does not always have the right permissions to access some of the areas on your system where you may be attempting to implement custom functionality. What this means is, some of the custom features that you try to implement may not work as expected.
For example, if you look at the custom FTP logging provider in following walkthrough, the provider may not have sufficient permissions to create log files in the folder that you specify:
How to Use Managed Code (C#) to Create a Simple FTP Logging Provider
There are a couple of ways that you can resolve this issue:
For what it's worth, I usually change the identity of the FTP 7 extensibility process on my servers so that I can set custom permissions for situations like this.
Here's how you do that:
Once you have done this, you can set permissions for this account whenever you need to specify permissions for situations like I described earlier.
Personally, I prefer to change the identity of the FTP 7 extensibility process instead of granting NETWORK SERVICE more permissions than it probably needs.
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
16 November 2011 • by Bob • FTP
Having written 10 blog posts in my series about FTP clients, I decided that it might be a good idea to recap some of the information that I have presented thus far. With that in mind, here is a quick recap of the entire series to date:
What I'd like to do in the rest of this blog is recap the scorecard information for the FTP clients that I've looked at. With one exception: I'm going to skip the information that I included about the FTP experience for various web browsers, which I discussed in Part 1 of this blog series, but only because web browsers aren't supposed to be first-class FTP clients.
That being said, I'm presenting the information for the remaining FTP clients that I have reviewed in alphabetical order, which is not necessarily by order of preference. ;-]
Original Blog Post: FTP Clients - Part 6: Core FTP LE
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
Core FTP LE 2.1 | Rich | Yes | Yes | Yes | Partial1 | Yes | No |
Footnotes:
Original Blog Post: FTP Clients - Part 9: Expression Web 4
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
Expression Web 4 | Rich | Yes | Yes | Yes1 | No2 | Partial3 | Yes |
Footnotes:
Original Blog Post: FTP Clients - Part 4: FileZilla
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
FileZilla 3.1.61 | Rich | Yes | Yes | Yes | No2 | Yes | Yes3 |
Footnotes:
Original Blog Post: FTP Clients - Part 10: FTP Voyager
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
FTP Voyager | Rich | Yes | Yes | Yes | Yes1 | Yes | Yes |
Footnotes:
Original Blog Post: FTP Clients - Part 7: Kermit FTP Client
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
Kermit FTP Client 2.1.3 | No | Yes | No | Yes | Partial1 | Yes | Yes |
Footnotes:
Original Blog Post: FTP Clients - Part 5: MOVEit Freely Command-Line Secure FTP Client
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
MOVEit Freely 5.0.0.0 | n/a | Yes | Yes | Yes | Partial1 | No | No |
Footnotes:
Original Blog Post: FTP Clients - Part 8: SmartFTP Client
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
SmartFTP Ultimate 4.0 | Rich | Yes | Yes | Yes | Yes1 | Yes | Yes |
Footnotes:
That wraps it up for my recap of the FTP clients that I've reviewed so far; but rest assured, I have a few more FTP clients that I'm waiting to review.
;-]
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
02 November 2011 • by Bob • FTP, IIS
I recently had an interesting scenario that was presented to me by a customer: they had a business requirement where they needed to give the same username and password to a group of people, but they didn't want any two people to be able to see anyone else's files. This seemed like an unusual business requirement to me; the whole point of keeping users separate is one of the reasons why we added user isolation to the FTP service.
With that in mind, my first suggestion was - of course - to rethink their business requirement, assign different usernames and passwords to everyone, and use FTP user isolation. But that wasn't going to work for them; their business requirement for giving out the same username and password could not be avoided. So I said that I would get back to them, and I spent the next few days experimenting with a few ideas.
One of my early ideas that seemed somewhat promising was to write a custom home directory provider that dynamically created unique home directories that were based on the session IDs for the individual FTP sessions, and the provider would use those directories to isolate the users. That seemed like a good idea, but when I analyzed the results I quickly saw that it wasn't going to work; as each user logged in, they would get a new session ID, and they wouldn't see their files from their last session. On top of that, the FTP server would rapidly start to collect a large number of session-based directories, with no garbage collection. So it was back to the drawing board for me.
After some discussions with the customer, we reasoned that the best suggestion for their particular environment was to leverage some of the code that I had written for my session-based home directory provider in order to create home directory provider that dynamically created home directories that are based on the remote IP of the FTP client.
I have to stress, however, that this solution will not work in all situations. For example:
That being said, the customer felt that those limitations were acceptable for their environment, so I created a home directory provider that dynamically created home directories that were based on the remote IP address of their FTP clients. I agree that it's not a perfect solution, but their business requirement made this scenario considerably difficult to work around.
Note: I wrote and tested the steps in this blog using both
The following items are required to complete the procedures in this blog:
ICACLS "%SystemDrive%\inetpub\ftproot" /Grant "Network Service":M /TWhere "%SystemDrive%\inetpub\ftproot" is the home directory for your FTP site.
In this step, you will create a project in
net stop ftpsvc
call "%VS100COMNTOOLS%\vsvars32.bat">null
gacutil.exe /if "$(TargetPath)"
net start ftpsvc
net stop ftpsvc
call "%VS90COMNTOOLS%\vsvars32.bat">null
gacutil.exe /if "$(TargetPath)"
net start ftpsvc
In this step, you will implement the extensibility interfaces for the demo provider.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using Microsoft.Web.FtpServer;
public class FtpRemoteIPHomeDirectory :
BaseProvider,
IFtpHomeDirectoryProvider,
IFtpLogProvider
{
// Create a dictionary object that will contain
// session IDs and remote IP addresses.
private static Dictionary<string, string> _sessionList = null;
// Store the path to the default FTP folder.
private static string _defaultDirectory = string.Empty;
// Override the default initialization method.
protected override void Initialize(StringDictionary config)
{
// Test if the session dictionary has been created.
if (_sessionList == null)
{
// Create the session dictionary.
_sessionList = new Dictionary<string, string>();
}
// Retrieve the default directory path from configuration.
_defaultDirectory = config["defaultDirectory"];
// Test for the default home directory (Required).
if (string.IsNullOrEmpty(_defaultDirectory))
{
throw new ArgumentException(
"Missing default directory path in configuration.");
}
}
// Define the home directory provider method.
string IFtpHomeDirectoryProvider.GetUserHomeDirectoryData(
string sessionId,
string siteName,
string userName)
{
// Create a string with the folder name.
string _sessionDirectory = String.Format(
@"{0}\{1}", _defaultDirectory,
_sessionList[sessionId]);
try
{
// Test if the folder already exists.
if (!Directory.Exists(_sessionDirectory))
{
// Create the physical folder. Note: NETWORK SERVICE
// needs write permissions to the default folder in
// order to create each remote IP's home directory.
Directory.CreateDirectory(_sessionDirectory);
}
}
catch (Exception ex)
{
throw ex;
}
// Return the path to the session folder.
return _sessionDirectory;
}
// Define the log provider method.
public void Log(FtpLogEntry logEntry)
{
// Test if the USER command was entered.
if (logEntry.Command.Equals(
"USER",
StringComparison.InvariantCultureIgnoreCase))
{
// Reformat the remote IP address.
string _remoteIp = logEntry.RemoteIPAddress
.Replace(':', '-')
.Replace('.', '-');
// Add the remote IP address to the session dictionary.
_sessionList.Add(logEntry.SessionId, _remoteIp);
}
// Test if the command channel was closed (end of session).
if (logEntry.Command.Equals(
"CommandChannelClosed",
StringComparison.InvariantCultureIgnoreCase))
{
// Remove the closed session from the dictionary.
_sessionList.Remove(logEntry.SessionId);
}
}
}
Note: If you did not use the optional steps to register the assemblies in the GAC, you will need to manually copy the assemblies to your IIS 7 computer and add the assemblies to the GAC using the Gacutil.exe tool. For more information, see the following topic on the Microsoft MSDN Web site:
In this step, you will add your provider to the global list of custom providers for your FTP service, configure your provider's settings, and enable your provider for an FTP site.
Note: If you prefer, you could use the command line to add the provider to FTP by using syntax like the following example:
cd %SystemRoot%\System32\Inetsrv
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"[name='FtpRemoteIPHomeDirectory',type='FtpRemoteIPHomeDirectory,FtpRemoteIPHomeDirectory,version=1.0.0.0,Culture=neutral,PublicKeyToken=426f62526f636b73']" /commit:apphost
At the moment there is no user interface that allows you to configure properties for a custom home directory provider, so you will have to use the following command line:
cd %SystemRoot%\System32\Inetsrv
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpRemoteIPHomeDirectory']" /commit:apphost
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpRemoteIPHomeDirectory'].[key='defaultDirectory',value='C:\Inetpub\ftproot']" /commit:apphost
Note: The highlighted area contains the value that you need to update with the root directory of your FTP site.
At the moment there is no user interface that allows you to enable a custom home directory provider for an FTP site, so you will have to use the following command line:
cd %SystemRoot%\System32\Inetsrv
appcmd.exe set config -section:system.applicationHost/sites /+"[name='My FTP Site'].ftpServer.customFeatures.providers.[name='FtpRemoteIPHomeDirectory']" /commit:apphost
appcmd.exe set config -section:system.applicationHost/sites /"[name='My FTP Site'].ftpServer.userIsolation.mode:Custom" /commit:apphost
Note: The highlighted areas contain the name of the FTP site where you want to enable the custom home directory provider.
In this blog I showed you how to:
When users connect to your FTP site, the FTP service will create a directory that is based on their remote IP address, and it will drop their session in the corresponding folder for their remote IP address. They will not be able to change to the root directory, or a directory for a different remote IP address.
For example, if the root directory for your FTP site is "C:\Inetpub\ftproot" and a client connects to your FTP site from 192.168.0.100, the FTP home directory provider will create a folder that is named "C:\Inetpub\ftproot\192-168-0-100", and the FTP client's sessions will be isolated in that directory; the FTP client will not be able to change directory to "C:\Inetpub\ftproot" or the home directory for another remote IP.
Once again, there are limitations to this approach, and I agree that it's not a perfect solution in all scenarios; but this provider works as expected when you have to use the same username and password for all of your FTP clients, and you know that your FTP clients will use unique remote IP addresses.
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
21 October 2011 • by Bob • FTP
For this installment in my series about FTP Clients, I'd like to take a look at FTP Voyager from Rhino Software. For this blog I used FTP Voyager 15.2.0.17, and it is available from the following URL:
FTP Voyager is a great FTP client that supports a wide array of features and connection options, but I shouldn't get ahead of myself and talk about everything in my introduction. ;-]
At the time of this blog post, FTP Voyager is a for-retail product that is available in two different versions:
You should take a look at the FTP Voyager Versions page for a description of the features that are available in each version.
The FTP Voyager user interface is uncluttered, easy to understand, and allows you to customize which panes you want to see displayed.
If you want a really uncluttered display, FTP Voyager offers a Simple Mode, which narrows down the number of panes that are displayed. (Sometimes this is a handy feature to have.)
FTP Voyager doesn't have a command-line interface, but it has web browser integration; and it has a really cool scheduler, which allows you to configure FTP jobs to run at scheduled times.
FTP Voyager also supports sending custom FTP commands, and it has an extensibility interface for creating add-ons. I didn't experiment with creating any add-ons, but you can find details about creating your own add-ons through RhinoSoft's FTP Voyager Add-Ons page.
FTP connections are created and edited through FTP Voyager's Site Profile Manager, which is comparable to the site management features that I have found in many of the better GUI-based FTP clients.
That concludes my summary for some of the general features - so now we'll take a look at the FTP7-specific features that I've discussed in my other FTP client blog posts.
FTP Voyager supports both Implicit and Explicit FTPS, so the choice is up to you to decide which method to use. As I have mentioned in my previous blogs, the FTPS method in FTP7 is specified by the port number that you choose when you are creating your bindings. Once again, I realize that I have posted the following information in almost all of my posts in this FTP client series, but it needs to be mentioned that the following rules apply for FTP7 when determining whether you are using Implicit or Explicit FTPS:
To configure the security options for a connection in FTP Voyager, you need to open the Advanced Settings dialog for the connection in FTP Voyager's Site Profile Manager.
![]() |
Fig. 6 - FTP Voyager's Security Options |
The additional security options in FTP Voyager's Security Options allow you to configure the SSL environment to match FTP7's Advanced SSL Policy settings.
![]() |
Fig. 7 - FTP7's Advanced SSL Policy Settings |
Note: I was able to use FTP Voyager's FTPS features with FTP7's virtual host names, but I should mention that I had to configure a Global Listener FTP Site in order to get that to work.
FTP Voyager has built-in for the HOST command, so you can use true FTP host names when using FTP Voyager to connect to FTP7 sites that are configured with host names. This feature is enabled by default, but if you needed to disable it for some reason, that feature can be accessed through FTP Voyager's Advanced Settings dialog.
![]() |
Fig. 7 - FTP Voyager's Advanced Connection Settings |
The following excerpt from the Log Pane of an FTP Voyager session shows the HOST command in action:
STATUS:> |
Connecting to "ftp.contoso.com" on port 21. |
|
220 Microsoft FTP Service |
STATUS:> |
Connected. Logging into the server |
COMMAND:> |
HOST ftp.contoso.com |
|
220 Host accepted. |
COMMAND:> |
USER robert |
|
331 Password required for robert. |
COMMAND:> |
PASS ********** |
|
230 User logged in. |
STATUS:> |
Login successful |
FTP Voyager's login settings allow you to specify the virtual host name as part of the user credentials by using syntax like "ftp.example.com|username" or "ftp.example.com\username", but since FTP Voyager allows you to use true FTP hosts this is really a moot point. Just the same, there's nothing to stop you from disabling the HOST command for a connection and specifying an FTP virtual host as part of your username, although I'm not sure why you would want to do that.
This concludes our quick look at some of the FTP features that are available with FTP Voyager, and here are the scorecard results:
Client Name | Directory Browsing | Explicit FTPS | Implicit FTPS | Virtual Hosts | True HOSTs | Site Manager | Extensibility |
---|---|---|---|---|---|---|---|
Rich | Y | Y | Y | Y | Y | Y | |
As noted earlier, FTP Voyager supports the FTP HOST command, and is enabled by default for new connections. |
In closing, FTP Voyager is a great GUI-based FTP client that has first-class support for all of the features that I have been examining in detail throughout my FTP client blog series. But that being said, I included the following disclaimer in all of my preceding posts, so this post will be no exception: there are a great number of additional features that FTP Voyager provides - but once again I only focused on a few specific topic areas that apply to FTP7. ;-]
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
04 October 2011 • by Bob • FTP, LogParser
One of my colleagues here at Microsoft, Emmanuel Boersma, just reminded me of an email thread that we had several weeks ago, where a customer had asked him how they could tell if FTPS was being used on their FTP server. He had pointed out that when he looks at his FTP log files, the port number was always 21, so it wasn't as easy as looking at a website's log files and looking for port 80 for HTTP versus port 443 for HTTPS. I had sent him the following notes, and I thought that they might make a good blog. ;-)
As I mentioned earlier, we had discussed the control channel is typically over port 21 for both FTP and FTPS, so you can't rely on the port. But having said that, I mentioned that you will see certain verbs in your FTP logs that will let you know when FTPS is being used, and that’s a reliable way to check.
With that in mind, I suggested the following two methods that you can use to determine if FTPS is being used:
For example, see the highlighted data in following FTP log file excerpts:
Explicit FTPS over port 21:
#Fields: date time c-ip cs-username cs-host s-ip s-port cs-method cs-uri-stem sc-status sc-win32-status sc-substatus sc-bytes cs-bytes time-taken 2011-06-30 22:11:24 ::1 - - ::1 21 ControlChannelOpened - - 0 0 0 0 0 2011-06-30 22:11:24 ::1 - - ::1 21 AUTH TLS 234 0 0 31 10 16 2011-06-30 22:11:27 ::1 - - ::1 21 PBSZ 0 200 0 0 69 8 0 2011-06-30 22:11:27 ::1 - - ::1 21 PROT P 200 0 0 69 8 0 2011-06-30 22:11:36 ::1 - - ::1 21 USER robert 331 0 0 69 13 0 2011-06-30 22:11:42 ::1 robert - ::1 21 PASS *** 230 0 0 53 15 2808
Implicit FTPS over port 990:
#Fields: date time c-ip cs-username cs-host s-ip s-port cs-method cs-uri-stem sc-status sc-win32-status sc-substatus sc-bytes cs-bytes time-taken 2011-06-30 22:16:55 ::1 - - ::1 990 ControlChannelOpened - - 0 0 0 0 0 2011-06-30 22:16:58 ::1 - - ::1 990 USER robert 331 0 0 69 13 0 2011-06-30 22:16:58 ::1 robert - ::1 990 PASS *** 230 0 0 53 15 78 2011-06-30 22:16:58 ::1 robert - ::1 990 SYST - 500 5 51 1005 6 0 2011-06-30 22:16:58 ::1 robert - ::1 990 FEAT - 211 0 0 313 6 0 2011-06-30 22:16:58 ::1 robert - ::1 990 OPTS UTF8+ON 200 0 0 85 14 0 2011-06-30 22:16:58 ::1 robert - ::1 990 PBSZ 0 200 0 0 69 8 0 2011-06-30 22:16:58 ::1 robert - ::1 990 PROT P 200 0 0 69 8 0
FWIW – An explanation about Implicit FTPS and Explicit FTPS can be found in the following articles:
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/