1--- A UPNP library based on code from upnp-info initially written by
2-- Thomas Buchanan. The code was factored out from upnp-info and partly
3-- re-written by Patrik Karlsson <patrik@cqure.net> in order to support
4-- multicast requests.
5--
6-- The library supports sending UPnP requests and decoding the responses
7--
8-- The library contains the following classes
9-- * <code>Comm</code>
10-- ** A class that handles communication with the UPnP service
11-- * <code>Helper</code>
12-- ** The helper class wraps the <code>Comm</code> class using functions with a more descriptive name.
13-- * <code>Util</code>
14-- ** The <code>Util</code> class contains a number of static functions mainly used to convert and sort data.
15--
16-- The following code snippet queries all UPnP services on the network:
17-- <code>
18--   local helper = upnp.Helper:new()
19--   helper:setMulticast(true)
20--   return stdnse.format_output(helper:queryServices())
21-- </code>
22--
23-- This next snippet queries a specific host for the same information:
24-- <code>
25--   local helper = upnp.Helper:new(host, port)
26--   return stdnse.format_output(helper:queryServices())
27-- </code>
28--
29--
30-- @author Thomas Buchanan
31-- @author Patrik Karlsson <patrik@cqure.net>
32
33--
34-- Version 0.1
35--
36
37local http = require "http"
38local ipOps = require "ipOps"
39local nmap = require "nmap"
40local stdnse = require "stdnse"
41local string = require "string"
42local table = require "table"
43local target = require "target"
44_ENV = stdnse.module("upnp", stdnse.seeall)
45
46Util = {
47
48  --- Compare function used for sorting IP-addresses
49  --
50  -- @param a table containing first item
51  -- @param b table containing second item
52  -- @return true if a is less than b
53  ipCompare = function(a, b)
54    return ipOps.compare_ip(a, "lt", b)
55  end,
56
57}
58
59Comm = {
60
61  --- Creates a new Comm instance
62  --
63  -- @param host string containing the host name or ip
64  -- @param port number containing the port to connect to
65  -- @return o a new instance of Comm
66  new = function( self, host, port )
67    local o = {}
68    setmetatable(o, self)
69    self.__index = self
70    o.host = host
71    o.port = port
72    o.mcast = false
73    return o
74  end,
75
76  --- Connect to the server
77  --
78  -- @return status true on success, false on failure
79  connect = function( self )
80    if ( self.mcast ) then
81      self.socket = nmap.new_socket("udp")
82      self.socket:set_timeout(5000)
83    else
84      self.socket = nmap.new_socket()
85      self.socket:set_timeout(5000)
86      local status, err = self.socket:connect(self.host, self.port, "udp" )
87      if ( not(status) ) then return false, err end
88    end
89
90    return true
91  end,
92
93  --- Send the UPNP discovery request to the server
94  --
95  -- @return status true on success, false on failure
96  sendRequest = function( self )
97
98    -- for details about the UPnP message format, see http://upnp.org/resources/documents.asp
99    local payload = 'M-SEARCH * HTTP/1.1\r\n\z
100    Host:239.255.255.250:1900\r\n\z
101    ST:upnp:rootdevice\r\n\z
102    Man:"ssdp:discover"\r\n\z
103    MX:3\r\n\r\n'
104
105    local status, err
106
107    if ( self.mcast ) then
108      status, err = self.socket:sendto( self.host, self.port, payload )
109    else
110      status, err = self.socket:send( payload )
111    end
112
113    if ( not(status) ) then return false, err end
114
115    return true
116  end,
117
118  --- Receives one or multiple UPNP responses depending on whether
119  -- <code>setBroadcast</code> was enabled or not.
120  --
121  -- The function returns the
122  -- status and a response containing:
123  -- * an array (table) of responses if broadcast is used
124  -- * a single response if broadcast is not in use
125  -- * an error message if status was false
126  --
127  -- @return status true on success, false on failure
128  -- @return result table or string containing results or error message
129  --         on failure.
130  receiveResponse = function( self )
131    local status, response
132    local result = {}
133    local host_responses = {}
134
135    repeat
136      status, response = self.socket:receive()
137      if ( not(status) and #response == 0 ) then
138        return false, response
139      elseif( not(status) ) then
140        break
141      end
142
143      local status, _, _, ip, _ = self.socket:get_info()
144      if ( not(status) ) then
145        return false, "Failed to retrieve socket information"
146      end
147      if target.ALLOW_NEW_TARGETS then target.add(ip) end
148
149      if ( not(host_responses[ip]) ) then
150        local status, output = self:decodeResponse( response )
151        if ( not(status) ) then
152          return false, "Failed to decode UPNP response"
153        end
154        output = { output }
155        output.name = ip
156        table.insert( result, output )
157        host_responses[ip] = true
158      end
159    until ( not( self.mcast ) )
160
161    if ( self.mcast ) then
162      table.sort(result, Util.ipCompare)
163      return true, result
164    end
165
166    if ( status and #result > 0 ) then
167      return true, result[1]
168    else
169      return false, "Received no responses"
170    end
171  end,
172
173  --- Processes a response from a upnp device
174  --
175  -- @param response as received over the socket
176  -- @return status boolean true on success, false on failure
177  -- @return response table or string suitable for output or error message if status is false
178  decodeResponse = function( self, response )
179    local output = {}
180
181    if response ~= nil then
182      -- We should get a response back that has contains one line for the server, and one line for the xml file location
183      -- these match any combination of upper and lower case responses
184      local server, location
185      server = string.match(response, "[Ss][Ee][Rr][Vv][Ee][Rr]:%s*(.-)\r?\n")
186      if server ~= nil then table.insert(output, "Server: " .. server ) end
187      location = string.match(response, "[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:%s*(.-)\r?\n")
188      if location ~= nil then
189        table.insert(output, "Location: " .. location )
190
191        local v = nmap.verbosity()
192
193        -- the following check can output quite a lot of information, so we require at least one -v flag
194        if v > 0 then
195          local status, result = self:retrieveXML( location )
196          if status then
197            table.insert(output, result)
198          end
199        end
200      end
201      if #output > 0 then
202        return true, output
203      else
204        return false, "Could not decode response"
205      end
206    end
207  end,
208
209  --- Retrieves the XML file that describes the UPNP device
210  --
211  -- @param location string containing the location of the XML file from the UPNP response
212  -- @return status boolean true on success, false on failure
213  -- @return response table or string suitable for output or error message if status is false
214  retrieveXML = function( self, location )
215    local response
216    local options = {}
217    options['header'] = {}
218    options['header']['Accept'] = "text/xml, application/xml, text/html"
219
220    -- if we're in multicast mode, or if the user doesn't want us to override the IP address,
221    -- just use the HTTP library to grab the XML file
222    if ( self.mcast or ( not self.override ) ) then
223      response = http.get_url( location, options )
224    else
225      -- otherwise, split the location into an IP address, port, and path name for the xml file
226      local xhost, xport, xfile
227      xhost = string.match(location, "http://(.-)/")
228      -- check to see if the host portion of the location specifies a port
229      -- if not, use port 80 as a standard web server port
230      if xhost ~= nil and string.match(xhost, ":") then
231        xport = string.match(xhost, ":(.*)")
232        xhost = string.match(xhost, "(.*):")
233      end
234
235      -- check to see if the IP address returned matches the IP address we scanned
236      if xhost ~= self.host.ip then
237        stdnse.debug1("IP addresses did not match! Found %s, using %s instead.", xhost, self.host.ip)
238        xhost = self.host.ip
239      end
240
241      if xport == nil then
242        xport = 80
243      end
244
245      -- extract the path name from the location field, but strip off the \r that HTTP servers return
246      xfile = string.match(location, "http://.-(/.-)\013")
247      if xfile ~= nil then
248        response = http.get( xhost, xport, xfile, options )
249      end
250    end
251
252    if response ~= nil then
253      local output = {}
254
255      -- extract information about the webserver that is handling responses for the UPnP system
256      local webserver = response['header']['server']
257      if webserver ~= nil then table.insert(output, "Webserver: " .. webserver) end
258
259      -- the schema for UPnP includes a number of <device> entries, which can a number of interesting fields
260      for device in string.gmatch(response['body'], "<deviceType>(.-)</UDN>") do
261        local fn, mnf, mdl, nm, ver
262
263        fn = string.match(device, "<friendlyName>(.-)</friendlyName>")
264        mnf = string.match(device, "<manufacturer>(.-)</manufacturer>")
265        mdl = string.match(device, "<modelDescription>(.-)</modelDescription>")
266        nm = string.match(device, "<modelName>(.-)</modelName>")
267        ver = string.match(device, "<modelNumber>(.-)</modelNumber>")
268
269        if fn ~= nil then table.insert(output, "Name: " .. fn) end
270        if mnf ~= nil then table.insert(output,"Manufacturer: " .. mnf) end
271        if mdl ~= nil then table.insert(output,"Model Descr: " .. mdl) end
272        if nm ~= nil then table.insert(output,"Model Name: " .. nm) end
273        if ver ~= nil then table.insert(output,"Model Version: " .. ver) end
274      end
275      return true, output
276    else
277      return false, "Could not retrieve XML file"
278    end
279  end,
280
281  --- Enables or disables multicast support
282  --
283  -- @param mcast boolean true if multicast is to be used, false otherwise
284  setMulticast = function( self, mcast )
285    assert( type(mcast)=="boolean", "mcast has to be either true or false")
286    self.mcast = mcast
287    local family = nmap.address_family()
288    self.host = (family=="inet6" and "FF02::C" or "239.255.255.250")
289    self.port = 1900
290  end,
291
292  --- Closes the socket
293  close = function( self ) self.socket:close() end
294
295}
296
297
298Helper = {
299
300  --- Creates a new helper instance
301  --
302  -- @param host string containing the host name or ip
303  -- @param port number containing the port to connect to
304  -- @return o a new instance of Helper
305  new = function( self, host, port )
306    local o = {}
307    setmetatable(o, self)
308    self.__index = self
309    o.comm = Comm:new( host, port )
310    return o
311  end,
312
313  --- Enables or disables multicast support
314  --
315  -- @param mcast boolean true if multicast is to be used, false otherwise
316  setMulticast = function( self, mcast ) self.comm:setMulticast(mcast) end,
317
318  --- Enables or disables whether the script will override the IP address is the Location URL
319  --
320  -- @param override boolean true if override is to be enabled, false otherwise
321  setOverride = function( self, override )
322    assert( type(override)=="boolean", "override has to be either true or false")
323    self.comm.override = override
324  end,
325
326  --- Sends a UPnP queries and collects a single or multiple responses
327  --
328  -- @return status true on success, false on failure
329  -- @return result table or string containing results or error message
330  --         on failure.
331  queryServices = function( self )
332    local status, err = self.comm:connect()
333    local response
334
335    if ( not(status) ) then return false, err end
336
337    status, err = self.comm:sendRequest()
338    if ( not(status) ) then return false, err end
339
340    status, response = self.comm:receiveResponse()
341    self.comm:close()
342
343    return status, response
344  end,
345
346}
347
348return _ENV;
349