#!/usr/bin/env ruby

require "socket"
require "fileutils"
require "json"

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_filesystems
  out = `findmnt -J -b -o SOURCE,TARGET,FSTYPE,SIZE,USED 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
    result << {
      "type"   => "filesystem",
      "source" => source,
      "mount"  => target,
      "fstype" => fstype,
      "size"   => size,
      "used"   => node["used"].to_i
    }
  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)

  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
