#!/usr/bin/env ruby

require "socket"
require "fileutils"
require "json"
require "shellwords"
require "etc"

SOCKET_PATH = "/run/malpd/malpd.sock"

def human_capacity(bytes)
  units = ["B", "KB", "MB", "GB", "TB", "PB"]
  i = 0
  size = bytes.to_f
  while size >= 1000 && i < units.length - 1
    size /= 1000
    i += 1
  end
  precision = i <= 3 ? 0 : 1
  "%g %s" % [size.round(precision), units[i]]
end

def drive_type(name)
  return "nvme" if name.start_with?("nvme")
  rotational = File.read("/sys/block/#{name}/queue/rotational").strip
  rotational == "1" ? "hdd" : "ssd"
end

def read_smart(name)
  out = `smartctl -j -A -H /dev/#{name} 2>/dev/null`
  return nil if out.empty?
  data = JSON.parse(out) rescue nil
  return nil unless data

  result = {}
  passed = data.dig("smart_status", "passed")
  result["passed"] = passed unless passed.nil?

  if (t = data.dig("temperature", "current"))
    result["temperature"] = t
  end
  if (poh = data.dig("power_on_time", "hours"))
    result["power_on_hours"] = poh
  end

  if name.start_with?("nvme")
    log = data["nvme_smart_health_information_log"] || {}
    result["percentage_used"] = log["percentage_used"] if log.key?("percentage_used")
    result["media_errors"]    = log["media_errors"]    if log.key?("media_errors")
    result["critical_warning"] = log["critical_warning"] if log.key?("critical_warning")
  else
    attrs = data.dig("ata_smart_attributes", "table") || []
    attrs.each do |a|
      raw = a.dig("raw", "value")
      next if raw.nil?
      case a["id"]
      when 5   then result["reallocated_sectors"] = raw
      when 10  then result["spin_retry"] = raw
      when 197 then result["pending_sectors"] = raw
      when 199 then result["crc_errors"] = raw
      end
    end
  end

  result
end

def read_inodes(mount)
  out = `stat -f -c '%c %d' #{Shellwords.escape(mount)} 2>/dev/null`.strip
  return nil if out.empty?
  total, free = out.split.map(&:to_i)
  return nil unless total && total > 0
  [total, total - free]
end

def read_filesystems
  out = `findmnt -J -b -o SOURCE,TARGET,FSTYPE,SIZE,USED,OPTIONS 2>/dev/null`
  return [] if out.empty?
  data = JSON.parse(out) rescue nil
  return [] unless data

  skip_fstypes = %w[tmpfs devtmpfs efivarfs]
  result = []
  seen = {}
  stack = Array(data["filesystems"]).dup
  until stack.empty?
    node = stack.shift
    stack.concat(node["children"]) if node["children"]
    source = node["source"].to_s
    fstype = node["fstype"].to_s
    target = node["target"].to_s
    next unless source.start_with?("/dev/")
    next if skip_fstypes.include?(fstype)
    size = node["size"].to_i
    next if size <= 0
    key = [source, target]
    next if seen[key]
    seen[key] = true

    opts = node["options"].to_s.split(",")
    entry = {
      "type"     => "filesystem",
      "source"   => source,
      "mount"    => target,
      "fstype"   => fstype,
      "size"     => size,
      "used"     => node["used"].to_i,
      "readonly" => opts.include?("ro")
    }
    if (ino = read_inodes(target))
      entry["inode_total"] = ino[0]
      entry["inode_used"]  = ino[1]
    end
    result << entry
  end
  result
end

def parse_mem(s)
  return 0 unless s
  n, unit = s.split
  mult = case unit
         when "KiB" then 1024
         when "MiB" then 1024 ** 2
         when "GiB" then 1024 ** 3
         when "TiB" then 1024 ** 4
         else 1
         end
  n.to_i * mult
end

def read_vm_dominfo(name)
  out = `virsh --connect qemu:///system dominfo #{Shellwords.escape(name)} 2>/dev/null`
  info = {}
  out.each_line do |line|
    key, value = line.split(":", 2).map { |s| s&.strip }
    next unless key && value
    case key
    when "State"       then info["state"] = value
    when "CPU(s)"      then info["vcpus"] = value.to_i
    when "Max memory"  then info["memory"] = parse_mem(value)
    when "Used memory" then info["used_memory"] = parse_mem(value)
    when "Autostart"   then info["autostart"] = (value == "enable")
    end
  end
  info
end

def read_vm_info(name)
  ssh_out = `ssh -Ti /root/.ssh/malp-vm-key malp@#{name}`
  JSON.parse(ssh_out)
end

def read_virtual_machines
  list_out = `virsh --connect qemu:///system list --all --name 2>/dev/null`
  return [] unless $?.success?
  names = list_out.split("\n").map(&:strip).reject(&:empty?)
  return [] if names.empty?

  names.map do |name|
    dom_info = read_vm_dominfo(name)
    vm_info = read_vm_info(name)
    {
      "type"        => "vm",
      "name"        => name,
    }.merge(dom_info).merge(vm_info)
  end
end

def read_server_info
  info = { "type" => "server" }

  if File.exist?("/etc/os-release")
    File.readlines("/etc/os-release").each do |line|
      if line =~ /^PRETTY_NAME="(.+)"$/
        info["os_name"] = $1
        break
      end
    end
  end

  out = `stat -c %W / 2>/dev/null`.strip
  if out =~ /^\d+$/ && out.to_i > 0
    info["install_date"] = Time.at(out.to_i).strftime("%Y-%m-%d")
  elsif File.exist?("/etc/machine-id")
    info["install_date"] = File.mtime("/etc/machine-id").strftime("%Y-%m-%d")
  end

  if File.exist?("/proc/cpuinfo")
    cpuinfo = File.read("/proc/cpuinfo")
    if cpuinfo =~ /^model name\s*:\s*(.+)$/
      info["cpu_model"] = $1.strip
    end
    cores = cpuinfo.scan(/^processor\s*:/).size
    info["cpu_cores"] = cores if cores > 0
  end

  if File.exist?("/proc/meminfo")
    mem = {}
    File.read("/proc/meminfo").each_line do |line|
      if line =~ /^(\w+):\s+(\d+)/
        mem[$1] = $2.to_i * 1024
      end
    end
    if (total = mem["MemTotal"])
      available = mem["MemAvailable"] ||
        (mem["MemFree"].to_i + mem["Buffers"].to_i + mem["Cached"].to_i)
      info["mem_total"] = total
      info["mem_used"]  = total - available
    end
  end

  info
end

def read_backups
  paths = [
    ["rsync", "/backup/last-backup"],
    ["gmail", "/backup/gmail/new"]
  ]
  entries = paths.map do |label, path|
    mtime = (File.mtime(path).to_i rescue nil)
    { "label" => label, "path" => path, "mtime" => mtime }
  end
  { "type" => "backup", "entries" => entries }
end

def read_mail
  users = []

  ["root", "josh"].each do |user|
    begin
      home = Etc.getpwnam(user).dir
    rescue ArgumentError
      next
    end

    count = 0
    mbox = ["/var/mail/#{user}", "/var/spool/mail/#{user}"].find { |p| File.exist?(p) }
    if mbox
      content = File.read(mbox) rescue ""
      count = content.scan(/^From /).size
    end

    users << {
      "user"        => user,
      "count"       => count,
      "dead_letter" => File.exist?(File.join(home, "dead.letter"))
    }
  end

  { "type" => "mail", "users" => users }
end

def read_ups
  out = `apcaccess 2>/dev/null`
  return nil unless $?.success? && !out.empty?

  raw = {}
  out.each_line do |line|
    next unless line.include?(":")
    key, value = line.split(":", 2).map(&:strip)
    raw[key] = value
  end

  result = { "type" => "ups" }
  result["model"] = raw["MODEL"] if raw["MODEL"]
  result["status"] = raw["STATUS"] if raw["STATUS"]

  if raw["BCHARGE"]
    result["charge"] = raw["BCHARGE"].to_f
  end
  if raw["TIMELEFT"]
    result["timeleft"] = raw["TIMELEFT"].to_f
  end
  if raw["LINEV"]
    result["line_voltage"] = raw["LINEV"].to_f
  end
  if raw["LOADPCT"]
    result["load"] = raw["LOADPCT"].to_f
  end
  if raw["NOMBATTV"]
    result["battery_voltage_nom"] = raw["NOMBATTV"].to_f
  end
  if raw["BATTV"]
    result["battery_voltage"] = raw["BATTV"].to_f
  end

  result
end

def handle_info(conn)
  info = []

  Dir.glob("/sys/block/*").each do |block_path|
    name = File.basename(block_path)
    next unless name.match?(/^(sd[a-z]+|nvme\d+n\d+)$/)

    device_path = File.realpath("#{block_path}/device") rescue next
    next if device_path.include?("/usb")

    model = (File.read("#{block_path}/device/model").strip rescue "Unknown")
    bytes = File.read("#{block_path}/size").strip.to_i * 512

    entry = {
      "type" => "drive",
      "drivetype" => drive_type(name),
      "name" => name,
      "model" => model,
      "capacity" => human_capacity(bytes)
    }
    if (smart = read_smart(name))
      entry["smart"] = smart
    end
    info << entry
  end

  info.concat(read_filesystems)
  info.concat(read_virtual_machines)

  info << read_server_info
  info << read_mail
  info << read_backups

  if (ups = read_ups)
    info << ups
  end

  if conn
    conn.puts JSON.generate(info)
  else
    puts JSON.pretty_generate(info)
  end
end

if ARGV.include?("-t")
  handle_info(nil)
  exit
end

if Process.uid != 0
  $stderr.puts "malpd must be run as root"
  exit(1)
end

if ENV["LISTEN_FDS"]
  server = UNIXServer.for_fd(3)
else
  FileUtils.mkdir_p(File.dirname(SOCKET_PATH))
  FileUtils.rm_f(SOCKET_PATH)

  server = UNIXServer.new(SOCKET_PATH)
  File.chmod(0660, SOCKET_PATH)
  FileUtils.chown(nil, "apache", SOCKET_PATH)
end

Signal.trap("TERM") { server.close }
Signal.trap("INT") { server.close }

loop do
  conn = server.accept
  request = conn.gets&.chomp

  begin
    case request
    when nil
      # empty request
    when "info"
      handle_info(conn)
    else
      conn.puts "unknown request: #{request}"
    end
  rescue => e
    $stderr.puts "error handling request: #{e.message}"
  ensure
    conn.close
  end
rescue IOError
  break
end
