#!/usr/bin/env ruby require "cgi" require "erb" require "digest" require "json" require "securerandom" require "socket" def human_age(seconds) return "unknown" if seconds.nil? return "just now" if seconds < 60 return "#{seconds / 60} min ago" if seconds < 3600 return "#{seconds / 3600} h ago" if seconds < 86400 "#{seconds / 86400} d ago" end 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 ASSETS_DIR = File.join(__dir__, "../assets") DATA_DIR = File.join(__dir__, "../data") SESSIONS_FILE = File.join(DATA_DIR, "sessions.txt") USER_FILE = File.join(DATA_DIR, "user.txt") cgi = CGI.new hostname = File.read("/etc/hostname").strip rescue "localhost" def load_sessions return [] unless File.exist?(SESSIONS_FILE) now = Time.now.to_i max_age = 3 * 7 * 24 * 60 * 60 # 3 weeks sessions = File.readlines(SESSIONS_FILE).filter_map do |line| token, timestamp = line.strip.split(":", 2) next if token.nil? || token.empty? [token, timestamp.to_i] end active, expired = sessions.partition { |_, ts| now - ts < max_age } if expired.any? File.write(SESSIONS_FILE, active.map { |t, ts| "#{t}:#{ts}" }.join("\n") + "\n") end active end def valid_session?(token) return false if token.nil? || token.empty? load_sessions.any? { |t, _| t == token } end def check_credentials(username, password) return false unless File.exist?(USER_FILE) stored_user, salt, stored_hash_hex2 = File.read(USER_FILE).strip.split(":", 3) return false unless username == stored_user stored_hash = [stored_hash_hex2].pack("H*") computed_hash = Digest::SHA256.hexdigest(salt + password) stored_hash == computed_hash end def create_session token = SecureRandom.hex(32) File.open(SESSIONS_FILE, "a") { |f| f.puts("#{token}:#{Time.now.to_i}") } token end def remove_session(token) return if token.nil? || token.empty? sessions = load_sessions.reject { |t, _| t == token } File.write(SESSIONS_FILE, sessions.map { |t, ts| "#{t}:#{ts}" }.join("\n") + "\n") end session_token = (cgi.cookies["MALP"] || []).first authenticated = valid_session?(session_token) cookie = nil if cgi.request_method == "POST" && authenticated && cgi.params["action"]&.first == "logout" remove_session(session_token) cookie = CGI::Cookie.new("name" => "MALP", "value" => "", "path" => "/", "expires" => Time.at(0)) print cgi.header("Status" => "303 See Other", "Location" => ENV["REQUEST_URI"], "cookie" => cookie) exit end if cgi.request_method == "POST" && !authenticated username = cgi.params["username"]&.first.to_s password = cgi.params["password"]&.first.to_s if check_credentials(username, password) token = create_session cookie = CGI::Cookie.new("name" => "MALP", "value" => token, "path" => "/") print cgi.header("Status" => "303 See Other", "Location" => ENV["REQUEST_URI"], "cookie" => cookie) exit end end if cgi.params.key?("content") if authenticated html = "" begin sock = UNIXSocket.new("/run/malpd/malpd.sock") sock.puts "info" info = JSON.parse(sock.gets) sock.close rescue cgi.out("type" => "text/html", "charset" => "UTF-8") do "Could not connect to malpd socket" end exit end drives = info.select { |e| e["type"] == "drive" } filesystems = info.select { |e| e["type"] == "filesystem" } vms = info.select { |e| e["type"] == "vm" } server = info.find { |e| e["type"] == "server" } html << %(
) if server html << %(
Server
) html << %(
Server Info
) if server["os_name"] html << <<~HTML
OS
#{CGI.escapeHTML(server["os_name"])}
HTML end if server["install_date"] html << <<~HTML
Installed
#{CGI.escapeHTML(server["install_date"])}
HTML end cpu_model = server["cpu_model"] cpu_cores = server["cpu_cores"] if cpu_model || cpu_cores cpu_text = [ cpu_model, cpu_cores ? "#{cpu_cores} thread#{cpu_cores == 1 ? "" : "s"}" : nil ].compact.join(" · ") html << <<~HTML
CPU
#{CGI.escapeHTML(cpu_text)}
HTML end mem_total = server["mem_total"].to_i mem_used = server["mem_used"].to_i if mem_total > 0 mem_pct_f = mem_used * 100.0 / mem_total mem_pct = mem_pct_f.round mem_cls = mem_pct_f >= 90 ? "bad" : mem_pct_f >= 75 ? "warn" : "ok" html << <<~HTML
Memory
#{mem_pct}% · #{CGI.escapeHTML(human_capacity(mem_used))} / #{CGI.escapeHTML(human_capacity(mem_total))}
HTML end html << %(
) end if filesystems.any? total_size = filesystems.sum { |e| e["size"].to_i } total_used = filesystems.sum { |e| e["used"].to_i } total_pct_f = total_size > 0 ? (total_used * 100.0 / total_size) : 0.0 total_pct = total_pct_f.round total_cls = total_pct_f >= 90 ? "bad" : total_pct_f >= 75 ? "warn" : "ok" fs_rows = filesystems.map do |entry| mount = CGI.escapeHTML(entry["mount"].to_s) source = CGI.escapeHTML(entry["source"].to_s) fstype = CGI.escapeHTML(entry["fstype"].to_s) size_b = entry["size"].to_i used_b = entry["used"].to_i pct_f = size_b > 0 ? (used_b * 100.0 / size_b) : 0.0 pct = pct_f.round space_cls = pct_f >= 90 ? "bad" : pct_f >= 75 ? "warn" : "ok" used_h = CGI.escapeHTML(human_capacity(used_b)) size_h = CGI.escapeHTML(human_capacity(size_b)) fs_stats = [] inode_cls = "ok" if (itot = entry["inode_total"].to_i) > 0 iused = entry["inode_used"].to_i ipct_f = iused * 100.0 / itot inode_cls = ipct_f >= 90 ? "bad" : ipct_f >= 75 ? "warn" : "ok" fs_stats << [inode_cls, "Inodes #{ipct_f.round}%"] end fs_stats << ["warn", "READ-ONLY"] if entry["readonly"] severities = [space_cls, inode_cls] row_cls = severities.include?("bad") ? "bad" : severities.include?("warn") ? "warn" : "ok" stats_html = fs_stats.map { |c, txt| %(#{CGI.escapeHTML(txt)}) }.join stats_block = fs_stats.empty? ? "" : %(
#{stats_html}
) row_html = <<~HTML
#{mount} · #{source} #{fstype}
#{pct}% · #{used_h} / #{size_h}
#{stats_block}
HTML [row_html, row_cls] end severities = fs_rows.map { |_, c| c } card_cls = severities.include?("bad") ? "bad" : severities.include?("warn") ? "warn" : "ok" total_used_h = CGI.escapeHTML(human_capacity(total_used)) total_size_h = CGI.escapeHTML(human_capacity(total_size)) html << %(
File Systems
) html << <<~HTML
File Systems #{total_pct}%
HTML fs_rows.each { |row_html, _| html << row_html } html << %(
) html << <<~HTML
Total #{total_pct}% · #{total_used_h} / #{total_size_h}
HTML html << %(
) end html << %(
) html << %(
) if vms.any? running = vms.count { |v| v["state"].to_s == "running" } total = vms.size hdr_cls = running == total ? "ok" : running == 0 ? "bad" : "warn" html << %(
Virtual Machines
) html << <<~HTML
Libvirt #{running} / #{total} Running
VM OS Resources Disk State
HTML vms.each do |vm| name = CGI.escapeHTML(vm["name"].to_s) os_name = vm["os_name"] ? CGI.escapeHTML(vm["os_name"]) : nil state = vm["state"].to_s state_cls = case state when "running" then "ok" when "paused", "in shutdown", "pmsuspended" then "warn" when "shut off", "crashed" then "bad" else "warn" end state_label = CGI.escapeHTML(state.split.map(&:capitalize).join(" ")) vcpus = vm["vcpus"].to_i mem_b = vm["memory"].to_i res = vcpus > 0 || mem_b > 0 ? "#{vcpus} CPU#{vcpus > 1 ? "s" : ""} · #{human_capacity(mem_b)}" : "—" df = vm["df"] || [] cap = df[0].to_i * 1024 used = df[1].to_i * 1024 dpct_f = cap > 0 ? (used * 100.0 / cap) : 0.0 dpct = dpct_f.round disk_cls = dpct_f >= 90 ? "bad" : dpct_f >= 75 ? "warn" : "ok" disk_html = if cap > 0 %(
#{dpct}% · #{CGI.escapeHTML(human_capacity(used))} / #{CGI.escapeHTML(human_capacity(cap))}
) else %(
) end os_html = os_name ? os_name : "" vm_stats = [] if updates = vm["updates"] cls = updates == 0 ? "ok" : "warn" vm_stats << [cls, "#{updates} update#{updates == 1 ? "" : "s"}"] end if reboot = vm["reboot_pending"] vm_stats << ["warn", "Reboot pending"] end vm_stats_html = vm_stats.map { |c, txt| %(#{CGI.escapeHTML(txt)}) }.join vm_stats_block = vm_stats.empty? ? "" : %(
#{vm_stats_html}
) html << <<~HTML
#{name}
#{os_html}
#{CGI.escapeHTML(res)}
#{disk_html}
#{state_label}
#{vm_stats_block}
HTML end html << %(
) end thousands = ->(n) { n.to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,') } drive_stats = lambda do |dt, smart| stats = [] case smart["passed"] when true then stats << ["ok", "SMART PASSED"] when false then stats << ["bad", "SMART FAILED"] end if (t = smart["temperature"]) cls = t >= 60 ? "bad" : t >= 50 ? "warn" : "ok" stats << [cls, "🌡 #{t} °C"] end if (poh = smart["power_on_hours"]) stats << ["ok", "POH #{thousands.(poh)} h"] end if dt == "nvme" if (p = smart["percentage_used"]) cls = p >= 90 ? "bad" : p >= 70 ? "warn" : "ok" stats << [cls, "Wear #{p}%"] end if (m = smart["media_errors"]) stats << [m > 0 ? "bad" : "ok", "Media err #{m}"] end if (cw = smart["critical_warning"]) && cw != 0 stats << ["bad", "Crit warn 0x#{cw.to_s(16)}"] end else if (r = smart["reallocated_sectors"]) stats << [r > 0 ? "warn" : "ok", "Realloc #{r}"] end if (s = smart["spin_retry"]) stats << [s > 0 ? "warn" : "ok", "Spin retry #{s}"] end if (p = smart["pending_sectors"]) stats << [p > 0 ? "warn" : "ok", "Pending #{p}"] end if (c = smart["crc_errors"]) stats << [c > 0 ? "warn" : "ok", "CRC #{c}"] end end stats end upses = info.select { |e| e["type"] == "ups" } mail = info.find { |e| e["type"] == "mail" } mail_visible = mail && (mail["users"] || []).any? backup = info.find { |e| e["type"] == "backup" } backup_visible = backup && (backup["entries"] || []).any? if drives.any? || upses.any? || mail_visible || backup_visible html << %(
) end if drives.any? html << %(
) html << %() html << %(
) drives.each do |entry| dt = entry["drivetype"] name = CGI.escapeHTML(entry["name"].to_s) model = CGI.escapeHTML(entry["model"]) capacity = CGI.escapeHTML(entry["capacity"]) smart = entry["smart"] stats = smart ? drive_stats.(dt, smart) : [] card_cls = if stats.any? { |c, _| c == "bad" } then "bad" elsif stats.any? { |c, _| c == "warn" } then "warn" else "ok" end stats_html = stats.map { |c, txt| %(#{CGI.escapeHTML(txt)}) }.join stats_block = stats.empty? ? "" : %(
#{stats_html}
) title = name.empty? ? model : "#{name} · #{model}" html << <<~HTML
#{title} #{capacity} #{dt.upcase}
#{stats_block}
HTML end html << %(
) html << %(
) end if upses.any? || mail_visible || backup_visible html << %(
) upses.each do |ups| status = ups["status"].to_s online = status.include?("ONLINE") card_cls = online ? "ok" : "bad" model = ups["model"] ? "APC " + CGI.escapeHTML(ups["model"]) : "UPS" stats = [] stats << [online ? "ok" : "bad", CGI.escapeHTML(status)] unless status.empty? if (charge = ups["charge"]) cls = charge >= 80 ? "ok" : charge >= 50 ? "warn" : "bad" stats << [cls, "Charge #{charge.round}%"] end if (timeleft = ups["timeleft"]) cls = timeleft >= 10 ? "ok" : timeleft >= 5 ? "warn" : "bad" stats << [cls, "#{timeleft.round(1)} min left"] end if (load = ups["load"]) cls = load >= 90 ? "bad" : load >= 70 ? "warn" : "ok" stats << [cls, "Load #{load.round}%"] end if (lv = ups["line_voltage"]) stats << ["ok", "Line #{lv.round} V"] end if (bv = ups["battery_voltage"]) stats << ["ok", "Batt #{bv.round(1)} V"] end stats_html = stats.map { |c, txt| %(#{txt}) }.join stats_block = stats.empty? ? "" : %(
#{stats_html}
) html << %() html << <<~HTML
#{model} #{online ? "Online" : CGI.escapeHTML(status)}
#{stats_block}
HTML end if mail_visible users = mail["users"] has_warn = users.any? { |u| u["count"].to_i > 0 || u["dead_letter"] } mail_card_cls = has_warn ? "warn" : "ok" html << %() html << %(
Local Mail
) users.each do |u| count = u["count"].to_i cls = count > 0 ? "warn" : "ok" label = count == 1 ? "1 message" : "#{count} messages" html << <<~HTML
#{CGI.escapeHTML(u["user"].to_s)}
#{label}
HTML end html << %(
) dead = users.select { |u| u["dead_letter"] } if dead.any? stats_html = dead.map { |u| %(dead.letter in ~#{CGI.escapeHTML(u["user"].to_s)}) }.join html << %(
#{stats_html}
) end html << %(
) end if backup_visible now = Time.now.to_i classified = backup["entries"].map do |e| mtime = e["mtime"] cls = if mtime.nil? "bad" else age = now - mtime age < 2 * 86400 ? "ok" : age < 7 * 86400 ? "warn" : "bad" end [e, cls] end backup_card_cls = if classified.any? { |_, c| c == "bad" } then "bad" elsif classified.any? { |_, c| c == "warn" } then "warn" else "ok" end html << %() html << %(
Backups
) classified.each do |e, cls| age_label = e["mtime"] ? human_age(now - e["mtime"]) : "missing" html << <<~HTML
#{CGI.escapeHTML(e["label"].to_s)}
#{CGI.escapeHTML(age_label)}
HTML end html << %(
) end html << %(
) end if drives.any? || upses.any? || mail_visible || backup_visible html << %(
) end html << %(
) cgi.out("type" => "text/html", "charset" => "UTF-8") do html end else cgi.out("Status" => "403 Forbidden", "type" => "text/plain", "charset" => "UTF-8") do "Forbidden" end end exit end template = ERB.new(File.read(File.join(ASSETS_DIR, "page.erb"))) out_params = { "type" => "text/html", "charset" => "UTF-8" } out_params["cookie"] = cookie if cookie cgi.out(out_params) do template.result(binding) end