malp/cgi-bin/malp.rb

608 lines
22 KiB
Ruby
Executable File

#!/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 << %(<div>)
if server
html << %(<div class="section-label">Server</div>)
html << %(<div class="card ok"><div class="card-header"><span class="card-title">Server Info</span></div><div class="subitems">)
if server["os_name"]
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">OS</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(server["os_name"])}</span>
</div>
HTML
end
if server["install_date"]
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">Installed</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(server["install_date"])}</span>
</div>
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
<div class="subitem">
<div class="subitem-left"><div class="dot ok"></div><span class="subitem-name">CPU</span></div>
<span class="subitem-value ok">#{CGI.escapeHTML(cpu_text)}</span>
</div>
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
<div class="subitem" style="flex-direction:column;align-items:stretch;gap:0.35rem;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div class="subitem-left"><div class="dot #{mem_cls}"></div><span class="subitem-name">Memory</span></div>
<span class="subitem-value #{mem_cls}">#{mem_pct}% · #{CGI.escapeHTML(human_capacity(mem_used))} / #{CGI.escapeHTML(human_capacity(mem_total))}</span>
</div>
<div class="bar-track"><div class="bar-fill #{mem_cls}" style="width:#{mem_pct}%"></div></div>
</div>
HTML
end
html << %(</div></div>)
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|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
stats_block = fs_stats.empty? ? "" :
%(<div class="drive-stats">#{stats_html}</div>)
row_html = <<~HTML
<div class="subitem" style="flex-direction:column;align-items:stretch;gap:0.35rem;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div class="subitem-left">
<div class="dot #{space_cls}"></div>
<span class="subitem-name">#{mount} · #{source}</span>
<span class="card-capacity">#{fstype}</span>
</div>
<span class="subitem-value #{space_cls}">#{pct}% · #{used_h} / #{size_h}</span>
</div>
<div class="bar-track"><div class="bar-fill #{space_cls}" style="width:#{pct}%"></div></div>
#{stats_block}
</div>
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 << %(<div class="section-label">File Systems</div>)
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">File Systems</span>
<span class="badge #{total_cls}">#{total_pct}%</span>
</div>
<div class="subitems">
HTML
fs_rows.each { |row_html, _| html << row_html }
html << %(</div>)
html << <<~HTML
<div style="margin-top:0.75rem;padding-top:0.6rem;border-top:1px solid #1e2433;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem;">
<span style="font-size:0.72rem;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Total</span>
<span class="subitem-value #{total_cls}">#{total_pct}% · #{total_used_h} / #{total_size_h}</span>
</div>
<div class="bar-track" style="height:6px;"><div class="bar-fill #{total_cls}" style="width:#{total_pct}%"></div></div>
</div>
HTML
html << %(</div>)
end
html << %(</div>)
html << %(<div class="span-3">)
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 << %(<div class="section-label">Virtual Machines</div>)
html << <<~HTML
<div class="card #{hdr_cls}">
<div class="card-header">
<span class="card-title">Libvirt</span>
<span class="badge #{hdr_cls}">#{running} / #{total} Running</span>
</div>
<div class="subitems">
<div style="display:grid;grid-template-columns:1fr 1fr 20ex 2fr 10ex;gap:0.5rem;padding:0 0.6rem;font-size:0.68rem;color:#334155;text-transform:uppercase;letter-spacing:0.06em;font-weight:600;">
<span>VM</span>
<span>OS</span>
<span style="text-align:right;padding-right:1rem;">Resources</span>
<span>Disk</span>
<span style="text-align:right;">State</span>
</div>
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
%(<div>
<div style="font-size:0.72rem;color:#94a3b8;">#{dpct}% · #{CGI.escapeHTML(human_capacity(used))} / #{CGI.escapeHTML(human_capacity(cap))}</div>
<div class="bar-track" style="margin-top:0.25rem;"><div class="bar-fill #{disk_cls}" style="width:#{dpct}%"></div></div>
</div>)
else
%(<div><span style="font-size:0.72rem;color:#475569;">—</span></div>)
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|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
vm_stats_block = vm_stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.4rem;padding:0 0.6rem;">#{vm_stats_html}</div>)
html << <<~HTML
<div class="subitem" style="flex-direction:column;align-items:stretch;gap:0.35rem;">
<div style="display:grid;grid-template-columns:1fr 1fr 20ex 2fr 10ex;gap:0.5rem;align-items:center;">
<div class="subitem-left">
<div class="dot #{state_cls}"></div>
<div class="subitem-name">#{name}</div>
</div>
<div class="subitem-name">#{os_html}</div>
<div style="text-align:right;padding-right:1rem;"><span class="subitem-value ok">#{CGI.escapeHTML(res)}</span></div>
#{disk_html}
<div style="text-align:right;"><span class="subitem-value #{state_cls}">#{state_label}</span></div>
</div>
#{vm_stats_block}
</div>
HTML
end
html << %(</div></div>)
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 << %(<div style="display:grid;grid-template-columns:2fr 1fr;gap:1rem;align-items:start;margin-top:1rem;">)
end
if drives.any?
html << %(<div>)
html << %(<div class="section-label">Storage Drives</div>)
html << %(<div class="stack">)
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|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
stats_block = stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
title = name.empty? ? model : "#{name} · #{model}"
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">#{title} <span class="card-capacity">#{capacity}</span> <span class="drive-type #{dt}">#{dt.upcase}</span></span>
</div>
#{stats_block}
</div>
HTML
end
html << %(</div>)
html << %(</div>)
end
if upses.any? || mail_visible || backup_visible
html << %(<div>)
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|
%(<span class="stat #{c}">#{txt}</span>)
}.join
stats_block = stats.empty? ? "" :
%(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
html << %(<div class="section-label">UPS</div>)
html << <<~HTML
<div class="card #{card_cls}">
<div class="card-header">
<span class="card-title">#{model}</span>
<span class="badge #{card_cls}">#{online ? "Online" : CGI.escapeHTML(status)}</span>
</div>
#{stats_block}
</div>
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 << %(<div class="section-label">Mail</div>)
html << %(<div class="card #{mail_card_cls}"><div class="card-header"><span class="card-title">Local Mail</span></div><div class="subitems">)
users.each do |u|
count = u["count"].to_i
cls = count > 0 ? "warn" : "ok"
label = count == 1 ? "1 message" : "#{count} messages"
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot #{cls}"></div><span class="subitem-name">#{CGI.escapeHTML(u["user"].to_s)}</span></div>
<span class="subitem-value #{cls}">#{label}</span>
</div>
HTML
end
html << %(</div>)
dead = users.select { |u| u["dead_letter"] }
if dead.any?
stats_html = dead.map { |u|
%(<span class="stat warn">dead.letter in ~#{CGI.escapeHTML(u["user"].to_s)}</span>)
}.join
html << %(<div class="drive-stats" style="margin-top:0.6rem;">#{stats_html}</div>)
end
html << %(</div>)
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 << %(<div class="section-label">Backups</div>)
html << %(<div class="card #{backup_card_cls}"><div class="card-header"><span class="card-title">Backups</span></div><div class="subitems">)
classified.each do |e, cls|
age_label = e["mtime"] ? human_age(now - e["mtime"]) : "missing"
html << <<~HTML
<div class="subitem">
<div class="subitem-left"><div class="dot #{cls}"></div><span class="subitem-name">#{CGI.escapeHTML(e["label"].to_s)}</span></div>
<span class="subitem-value #{cls}">#{CGI.escapeHTML(age_label)}</span>
</div>
HTML
end
html << %(</div></div>)
end
html << %(</div>)
end
if drives.any? || upses.any? || mail_visible || backup_visible
html << %(</div>)
end
html << %(</div>)
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