Compare commits
47
Commits
4960132d1d
...
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 | ||
|
|
bf530217f3 | ||
|
|
69c52fdf8c | ||
|
|
d61c137c12 | ||
|
|
8a8d793199 | ||
|
|
011be07e03 | ||
|
|
518ee7c366 | ||
|
|
f31e97b014 | ||
|
|
7f0cf36419 | ||
|
|
d206cad0c5 | ||
|
|
bf4fd38202 |
No files matched your search
@@ -0,0 +1 @@
|
||||
/user.txt
|
||||
@@ -1,7 +1,138 @@
|
||||
# MALP - Monitor Active Linux Pulses
|
||||
# MALP - MALP Assesses a Linux Platform
|
||||
|
||||
A lightweight Ruby CGI status page for home server monitoring.
|
||||
|
||||
## Install
|
||||
|
||||
Copy repository directory to `/var/www/malp`.
|
||||
Copy this repository directory to `/var/www`, so `/var/www/malp/cgi-bin` exists.
|
||||
|
||||
As root:
|
||||
|
||||
```
|
||||
mkdir /var/www/malp/data
|
||||
chown apache:apache /var/www/malp/data
|
||||
```
|
||||
|
||||
### Install ruby
|
||||
|
||||
As root:
|
||||
|
||||
```
|
||||
dnf install ruby
|
||||
```
|
||||
|
||||
### 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_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
|
||||
|
||||
Here is my example setup.
|
||||
My server's name is `anubis`.
|
||||
Replace as desired.
|
||||
|
||||
#### Create self-signed TLS certificate
|
||||
|
||||
As root:
|
||||
|
||||
```
|
||||
mkdir /etc/httpd/tls
|
||||
cd /etc/httpd/tls
|
||||
openssl ecparam -name secp384r1 -genkey -noout -out anubis.key
|
||||
openssl req -new -x509 -key anubis.key -out anubis.crt -days 3650 -sha384
|
||||
```
|
||||
|
||||
#### /etc/httpd/conf.d/anubis.conf
|
||||
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName anubis
|
||||
ServerAlias anubis
|
||||
|
||||
# Permanent redirect to the same URI on HTTPS
|
||||
Redirect permanent / https://anubis/
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName anubis
|
||||
DocumentRoot /var/www/html
|
||||
|
||||
SSLEngine on
|
||||
|
||||
SSLCertificateFile /etc/httpd/tls/anubis.crt
|
||||
SSLCertificateKeyFile /etc/httpd/tls/anubis.key
|
||||
|
||||
# Modern TLS Security (Recommended for ECDSA)
|
||||
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||
SSLCipherSuite ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
||||
SSLHonorCipherOrder off
|
||||
SSLSessionTickets off
|
||||
|
||||
ErrorLog /var/log/httpd/anubis-error.log
|
||||
CustomLog /var/log/httpd/anubis-access.log combined
|
||||
|
||||
<Directory /var/www/html>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ScriptAlias / /var/www/malp/cgi-bin/malp.rb
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### Set user name and password
|
||||
|
||||
As root:
|
||||
|
||||
```
|
||||
/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...)
|
||||
@@ -1,137 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Home Server Status</title>
|
||||
<style>
|
||||
body {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #1e2433;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
header h1 span { color: #e2e8f0; }
|
||||
|
||||
.card {
|
||||
background: #161b27;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border: 1px solid #1e2433;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #475569;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #1e2433;
|
||||
border-radius: 7px;
|
||||
color: #e2e8f0;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
margin-top: 0.5rem;
|
||||
background: #14532d;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1><span>homelab</span> · status</h1>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= hostname %> status - MALP</title>
|
||||
<style>
|
||||
body {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 1rem 2rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-bottom: 0rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #1e2433;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header h1 span { color: #e2e8f0; }
|
||||
|
||||
.card {
|
||||
background: #161b27;
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid #1e2433;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card.auth {
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card.auth .card-title {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#content {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #475569;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #1e2433;
|
||||
border-radius: 7px;
|
||||
color: #e2e8f0;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
margin-top: 0.5rem;
|
||||
background: #14532d;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
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>
|
||||
</head>
|
||||
<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>
|
||||
<h1><span><%= hostname %></span> status - MALP</h1>
|
||||
</header>
|
||||
|
||||
<% 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>
|
||||
<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>
|
||||
</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
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require "digest"
|
||||
require "fileutils"
|
||||
require "io/console"
|
||||
require "securerandom"
|
||||
|
||||
DATA_DIR = File.join(__dir__, "../data")
|
||||
USER_FILE = File.join(DATA_DIR, "user.txt")
|
||||
|
||||
print "User name: "
|
||||
username = $stdin.gets.chomp
|
||||
|
||||
unless username =~ /^[a-zA-Z0-9_]+$/
|
||||
$stderr.puts "Invalid characters in user name"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
print "Password: "
|
||||
password = $stdin.noecho(&:gets).chomp
|
||||
|
||||
salt = SecureRandom.hex(8)
|
||||
input = salt + password
|
||||
hash = Digest::SHA256.hexdigest(input)
|
||||
hashhex = hash.chars.map {|c| sprintf("%02X", c.ord)}.join
|
||||
user_file_contents = "#{username}:#{salt}:#{hashhex}\n"
|
||||
|
||||
File.binwrite(USER_FILE, user_file_contents)
|
||||
puts "User and password set"
|
||||
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)
|
||||
+615
-2
@@ -2,13 +2,626 @@
|
||||
|
||||
require "cgi"
|
||||
require "erb"
|
||||
require "digest"
|
||||
require "json"
|
||||
require "securerandom"
|
||||
require "socket"
|
||||
|
||||
def human_age(seconds)
|
||||
return "unknown" if seconds.nil?
|
||||
return "just now" if seconds < 60
|
||||
return "#{seconds / 60} min ago" if seconds < 3600
|
||||
return "#{seconds / 3600} h ago" if seconds < 86400
|
||||
"#{seconds / 86400} d ago"
|
||||
end
|
||||
|
||||
def human_capacity(bytes)
|
||||
units = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||
i = 0
|
||||
size = bytes.to_f
|
||||
while size >= 1000 && i < units.length - 1
|
||||
size /= 1000
|
||||
i += 1
|
||||
end
|
||||
precision = i <= 3 ? 0 : 1
|
||||
"%g %s" % [size.round(precision), units[i]]
|
||||
end
|
||||
|
||||
ASSETS_DIR = File.join(__dir__, "../assets")
|
||||
DATA_DIR = File.join(__dir__, "../data")
|
||||
SESSIONS_FILE = File.join(DATA_DIR, "sessions.txt")
|
||||
USER_FILE = File.join(DATA_DIR, "user.txt")
|
||||
|
||||
cgi = CGI.new
|
||||
|
||||
template = ERB.new(File.read(File.join(ASSETS_DIR, "login.erb")))
|
||||
hostname = File.read("/etc/hostname").strip rescue "localhost"
|
||||
|
||||
cgi.out("type" => "text/html", "charset" => "UTF-8") do
|
||||
def load_sessions
|
||||
return [] unless File.exist?(SESSIONS_FILE)
|
||||
now = Time.now.to_i
|
||||
max_age = 3 * 7 * 24 * 60 * 60 # 3 weeks
|
||||
sessions = File.readlines(SESSIONS_FILE).filter_map do |line|
|
||||
token, timestamp = line.strip.split(":", 2)
|
||||
next if token.nil? || token.empty?
|
||||
[token, timestamp.to_i]
|
||||
end
|
||||
active, expired = sessions.partition { |_, ts| now - ts < max_age }
|
||||
if expired.any?
|
||||
File.write(SESSIONS_FILE, active.map { |t, ts| "#{t}:#{ts}" }.join("\n") + "\n")
|
||||
end
|
||||
active
|
||||
end
|
||||
|
||||
def valid_session?(token)
|
||||
return false if token.nil? || token.empty?
|
||||
load_sessions.any? { |t, _| t == token }
|
||||
end
|
||||
|
||||
def check_credentials(username, password)
|
||||
return false unless File.exist?(USER_FILE)
|
||||
stored_user, salt, stored_hash_hex2 = File.read(USER_FILE).strip.split(":", 3)
|
||||
return false unless username == stored_user
|
||||
stored_hash = [stored_hash_hex2].pack("H*")
|
||||
computed_hash = Digest::SHA256.hexdigest(salt + password)
|
||||
stored_hash == computed_hash
|
||||
end
|
||||
|
||||
def create_session
|
||||
token = SecureRandom.hex(32)
|
||||
File.open(SESSIONS_FILE, "a") { |f| f.puts("#{token}:#{Time.now.to_i}") }
|
||||
token
|
||||
end
|
||||
|
||||
def remove_session(token)
|
||||
return if token.nil? || token.empty?
|
||||
sessions = load_sessions.reject { |t, _| t == token }
|
||||
File.write(SESSIONS_FILE, sessions.map { |t, ts| "#{t}:#{ts}" }.join("\n") + "\n")
|
||||
end
|
||||
|
||||
session_token = (cgi.cookies["MALP"] || []).first
|
||||
authenticated = valid_session?(session_token)
|
||||
cookie = nil
|
||||
|
||||
if cgi.request_method == "POST" && authenticated && cgi.params["action"]&.first == "logout"
|
||||
remove_session(session_token)
|
||||
cookie = CGI::Cookie.new("name" => "MALP", "value" => "", "path" => "/", "expires" => Time.at(0))
|
||||
print cgi.header("Status" => "303 See Other", "Location" => ENV["REQUEST_URI"], "cookie" => cookie)
|
||||
exit
|
||||
end
|
||||
|
||||
if cgi.request_method == "POST" && !authenticated
|
||||
username = cgi.params["username"]&.first.to_s
|
||||
password = cgi.params["password"]&.first.to_s
|
||||
|
||||
if check_credentials(username, password)
|
||||
token = create_session
|
||||
cookie = CGI::Cookie.new("name" => "MALP", "value" => token, "path" => "/")
|
||||
print cgi.header("Status" => "303 See Other", "Location" => ENV["REQUEST_URI"], "cookie" => cookie)
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
if cgi.params.key?("content")
|
||||
if authenticated
|
||||
html = ""
|
||||
|
||||
begin
|
||||
sock = UNIXSocket.new("/run/malpd/malpd.sock")
|
||||
sock.puts "info"
|
||||
info = JSON.parse(sock.gets)
|
||||
sock.close
|
||||
rescue
|
||||
cgi.out("type" => "text/html", "charset" => "UTF-8") do
|
||||
"Could not connect to malpd socket"
|
||||
end
|
||||
exit
|
||||
end
|
||||
|
||||
drives = info.select { |e| e["type"] == "drive" }
|
||||
filesystems = info.select { |e| e["type"] == "filesystem" }
|
||||
vms = info.select { |e| e["type"] == "vm" }
|
||||
server = info.find { |e| e["type"] == "server" }
|
||||
|
||||
html << %(<div>)
|
||||
|
||||
if server
|
||||
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")))
|
||||
|
||||
out_params = { "type" => "text/html", "charset" => "UTF-8" }
|
||||
out_params["cookie"] = cookie if cookie
|
||||
|
||||
cgi.out(out_params) do
|
||||
template.result(binding)
|
||||
end
|
||||
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