local dhcp = require "dhcp"
local dns = require "dns"
local http = require "http"
local nmap = require "nmap"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local url = require "url"

description = [[
Retrieves a list of proxy servers on a LAN using the Web Proxy
Autodiscovery Protocol (WPAD).  It implements both the DHCP and DNS
methods of doing so and starts by querying DHCP to get the address.
DHCP discovery requires nmap to be running in privileged mode and will
be skipped when this is not the case.  DNS discovery relies on the
script being able to resolve the local domain either through a script
argument or by attempting to reverse resolve the local IP.
]]

---
-- @usage
-- nmap --script broadcast-wpad-discover
--
-- @output
-- | broadcast-wpad-discover:
-- |   1.2.3.4:8080
-- |_  4.5.6.7:3128
--
-- @args broadcast-wpad-discover.domain the domain in which the WPAD host should be discovered
-- @args broadcast-wpad-discover.nodns instructs the script to skip discovery using DNS
-- @args broadcast-wpad-discover.nodhcp instructs the script to skip discovery using dhcp
-- @args broadcast-wpad-discover.getwpad instructs the script to retrieve the WPAD file instead of parsing it

author = "Patrik Karlsson"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"broadcast", "safe"}


prerule = function() return true end

local arg_domain = stdnse.get_script_args(SCRIPT_NAME .. ".domain")
local arg_nodns  = stdnse.get_script_args(SCRIPT_NAME .. ".nodns")
local arg_nodhcp = stdnse.get_script_args(SCRIPT_NAME .. ".nodhcp")
local arg_getwpad= stdnse.get_script_args(SCRIPT_NAME .. ".getwpad")

local function createRequestList(req_list)
  local output = {}
  for i, v in ipairs(req_list) do
    output[i] = string.char(v)
  end
  return table.concat(output)
end


-- Gets a list of available interfaces based on link and up filters
--
-- @param link string containing the link type to filter
-- @param up string containing the interface status to filter
-- @return result table containing the matching interfaces
local function getInterfaces(link, up)
  if( not(nmap.list_interfaces) ) then return end
  local interfaces, err = nmap.list_interfaces()
  local result
  if ( not(err) ) then
    for _, iface in ipairs(interfaces) do
      if ( iface.link == link and iface.up == up ) then
        result = result or {}
        result[iface.device] = true
      end
    end
  end
  return result
end


local function parseDHCPResponse(response)
  for _, v in ipairs(response.options) do
    if ( "WPAD" == v.name ) then
      return true, v.value
    end
  end
end

local function getWPAD(u)
  local u_parsed = url.parse(u)

  if ( not(u_parsed) ) then
    return false, ("Failed to parse url: %s"):format(u)
  end

  local response = http.get(u_parsed.host, u_parsed.port or 80, u_parsed.path)
  if ( response and response.status == 200 ) then
    return true, response.body
  end

  return false, ("Failed to retrieve wpad.dat (%s) from server"):format(u)
end

local function parseWPAD(wpad)
  local proxies = {}
  for proxy in wpad:gmatch("PROXY%s*([^\";%s]*)") do
    table.insert(proxies, proxy)
  end
  return proxies
end

-- cache of all names we've already tried once. No point in wasting time.
local wpad_dns_tried = {}

-- tries to discover WPAD for all domains and sub-domains
local function enumWPADNames(domain)
  local d = domain
  -- reduce domain until we only have a single dot left
  -- there is a security problem in querying for wpad.tld like eg
  -- wpad.com as this could be a rogue domain. This loop does not
  -- account for domains with tld's containing two parts e.g. co.uk.
  -- However, as the script just attempts to download and parse the
  -- proxy values in the WPAD there should be no real harm here.
  repeat
    local name = ("wpad.%s"):format(d)
    if wpad_dns_tried[name] then
      -- We've been here before, stop.
      d = nil
    else
      wpad_dns_tried[name] = true
      d = d:match("^[^%.]-%.(.*)$")
      local status, response = dns.query(name, { dtype = 'A', retAll = true })

      -- get the first entry and return
      if ( status and response[1] ) then
        return true, { name = name, ip = response[1] }
      end
    end
  until not d

end

local function dnsDiscover()
  -- first try a domain if it was supplied
  if ( arg_domain ) then
    local status, response = enumWPADNames(arg_domain)
    if ( status ) then
      return status, response
    end
  end


  -- if no domain was supplied, attempt to reverse lookup every ip on each
  -- interface to find our FQDN hostname, once we do, try to query for WPAD
  for i in pairs(getInterfaces("ethernet", "up") or {}) do
    local iface, err = nmap.get_interface_info(i)
    if ( iface ) then
      local status, response = dns.query( dns.reverse(iface.address), { dtype = 'PTR', retAll = true } )

      -- did we get a name back from dns?
      if ( status ) then
        local domains = {}
        for _, name in ipairs(response) do
          -- first get all unique domain names
          if ( not(name:match("in%-addr.arpa$")) ) then
            local domain = name:match("^[^%.]-%.(.*)$")
            domains[domain or ""] = true
          end
        end

        -- attempt to discover the ip for WPAD in all domains
        -- each domain is processed and reduced and ones the first
        -- match is received it returns an IP
        for domain in pairs(domains) do
          status, response = enumWPADNames(domain)
          if ( status ) then
            return true, response
          end
        end

      end

    end
  end

  return false, "Failed to find WPAD using DNS"

end

local function dhcpDiscover()

  -- send a DHCP discover on all ethernet interfaces that are up
  for i in pairs(getInterfaces("ethernet", "up") or {}) do
    local iface, err = nmap.get_interface_info(i)
    if ( iface ) then
      local req_list = createRequestList( { 1, 15, 3, 6, 44, 46, 47, 31, 33, 249, 43, 252 } )
      local status, response = dhcp.make_request("255.255.255.255", dhcp.request_types["DHCPDISCOVER"], "0.0.0.0", iface.mac, nil, req_list, { flags = 0x8000 } )

      -- if we got a response, we're happy and don't need to continue
      if (status) then
        return status, response
      end
    end
  end

end

local function fail (err) return stdnse.format_output(false, err) end

action = function()

  local status, response, wpad

  if ( arg_nodhcp and arg_nodns ) then
    stdnse.verbose1("Both nodns and nodhcp arguments were supplied")
    return fail("Both nodns and nodhcp arguments were supplied")
  end

  if ( nmap.is_privileged() and not(arg_nodhcp) ) then
    status, response = dhcpDiscover()
    if ( status ) then
      status, wpad = parseDHCPResponse(response)
    end
  end

  -- if the DHCP did not get a result, fallback to DNS
  if (not(status) and not(arg_nodns) ) then
    status, response = dnsDiscover()
    if ( not(status) ) then
      local services = "DNS" .. ( nmap.is_privileged() and "/DHCP" or "" )
      return fail(("Could not find WPAD using %s"):format(services))
    end
    wpad = ("http://%s/wpad.dat"):format( response.name )
  end

  if ( status ) then
    status, response = getWPAD(wpad)
  end

  if ( not(status) ) then
    return status, response
  end

  local output = ( arg_getwpad and response or parseWPAD(response) )

  return stdnse.format_output(true, output)
end
