Compare commits
37
Commits
bf530217f3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0da87fac | ||
|
|
a21bf2c215 | ||
|
|
16f88d4401 | ||
|
|
4b74bc01a7 | ||
|
|
ca45a90930 | ||
|
|
b94343722f | ||
|
|
c14f08d4b1 | ||
|
|
93c9bf27fa | ||
|
|
49a20a946f | ||
|
|
5687d8a4e7 | ||
|
|
f037a97792 | ||
|
|
b78fb9469e | ||
|
|
e6d2945b7b | ||
|
|
69a5baeb8d | ||
|
|
ad23419b99 | ||
|
|
ea932dd641 | ||
|
|
34e984d563 | ||
|
|
5773347bda | ||
|
|
23008b4997 | ||
|
|
1e87b08b54 | ||
|
|
a282debfbd | ||
|
|
23d7e79605 | ||
|
|
40109b5c62 | ||
|
|
08a2a6df4b | ||
|
|
80cae35688 | ||
|
|
5e60752594 | ||
|
|
1180849b44 | ||
|
|
c717b5d589 | ||
|
|
7c907fe0ce | ||
|
|
51aa0c805e | ||
|
|
46a1887982 | ||
|
|
ad9628e45c | ||
|
|
1eb58f87d2 | ||
|
|
244edf90ac | ||
|
|
ed4e749901 | ||
|
|
39f7dd6285 | ||
|
|
327c4b755f |
No files matched your search
@@ -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...)
|
||||||
+312
-6
@@ -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,15 +131,299 @@
|
|||||||
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 id="content" class="grid"><div class="spinner"></div></div>
|
||||||
|
<script>
|
||||||
|
fetch("?content", { credentials: "same-origin" })
|
||||||
|
.then(function(response) {
|
||||||
|
if (response.ok) return response.text();
|
||||||
|
throw new Error();
|
||||||
|
})
|
||||||
|
.then(function(html) {
|
||||||
|
document.getElementById("content").innerHTML = html;
|
||||||
|
var now = new Date();
|
||||||
|
var pad = function(n) { return String(n).padStart(2, "0"); };
|
||||||
|
var stamp = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
|
||||||
|
" " + 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>
|
<div class="card-title">Sign In</div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -132,6 +437,7 @@
|
|||||||
<button type="submit">Log In</button>
|
<button type="submit">Log In</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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
@@ -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
@@ -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.
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=malpd Unix socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/malpd/malpd.sock
|
||||||
|
SocketGroup=apache
|
||||||
|
SocketMode=0660
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
Reference in new issue
Block a user