Compare commits

...
37 Commits
Author SHA1 Message Date
josh 2c0da87fac Add package update count and reboot pending to host server info card 2026-08-21 10:17:20 -04:00
josh a21bf2c215 Handle ssh attempt to VM that is down 2026-05-01 07:18:11 -04:00
josh 16f88d4401 Combine file systems into one card 2026-04-20 22:57:31 -04:00
josh 4b74bc01a7 Add Backups card 2026-04-20 22:39:45 -04:00
josh ca45a90930 Add Mail card 2026-04-20 22:19:50 -04:00
josh b94343722f Left-align page title; compact vertically a bit 2026-04-20 21:23:12 -04:00
josh c14f08d4b1 Add Updated time stamp 2026-04-20 21:07:23 -04:00
josh 93c9bf27fa Reorganize cards with server info and file systems in left column 2026-04-20 21:04:09 -04:00
josh 49a20a946f Add Server Info card 2026-04-20 20:57:39 -04:00
josh 5687d8a4e7 Center loading spinner 2026-04-19 22:58:41 -04:00
josh f037a97792 Reorganize to allow file systems next to VMs, storage drives, and UPS cards 2026-04-19 22:56:45 -04:00
josh b78fb9469e Add UPS status card 2026-04-19 22:40:26 -04:00
josh e6d2945b7b Rename MALP 2026-04-19 15:14:17 -04:00
josh 69a5baeb8d Add VM info from bin/vm-info to status page 2026-04-19 15:08:11 -04:00
josh ad23419b99 Get VM info in malpd from vm-info script via SSH 2026-04-17 22:35:35 -04:00
josh ea932dd641 Do not downcase OS name 2026-04-17 22:30:10 -04:00
josh 34e984d563 Add vm-info script to gather VM info 2026-04-17 21:35:43 -04:00
josh 5773347bda Add virtual machines list 2026-04-14 09:12:30 -04:00
josh 23008b4997 Show file system inode usage and read-only status 2026-04-14 08:41:39 -04:00
josh 1e87b08b54 Add file systems 2026-04-13 23:04:21 -04:00
josh a282debfbd Move drive capacity to top row of drive card 2026-04-13 22:04:27 -04:00
josh 23d7e79605 Add drive SMART attributes and health status 2026-04-13 21:57:00 -04:00
josh 40109b5c62 Add NVMe drive style 2026-04-13 20:31:57 -04:00
josh 08a2a6df4b Add several card styles 2026-04-13 20:29:39 -04:00
josh 80cae35688 Add selinux policy for malpd.sock access; update README.md 2026-04-01 22:59:46 -04:00
josh 5e60752594 Update install instructions with Ruby/systemd 2026-04-01 21:13:09 -04:00
josh 1180849b44 Add systemd unit files for malpd and support systemd socket activation 2026-04-01 21:05:57 -04:00
josh c717b5d589 Report errors connecting to malpd socket 2026-04-01 20:58:34 -04:00
josh 7c907fe0ce Report Error on status page if content page returns error 2026-04-01 20:55:55 -04:00
josh 51aa0c805e Render drive info cards 2026-04-01 20:54:44 -04:00
josh 46a1887982 malpd: report drive info 2026-04-01 20:36:31 -04:00
josh ad9628e45c Add malpd daemon executable 2026-04-01 19:59:17 -04:00
josh 1eb58f87d2 Add loading indicator 2026-04-01 13:13:15 -04:00
josh 244edf90ac Add asynchronous page content request 2026-04-01 13:06:06 -04:00
josh ed4e749901 Add log out button 2026-03-31 21:53:29 -04:00
josh 39f7dd6285 Do not show login box when logged in 2026-03-31 21:42:55 -04:00
josh 327c4b755f Redirect after login to allow refresh without browser prompt 2026-03-31 21:32:30 -04:00
9 changed files with 1488 additions and 21 deletions

No files matched your search

+60 -1
View File
@@ -1,4 +1,4 @@
# MALP - Monitor A Linux Platform # MALP - MALP Assesses a Linux Platform
A lightweight Ruby CGI status page for home server monitoring. A lightweight Ruby CGI status page for home server monitoring.
@@ -6,16 +6,30 @@ A lightweight Ruby CGI status page for home server monitoring.
Copy this repository directory to `/var/www`, so `/var/www/malp/cgi-bin` exists. Copy this repository directory to `/var/www`, so `/var/www/malp/cgi-bin` exists.
As root:
``` ```
mkdir /var/www/malp/data mkdir /var/www/malp/data
chown apache:apache /var/www/malp/data chown apache:apache /var/www/malp/data
``` ```
### Install ruby
As root:
```
dnf install ruby
```
### If using SELinux (e.g. AlmaLinux) ### If using SELinux (e.g. AlmaLinux)
As root:
``` ```
chcon -R -t httpd_sys_script_exec_t /var/www/malp/cgi-bin chcon -R -t httpd_sys_script_exec_t /var/www/malp/cgi-bin
chcon -R -t httpd_sys_rw_content_t /var/www/malp/data chcon -R -t httpd_sys_rw_content_t /var/www/malp/data
chcon -t bin_t /var/www/malp/bin/malpd
semodule -i /var/www/malp/selinux/malp_to_malpd.pp
``` ```
### Example Apache HTTPD Setup ### Example Apache HTTPD Setup
@@ -26,6 +40,8 @@ Replace as desired.
#### Create self-signed TLS certificate #### Create self-signed TLS certificate
As root:
``` ```
mkdir /etc/httpd/tls mkdir /etc/httpd/tls
cd /etc/httpd/tls cd /etc/httpd/tls
@@ -74,6 +90,49 @@ openssl req -new -x509 -key anubis.key -out anubis.crt -days 3650 -sha384
### Set user name and password ### Set user name and password
As root:
``` ```
/var/www/malp/bin/setpasswd /var/www/malp/bin/setpasswd
``` ```
### Install systemd units
As root:
```
cp /var/www/malp/systemd/* /usr/lib/systemd/system
systemctl enable --now malpd.socket
systemctl enable --now malpd.service
```
### Virtual Machine Info
#### Generate SSH Key
On server host OS, generate an SSH key that will be used to gather information
from each running VM.
Leave the passphrase empty so it can be used non-interactively.
```
ssh-keygen -f /root/.ssh/malp-vm-key
```
#### malp user
On each VM to gather status information, add a `malp` user with:
```
useradd -m malp
```
Add a /home/malp/.ssh/authorized_keys file with content such as (replacing the
SSH key beginning with `ssh-ed25519`...):
```
command="/path/to/malp/bin/vm-info",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3Nza...user@example.com
```
#### vm-info script
Make the `bin/vm-info` script available on each VM (via scp/rsync, NFS, etc...)
+325 -19
View File
@@ -11,17 +11,25 @@
font-family: 'Segoe UI', system-ui, sans-serif; font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
padding: 2rem; padding: 1rem 2rem 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.header-controls {
position: fixed;
top: 1rem;
right: 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header { header {
width: 100%; width: 100%;
max-width: 400px; text-align: left;
text-align: center; margin-bottom: 0rem;
margin-bottom: 2rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid #1e2433; border-bottom: 1px solid #1e2433;
} }
@@ -32,6 +40,7 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #94a3b8; color: #94a3b8;
text-transform: uppercase; text-transform: uppercase;
margin: 0;
} }
header h1 span { color: #e2e8f0; } header h1 span { color: #e2e8f0; }
@@ -39,8 +48,13 @@
.card { .card {
background: #161b27; background: #161b27;
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem 1.1rem;
border: 1px solid #1e2433; border: 1px solid #1e2433;
transition: border-color 0.2s;
}
.card.auth {
padding: 2rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-sizing: border-box; box-sizing: border-box;
@@ -52,10 +66,17 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #94a3b8; color: #94a3b8;
}
.card.auth .card-title {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
text-align: center; text-align: center;
} }
#content {
align-self: stretch;
}
.form-group { .form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -110,28 +131,313 @@
button:hover { button:hover {
background: #166534; background: #166534;
} }
.logout-form {
margin: 0;
}
.logout-btn {
width: auto;
padding: 0.4rem 0.8rem;
margin: 0;
background: #1e2433;
color: #94a3b8;
font-size: 0.75rem;
}
.logout-btn:hover {
background: #2a3040;
}
.spinner {
grid-column: 1 / -1;
width: 24px;
height: 24px;
margin: 1rem auto;
border: 3px solid #1e2433;
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.last-updated {
font-size: 0.75rem;
color: #475569;
}
.overall-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
color: #4ade80;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
align-items: start;
}
@media (max-width: 1200px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 800px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 500px) { .grid { grid-template-columns: 1fr; } }
/* span modifiers */
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
.span-all { grid-column: 1 / -1; }
.card:hover { border-color: #2d3748; }
.card.ok { border-left: 3px solid #22c55e; }
.card.warn { border-left: 3px solid #f59e0b; }
.card.bad { border-left: 3px solid #ef4444; }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge.ok { background: #14532d; color: #4ade80; }
.badge.warn { background: #451a03; color: #fbbf24; }
.badge.bad { background: #450a0a; color: #f87171; }
.card-value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
margin-bottom: 0.15rem;
}
.card-value.ok { color: #4ade80; }
.card-value.warn { color: #fbbf24; }
.card-value.bad { color: #f87171; }
.card-sub {
font-size: 0.72rem;
color: #475569;
margin-bottom: 0.6rem;
}
/* progress bar */
.bar-track {
height: 4px;
background: #1e2433;
border-radius: 2px;
overflow: hidden;
margin-top: 0.5rem;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.bar-fill.ok { background: #22c55e; }
.bar-fill.warn { background: #f59e0b; }
.bar-fill.bad { background: #ef4444; }
/* subitems */
.subitems {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-top: 0.6rem;
}
.subitem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: #0f1117;
border-radius: 7px;
font-size: 0.78rem;
}
.subitem-left {
display: flex;
align-items: center;
gap: 0.45rem;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.ok { background: #22c55e; box-shadow: 0 0 5px #22c55e88; }
.dot.warn { background: #f59e0b; box-shadow: 0 0 5px #f59e0b88; }
.dot.bad { background: #ef4444; box-shadow: 0 0 5px #ef444488; }
.subitem-name { color: #cbd5e1; }
.subitem-value {
font-size: 0.72rem;
font-weight: 600;
}
.subitem-value.ok { color: #4ade80; }
.subitem-value.warn { color: #fbbf24; }
.subitem-value.bad { color: #f87171; }
/* drive capacity chip (shown inline in card title) */
.card-capacity {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
color: #cbd5e1;
background: #0f1117;
border: 1px solid #1e2433;
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.4rem;
text-transform: none;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
/* drive type badge */
.drive-type {
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-left: 0.35rem;
}
.drive-type.hdd { background: #1e3a5f; color: #60a5fa; }
.drive-type.ssd { background: #2d1b69; color: #a78bfa; }
.drive-type.nvme { background: #134e4a; color: #5eead4; }
/* per-drive health stats row */
.drive-stats {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.stat {
font-size: 0.67rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 5px;
}
.stat.ok { background: #0f2a1a; color: #4ade80; }
.stat.warn { background: #2d1a00; color: #fbbf24; }
.stat.bad { background: #2d0a0a; color: #f87171; }
/* uptime row */
.uptime-blocks {
display: flex;
gap: 2px;
margin-top: 0.5rem;
}
.uptime-block {
flex: 1;
height: 20px;
border-radius: 3px;
}
.uptime-block.ok { background: #22c55e; }
.uptime-block.bad { background: #ef4444; }
.uptime-block.warn { background: #f59e0b; }
.uptime-block.empty { background: #1e2433; }
.section-label {
font-size: 0.68rem;
color: #334155;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 1.5rem 0 0.5rem;
grid-column: 1 / -1;
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style> </style>
</head> </head>
<body> <body>
<% if authenticated %>
<div class="header-controls">
<div class="last-updated" id="last-updated"></div>
<form method="post" class="logout-form">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Log Out</button>
</form>
</div>
<% end %>
<header> <header>
<h1><span><%= hostname %></span> status - MALP</h1> <h1><span><%= hostname %></span> status - MALP</h1>
</header> </header>
<div class="card"> <% if authenticated %>
<div class="card-title">Sign In</div> <div id="content" class="grid"><div class="spinner"></div></div>
<form method="post"> <script>
<div class="form-group"> fetch("?content", { credentials: "same-origin" })
<label for="username">Username</label> .then(function(response) {
<input type="text" id="username" name="username" autocomplete="username" required autofocus> if (response.ok) return response.text();
</div> throw new Error();
<div class="form-group"> })
<label for="password">Password</label> .then(function(html) {
<input type="password" id="password" name="password" autocomplete="current-password" required> document.getElementById("content").innerHTML = html;
</div> var now = new Date();
<button type="submit">Log In</button> var pad = function(n) { return String(n).padStart(2, "0"); };
</form> var stamp = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
</div> " " + pad(now.getHours()) + ":" + pad(now.getMinutes());
document.getElementById("last-updated").textContent = "Updated: " + stamp;
})
.catch(function() {
document.getElementById("content").innerHTML = "Error";
});
</script>
<% end %>
<% unless authenticated %>
<div class="card auth">
<div class="card-title">Sign In</div>
<form method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit">Log In</button>
</form>
</div>
<% end %>
</body> </body>
</html> </html>
Executable
+434
View File
@@ -0,0 +1,434 @@
#!/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 -o ConnectTimeout=1 -Ti /root/.ssh/malp-vm-key malp@#{name} 2>/dev/null`
if ssh_out != ""
JSON.parse(ssh_out)
end
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_os_id
return nil unless File.exist?("/etc/os-release")
File.readlines("/etc/os-release").each do |line|
if line =~ /^ID=(.+)$/
return $1.strip.delete('"').downcase
end
end
nil
end
def apt_updates
out = `apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null`
count = 0
out.each_line do |line|
count += 1 if line.start_with?("Inst ")
end
count
end
def apt_reboot_pending
File.exist?("/var/run/reboot-required")
end
def dnf_updates
out = `dnf -q check-update 2>/dev/null`
status = $?.exitstatus
return 0 if status == 0
return nil unless status == 100
count = 0
out.each_line do |line|
line = line.strip
next if line.empty?
next if line.start_with?("Obsoleting", "Last metadata")
count += 1 if line.split(/\s+/).size >= 3
end
count
end
def dnf_reboot_pending
`dnf needs-restarting -r >/dev/null 2>&1`
$?.exitstatus == 1
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
case read_os_id
when "ubuntu", "debian"
info["updates"] = apt_updates
info["reboot_pending"] = apt_reboot_pending
when "almalinux", "rocky", "rhel", "centos", "fedora"
info["updates"] = dnf_updates
info["reboot_pending"] = dnf_reboot_pending
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
Executable
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env ruby
require "json"
def read_os_id
return nil unless File.exist?("/etc/os-release")
File.readlines("/etc/os-release").each do |line|
if line =~ /^ID=(.+)$/
return $1.strip.delete('"').downcase
end
end
nil
end
def read_os_name
return nil unless File.exist?("/etc/os-release")
File.readlines("/etc/os-release").each do |line|
if line =~ /^PRETTY_NAME="(.+)"$/
return $1.strip.delete('"')
end
end
nil
end
def ubuntu_updates
out = `apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null`
count = 0
out.each_line do |line|
count += 1 if line.start_with?("Inst ")
end
count
end
def ubuntu_reboot_pending
File.exist?("/var/run/reboot-required")
end
def alma_updates
out = `dnf -q check-update 2>/dev/null`
status = $?.exitstatus
return 0 if status == 0
return nil unless status == 100
count = 0
out.each_line do |line|
line = line.strip
next if line.empty?
next if line.start_with?("Obsoleting", "Last metadata")
count += 1 if line.split(/\s+/).size >= 3
end
count
end
def alma_reboot_pending
`dnf needs-restarting -r >/dev/null 2>&1`
$?.exitstatus == 1
end
def df
df_out = `df -k /`
if df_out =~ /^\S+\s+(\d+)\s+(\d+)/
total, used = $1.to_i, $2.to_i
[total, used]
end
end
result = {}
if os = read_os_id
result["os"] = os
end
if os_name = read_os_name
result["os_name"] = os_name
end
case os
when "ubuntu", "debian"
result["updates"] = ubuntu_updates
result["reboot_pending"] = ubuntu_reboot_pending
when "almalinux", "rocky", "rhel", "centos", "fedora"
result["updates"] = alma_updates
result["reboot_pending"] = alma_reboot_pending
end
if df_info = df
result["df"] = df_info
end
puts JSON.generate(result)
+553 -1
View File
@@ -3,7 +3,29 @@
require "cgi" require "cgi"
require "erb" require "erb"
require "digest" require "digest"
require "json"
require "securerandom" 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") ASSETS_DIR = File.join(__dir__, "../assets")
DATA_DIR = File.join(__dir__, "../data") DATA_DIR = File.join(__dir__, "../data")
@@ -50,10 +72,23 @@ def create_session
token token
end 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 session_token = (cgi.cookies["MALP"] || []).first
authenticated = valid_session?(session_token) authenticated = valid_session?(session_token)
cookie = nil 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 if cgi.request_method == "POST" && !authenticated
username = cgi.params["username"]&.first.to_s username = cgi.params["username"]&.first.to_s
password = cgi.params["password"]&.first.to_s password = cgi.params["password"]&.first.to_s
@@ -61,10 +96,527 @@ if cgi.request_method == "POST" && !authenticated
if check_credentials(username, password) if check_credentials(username, password)
token = create_session token = create_session
cookie = CGI::Cookie.new("name" => "MALP", "value" => token, "path" => "/") cookie = CGI::Cookie.new("name" => "MALP", "value" => token, "path" => "/")
authenticated = true print cgi.header("Status" => "303 See Other", "Location" => ENV["REQUEST_URI"], "cookie" => cookie)
exit
end end
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
srv_stats = []
if updates = server["updates"]
cls = updates == 0 ? "ok" : "warn"
srv_stats << [cls, "#{updates} update#{updates == 1 ? "" : "s"}"]
end
if server["reboot_pending"]
srv_stats << ["warn", "Reboot pending"]
end
srv_cls = srv_stats.any? { |c, _| c == "bad" } ? "bad" :
srv_stats.any? { |c, _| c == "warn" } ? "warn" : "ok"
html << %(<div class="section-label">Server</div>)
html << %(<div class="card #{srv_cls}"><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>)
if srv_stats.any?
srv_stats_html = srv_stats.map { |c, txt|
%(<span class="stat #{c}">#{CGI.escapeHTML(txt)}</span>)
}.join
html << %(<div class="drive-stats" style="margin-top:0.45rem;padding:0 0.6rem;">#{srv_stats_html}</div>)
end
html << %(</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"))) template = ERB.new(File.read(File.join(ASSETS_DIR, "page.erb")))
out_params = { "type" => "text/html", "charset" => "UTF-8" } out_params = { "type" => "text/html", "charset" => "UTF-8" }
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
module malp_to_malpd 1.0;
require {
type httpd_sys_script_t;
type unconfined_service_t;
class unix_stream_socket connectto;
}
#============= httpd_sys_script_t ==============
allow httpd_sys_script_t unconfined_service_t:unix_stream_socket connectto;
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=MALP monitoring daemon
Requires=malpd.socket
[Service]
Type=simple
ExecStart=/var/www/malp/bin/malpd
[Install]
WantedBy=multi-user.target
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=malpd Unix socket
[Socket]
ListenStream=/run/malpd/malpd.sock
SocketGroup=apache
SocketMode=0660
[Install]
WantedBy=sockets.target