local creds = require "creds"
local redis = require "redis"
local nmap = require "nmap"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"
local stringaux = require "stringaux"
local table = require "table"
local ipOps = require "ipOps"

description = [[
Retrieves information (such as version number and architecture) from a Redis key-value store.
]]

---
-- @usage
-- nmap -p 6379 <ip> --script redis-info
--
-- @output
-- PORT     STATE SERVICE
-- 6379/tcp open  unknown
-- | redis-info:
-- |   Version            2.2.11
-- |   Architecture       64 bits
-- |   Process ID         17821
-- |   Used CPU (sys)     2.37
-- |   Used CPU (user)    1.02
-- |   Connected clients  1
-- |   Connected slaves   0
-- |   Used memory        780.16K
-- |   Role               master
-- |   Bind addresses:
-- |     192.168.121.101
-- |   Active channels:
-- |     testChannel
-- |     bidChannel
-- |   Client connections:
-- |     192.168.171.101
-- |_    72.14.177.105
--
--

author = {"Patrik Karlsson", "Vasiliy Kulikov"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}
dependencies = {"redis-brute"}


portrule = shortport.port_or_service(6379, "redis")

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

local function cb_parse_version(host, port, val)
  port.version.version = val
  port.version.cpe = port.version.cpe or {}
  table.insert(port.version.cpe, 'cpe:/a:redis:redis:' .. val)
  nmap.set_port_version(host, port)
  return val
end

local function cb_parse_architecture(host, port, val)
  val = ("%s bits"):format(val)
  port.version.extrainfo = val
  nmap.set_port_version(host, port)
  return val
end

local filter = {

  ["redis_version"] = { name = "Version", func = cb_parse_version },
  ["os"] = { name = "Operating System" },
  ["arch_bits"] = { name = "Architecture", func = cb_parse_architecture },
  ["process_id"] = { name = "Process ID"},
  ["uptime"] = { name = "Uptime", func = function(h, p, v) return ("%s seconds"):format(v) end },
  ["used_cpu_sys"]= { name = "Used CPU (sys)"},
  ["used_cpu_user"] = { name = "Used CPU (user)"},
  ["connected_clients"] = { name = "Connected clients"},
  ["connected_slaves"] = { name = "Connected slaves"},
  ["used_memory_human"] = { name = "Used memory"},
  ["role"] = { name = "Role"}

}

local order = {
  "redis_version", "os", "arch_bits", "process_id", "used_cpu_sys",
  "used_cpu_user", "connected_clients", "connected_slaves",
  "used_memory_human", "role"
}

local extras = {
  {
    "Bind addresses", {"CONFIG", "GET", "bind"}, function (data)
      if data[1] ~= "bind" or not data[2] then
        return nil
      end
      local restab = stringaux.strsplit(" ", data[2])
      if not restab or 0 == #restab then
        stdnse.debug1("Failed to parse response from server")
        return nil
      end
      for i, ip in ipairs(restab) do
        if ip == '' then restab[i] = '0.0.0.0' end
      end
      return restab
    end
  },
  {
    "Active channels", {"PUBSUB", "CHANNELS"}, function (data)
      local channels = {}
      local i = 0
      local omitted = 0
      local limit = nmap.verbosity() <= 1 and 20 or false
      for _, channel in ipairs(data) do
        if limit and i > limit then
          omitted = omitted + 1
        else
          table.insert(channels, channel)
        end
        i = i + 1
      end

      if omitted > 0 then
        table.insert(channels, ("(omitted %s item(s), use verbose mode -v to show them)"):format(omitted))
      end
      return i > 0 and channels or nil
    end
  },
  {
    "Client connections", {"CLIENT", "LIST"}, function(data)
      local restab = stringaux.strsplit("\n", data)
      if not restab or 0 == #restab then
        stdnse.debug1("Failed to parse response from server")
        return nil
      end

      local client_ips = {}
      for _, item in ipairs(restab) do
        local ip = item:match("addr=%[?([0-9a-f:.]+)%]?:[0-9]+ ")
        client_ips[ip] = true;
      end
      if not next(client_ips) then
        return nil
      end
      local out = {}
      for ip, _ in pairs(client_ips) do
        local sortable = ipOps.ip_to_str(ip)
        if sortable then
          -- prepending length sorts IPv4 and IPv6 separately
          out[#out+1] = string.pack("s1", sortable)
        end
      end
      if not next(out) then
        return nil
      end
      table.sort(out)
      for i, packed in ipairs(out) do
        out[i] = ipOps.str_to_ip(string.unpack("s1", packed))
      end
      return out
    end
  },
  {
    "Cluster nodes", {"CLUSTER", "NODES"}, function(data)
      local restab = stringaux.strsplit("\n", data)
      if not restab or 0 == #restab then
        return nil
      end

      local ips = {}
      for _, item in ipairs(restab) do
        local id, ip, port, flags = item:match("^([a-f0-9]+) ([0-9.:a-f]+):([0-9]+) ([a-z,]+)")
        stdnse.debug1("ip=%s port=%s flags=%s", ip, port, flags)
        table.insert(ips, ip .. ":" .. port .. " (" .. flags .. ")")
      end

      return ips
    end
  },
}

action = function(host, port)

  local helper = redis.Helper:new(host, port)
  local status = helper:connect()
  if( not(status) ) then
    return fail("Failed to connect to server")
  end

  -- do we have a service password
  local c = creds.Credentials:new(creds.ALL_DATA, host, port)
  local cred = c:getCredentials(creds.State.VALID + creds.State.PARAM)()

  if ( cred and cred.pass ) then
    local status, response = helper:reqCmd("AUTH", cred.pass)
    if ( not(status) ) then
      helper:close()
      return fail(response)
    end
  end

  local status, response = helper:reqCmd("INFO")
  if ( not(status) ) then
    helper:close()
    return fail(response)
  end

  if ( redis.Response.Type.ERROR == response.type ) then
    if ( "-ERR operation not permitted" == response.data ) or
        ( "-NOAUTH Authentication required." == response.data ) then
      return fail("Authentication required")
    end
    return fail(response.data)
  end

  local restab = stringaux.strsplit("\r\n", response.data)
  if ( not(restab) or 0 == #restab ) then
    return fail("Failed to parse response from server")
  end

  local kvs = {}
  for _, item in ipairs(restab) do
    local k, v = item:match("^([^:]*):(.*)$")
    if k ~= nil then
      kvs[k] = v
    end
  end

  local result = stdnse.output_table()
  for _, item in ipairs(order) do
    if kvs[item] then
      local name = filter[item].name
      local val

      if filter[item].func then
        val = filter[item].func(host, port, kvs[item])
      else
        val = kvs[item]
      end
      result[name] = val
    end
  end

  for i=1, #extras do
    local name = extras[i][1]
    local cmd = extras[i][2]
    local process = extras[i][3]

    local status, response = helper:reqCmd(table.unpack(cmd))
    if status and redis.Response.Type.ERROR ~= response.type then
      result[name] = process(response.data)
    end
  end
  helper:close()
  return result
end
