From f50008c41d836ed0d5d84521866f7d9e9a47aa6a Mon Sep 17 00:00:00 2001 From: Rufus King Date: Sun, 26 Jul 2026 21:34:22 -0400 Subject: [PATCH] Initial import --- Backup Scripts/NAS08-Backup-Summary.html | 923 ++++++++++++++++++ Backup Scripts/nas08-backup.sh | 595 +++++++++++ NAS08-Kernel-Recovery-Summary.html | 682 +++++++++++++ Stocks/StockFunctions.bas | 235 +++++ Stocks/onlyoffice_macro_autostart_recalc.js | 19 + Stocks/onlyoffice_macro_fundprice_proxy.js | 61 ++ ...onlyoffice_macro_stockprice_finnhub (1).js | 55 ++ Stocks/onlyoffice_macro_stockprice_finnhub.js | 55 ++ Stocks/stockproxy_Dockerfile | 14 + Stocks/stockproxy_app.py | 161 +++ Stocks/stockproxy_docker-compose.yml | 12 + Stocks/stockproxy_requirements.txt | 3 + nextcloud/nextcloud_update_check (1).sh | 371 +++++++ nextcloud/nextcloud_update_check.sh | 352 +++++++ omv/SSD-fstrim-Summary.html | 521 ++++++++++ omv/fstrim-override.conf | 4 + omv/fstrim-report.sh | 471 +++++++++ scripts/nextcloud_update_check.sh | 288 ++++++ zfs/NAS08-zfs_pools.html | 506 ++++++++++ zfs/NAS08-zfs_pools.html~ | 506 ++++++++++ zfs/nas08_zfs_report.sh | 351 +++++++ zfs/nas08_zfs_scrub-1.sh | 322 ++++++ zfs/nas08_zfs_scrub.sh | 295 ++++++ zfs_report_nas08_howto.md | 11 + 24 files changed, 6813 insertions(+) create mode 100644 Backup Scripts/NAS08-Backup-Summary.html create mode 100644 Backup Scripts/nas08-backup.sh create mode 100644 NAS08-Kernel-Recovery-Summary.html create mode 100644 Stocks/StockFunctions.bas create mode 100644 Stocks/onlyoffice_macro_autostart_recalc.js create mode 100644 Stocks/onlyoffice_macro_fundprice_proxy.js create mode 100644 Stocks/onlyoffice_macro_stockprice_finnhub (1).js create mode 100644 Stocks/onlyoffice_macro_stockprice_finnhub.js create mode 100644 Stocks/stockproxy_Dockerfile create mode 100644 Stocks/stockproxy_app.py create mode 100644 Stocks/stockproxy_docker-compose.yml create mode 100644 Stocks/stockproxy_requirements.txt create mode 100644 nextcloud/nextcloud_update_check (1).sh create mode 100644 nextcloud/nextcloud_update_check.sh create mode 100644 omv/SSD-fstrim-Summary.html create mode 100644 omv/fstrim-override.conf create mode 100644 omv/fstrim-report.sh create mode 100644 scripts/nextcloud_update_check.sh create mode 100644 zfs/NAS08-zfs_pools.html create mode 100644 zfs/NAS08-zfs_pools.html~ create mode 100644 zfs/nas08_zfs_report.sh create mode 100644 zfs/nas08_zfs_scrub-1.sh create mode 100644 zfs/nas08_zfs_scrub.sh create mode 100644 zfs_report_nas08_howto.md diff --git a/Backup Scripts/NAS08-Backup-Summary.html b/Backup Scripts/NAS08-Backup-Summary.html new file mode 100644 index 0000000..8e23803 --- /dev/null +++ b/Backup Scripts/NAS08-Backup-Summary.html @@ -0,0 +1,923 @@ + + + + + +KingDezigns — NAS08 Automated Backup Setup Summary + + + + + +
+ +
+

KingDezigns — NAS08 Automated Backup Setup summary

+
+ 📅 2026-05-11 + 🖥 NAS08 / Raspberry Pi 5 + ⚙️ OpenMediaVault (OMV) + 🐳 Docker Compose + 💾 ZFS RAIDZ2 — Penta SATA HAT +
+
+ + + +
+
The goal
+ +

NAS08 had no automated recovery path. A software or hardware failure — corrupt SD card, bad OMV update, runaway script — would have required a full manual rebuild of OMV, all Docker containers, Pi-hole, Plex, Vaultwarden, Nextcloud, and nginx-proxy-manager with no documented procedure and no saved state.

+ +
+ No OS backup + No automated recovery plan + No ZFS restore procedure + Plex music library previously lost to a bad script +
+ +

The goal was to create a fully automated, scheduled backup of the OS and configuration layer — not the NAS data drives — that could restore NAS08 to full operation within approximately 90 minutes after a failure, with no manual intervention during normal operation.

+ +
+
⚠ Critical — store this document off NAS08
+

Keep a copy of this document on your workstation, a USB drive, or in print. If NAS08 is down you cannot read files stored on it.

+
+
+ + + +
+
Discovery commands run & why
+ +
+
1 Locate Docker app data directories
+
ls /export/kingdezigns-all/Docker
+ls /export/kingdezigns-all/Docker/Compose
+

Needed to confirm where application configs and compose files lived before writing backup paths into the script.

+

Docker root confirmed at /export/kingdezigns-all/Docker/ with Compose subfolder containing all stacks: nextcloud, nginx-proxy-manager, pihole, plex, vaultwarden.

+
+ +
+
2 Check first archive size and contents
+
ls -lh /export/kingdezigns-all/Backups/NAS08/nas08-backup-2026-05-11.tar.gz
+tar -tzf /export/kingdezigns-all/Backups/NAS08/nas08-backup-2026-05-11.tar.gz | head -40
+

Verified the archive was created and inspected its contents to catch bloat or missing sections.

+

Archive was 1.4GB — Vaultwarden icon_cache and full Plex config were included without exclusions, inflating the size unnecessarily.

+
+ +
+
3 Measure sizes across all Docker folders
+
sudo du -sh /export/kingdezigns-all/Docker/Compose/*/
+sudo du -sh /export/kingdezigns-all/Docker/pihole/
+sudo du -sh /export/kingdezigns-all/Docker/plex/
+

Identify where archive bulk was coming from before adding exclusions.

+

Plex at 1.8GB was the dominant size. Pi-hole at 27MB acceptable as-is. Compose folders were tiny (3.5K–7.4M each).

+
+ +
+
4 Drill into Plex config subfolder breakdown
+
sudo du -sh /export/kingdezigns-all/Docker/plex/config/Library/Application\ Support/Plex\ Media\ Server/*/
+

Identify which Plex subfolders were safe to exclude vs which contained manually curated data worth preserving.

+

Cache (24M), Logs (2M), Crash Reports (23K) identified as safe exclusions. Media (655M) and Metadata (956M) confirmed as music organization and library data — kept.

+
+ +
+
5 Inspect ZFS pool configuration
+
sudo zpool list
+sudo zpool status
+sudo zfs list
+

Document exact pool name, drive identifiers, RAID type, and mount point needed for recovery plan ZFS steps.

+

Pool: kingdezignsnas — RAIDZ2 across 4 × 1TB SSDs — ONLINE — mounted at /kingdezignsnas — last scrub clean, no errors.

+
+ +
+ + + +
+
Issues encountered during setup
+ +
+

On first run the backup destination directory did not exist, causing the logging system to fail immediately before any backup work could begin.

+
+ +
+
⚠ Error — first run
+

Script failed with a tee write error before reaching any backup steps.

+
tee: /export/kingdezigns-all/Backups/NAS08/backup.log: No such file or directory
+

Backups/NAS08/ had not been created yet. Fixed by creating it manually. The script now handles this automatically via mkdir -p on every run.

+
+ +
+

SCP to /usr/local/bin/ failed with permission denied — that path requires root SCP access which rufusking does not have.

+
+ +
+

Resolution: Created /usr/scripts/omv/ owned by rufusking. SCP now works without root. Script still runs with sudo for root-level operations at runtime.

+
+
+ + + +
+
Scripts / files created
+ +
+
+
Backup Script Location
+
/usr/scripts/omv/nas08-backup.sh
+
+
+
What It Backs Up
+
OMV config        — omv-confbak export + raw /etc/openmediavault/config.xml
+/etc              — full system config (network, fstab, nginx, cron, etc.)
+Docker Compose    — all stacks + global.env  (vaultwarden icon_cache excluded)
+Pi-hole data      — /export/kingdezigns-all/Docker/pihole/
+Plex data         — full config minus Cache/, Logs/, Crash Reports/
+                    Media/ and Metadata/ KEPT (music org + library data)
+Shared folder     — /export/kingdezigns-all/Docker/Shared/
+Crontab           — root crontab + /etc/cron.d entries
+MANIFEST.txt      — baked-in restore instructions and OMV version info
+
+
+
Output Paths
+
/export/kingdezigns-all/Backups/NAS08/nas08-backup-YYYY-MM-DD.tar.gz
+/export/kingdezigns-all/Backups/NAS08/backup.log
+
+
+
Retention
+
10 archives retained (~30 days coverage) — older archives auto-deleted on each run
+
+
+
Expected Size
+
~1.4GB per archive — ~14GB total at max retention
+450GB free on data drive — well within capacity
+
+
+
Schedule via OMV UI
+
System → Scheduled Jobs → Add
+  Name:          NAS08 Backup
+  Command:       /usr/scripts/omv/nas08-backup.sh
+  Minute:        0
+  Hour:          2
+  Day of month:  */3
+  Month:         *
+  Day of week:   *
+Runs automatically at 2:00 AM every 3 days
+
+
+
Manual Run
+
sudo /usr/scripts/omv/nas08-backup.sh
+# or: OMV UI → System → Scheduled Jobs → Run
+
+
+ +
+ + + +
+
Full recovery procedure
+ +
+
⚠ Before You Start
+

You need: a workstation with internet, a replacement SD card (16GB+), Raspberry Pi Imager, SSH access, and your rufusking password. Physically connect all 4 ZFS drives to the Penta SATA HAT before powering on.

+
+ + +
Phase 1 — Flash the OS
+ +
+
1 Download Raspberry Pi Imager — workstation
+
https://www.raspberrypi.com/software/
+

Download and install for your OS (Windows / Mac / Linux).

+
+ +
+
2 Flash Raspberry Pi OS Lite (64-bit) — workstation
+
Open Raspberry Pi Imager
+→ Choose Device    : Raspberry Pi 5
+→ Choose OS        : Raspberry Pi OS Lite (64-bit)   ← NOT the Desktop version
+→ Choose Storage   : your SD card
+→ Next → Edit Settings:
+    Hostname  : NAS08
+    Username  : rufusking
+    Password  : your usual password
+    Services  : Enable SSH → Use password authentication
+→ Save → Yes → Yes (confirm flash and verify)
+

Wait for the flash and verify to complete fully before removing the SD card.

+
+ +
+
3 Boot the Raspberry Pi
+
1. Insert SD card into Raspberry Pi 5
+2. Connect Penta SATA HAT with all 4 ZFS drives plugged in
+3. Power on
+4. Wait 2–3 minutes for first boot to complete
+
+ + +
Phase 2 — Install OpenMediaVault
+ +
+
⚠ Note
+

The OMV install method may change in the future. Verify the current script at https://github.com/OpenMediaVault-Plugin-Developers/installScript before running. Steps below reflect the method as of 2026-05-11.

+
+ +
+
4 SSH into NAS08 — workstation
+
ssh rufusking@192.168.150.35
+

If the static IP is not yet assigned, log into UniFi at http://192.168.100.1, find NAS08 in Clients, and SSH to its temporary IP instead.

+
+ +
+
5 Update the OS — NAS08
+
sudo apt-get update && sudo apt-get upgrade -y
+

Wait for the prompt to return before continuing. This may take a few minutes.

+
+ +
+
6 Install OpenMediaVault — NAS08
+
sudo wget -O - https://github.com/OpenMediaVault-Plugin-Developers/installScript/raw/master/install | sudo bash
+

Takes 10–20 minutes. The Pi may reboot automatically. If it does, SSH back in and wait for the script to finish. Do not interrupt it.

+
+ +
+
7 Verify OMV is running — browser on workstation
+
http://192.168.150.35
+
+Default login:
+  Username: admin
+  Password: openmediavault
+

Confirm the OMV login page loads. Do not configure anything yet — the restore handles all configuration.

+
+ + +
Phase 3 — Enable PCIe for Penta SATA HAT
+ +
+

The Raspberry Pi 5 does not expose PCIe by default. Without enabling it, the Penta SATA HAT and all 4 ZFS drives are completely invisible to the OS. This must be done before any ZFS commands will work.

+
+ +
+
8 Edit boot config to enable PCIe — NAS08
+
sudo nano /boot/firmware/config.txt
+

Scroll to find the line arm_boost=1 and add these lines immediately after it:

+
# Enable PCIe
+dtparam=pciex1
+dtparam=pciex1_gen=3
+

Save and exit:

+
Ctrl+X  →  Y  →  Enter
+
+ +
+
9 Reboot to apply PCIe config — NAS08
+
sudo reboot
+

Wait 2–3 minutes, then SSH back in:

+
ssh rufusking@192.168.150.35
+
+ + +
Phase 4 — Import ZFS Pool
+ +
+
10 Install ZFS tools — NAS08
+
sudo apt-get install -y zfsutils-linux
+

Installs the ZFS kernel modules and CLI tools. Required on every fresh OS install.

+
+ +
+
11 Verify all 4 drives are visible — NAS08
+
lsblk
+

You should see 4 drives listed (typically sda through sdd). If any are missing, check physical connections and reboot:

+
sudo reboot
+# SSH back in, then run lsblk again
+
+ +
+
12 Import the ZFS pool — NAS08
+
sudo zpool import kingdezignsnas
+

The pool name kingdezignsnas is stored on the drives themselves — it will be recognized on a fresh OS automatically. If the previous OS failed uncleanly, force the import:

+
# Only use -f if the standard import fails:
+sudo zpool import -f kingdezignsnas
+
+ +
+
13 Verify pool imported correctly — NAS08
+
sudo zpool status
+

All 4 drives must show ONLINE. Expected output:

+
  pool: kingdezignsnas
+ state: ONLINE
+config:
+    kingdezignsnas                        ONLINE
+      raidz2-0                            ONLINE
+        ata-CT1000BX500SSD1_2539E9CB8529  ONLINE
+        ata-CT1000BX500SSD1_2534E9C97DAD  ONLINE
+        ata-CT1000BX500SSD1_2530E9C74CD1  ONLINE
+        ata-CT1000BX500SSD1_2539E9CB84F8  ONLINE
+errors: No known data errors
+

If any drive shows FAULTED or DEGRADED, do not proceed. See ZFS Troubleshooting at the bottom of this document.

+
+ +
+
14 Confirm backup files are accessible — NAS08
+
sudo zfs list
+ls /kingdezignsnas/
+ls /kingdezignsnas/Backups/NAS08/
+
+# List archives sorted newest first:
+ls -lht /kingdezignsnas/Backups/NAS08/nas08-backup-*.tar.gz
+

Use /kingdezignsnas/ directly at this stage. The /export/kingdezigns-all/ path is recreated by OMV after the config restore in Phase 6.

+
+ + +
Phase 5 — Extract the Backup Archive
+ +
+
15 Create restore working directory — NAS08
+
sudo mkdir -p /tmp/nas08-restore
+
+ +
+
16 Extract the most recent backup — NAS08
+
# Replace YYYY-MM-DD with the actual archive date from step 14
+sudo tar -xzf /kingdezignsnas/Backups/NAS08/nas08-backup-YYYY-MM-DD.tar.gz \
+    -C /tmp/nas08-restore
+

This takes a few minutes — the archive is ~1.4GB. Wait for the prompt to return.

+
+ +
+
17 Confirm extraction and read the manifest — NAS08
+
# Confirm all folders are present:
+ls /tmp/nas08-restore/
+ls /tmp/nas08-restore/nas08-backup-*/
+
+# Read the manifest:
+cat /tmp/nas08-restore/nas08-backup-*/MANIFEST.txt
+

You should see: omv-config/  etc/  docker-compose/  app-data/  cron/  MANIFEST.txt

+
+ + +
Phase 6 — Restore System Configuration
+ +
+
18 Restore /etc — NAS08
+
sudo rsync -a /tmp/nas08-restore/nas08-backup-*/etc/ /etc/
+

Restores all system configuration: network settings, nginx, fstab, cron, and everything else under /etc.

+
+ +
+
19 Restore OMV configuration — NAS08
+
# Copy saved OMV config back into place:
+sudo cp /tmp/nas08-restore/nas08-backup-*/omv-config/config.xml.raw \
+    /etc/openmediavault/config.xml
+
+# Load it into the OMV database:
+sudo omv-confdbadm populate
+
+# Re-apply all settings to the system (shares, users, plugins, schedules):
+sudo omv-salt deploy run all
+

The last command takes several minutes. Do not interrupt it — let it complete fully.

+
+ +
+
20 Reboot and verify OMV — NAS08 then browser
+
sudo reboot
+

After 2–3 minutes, SSH back in and check:

+
ssh rufusking@192.168.150.35
+

Then open a browser on your workstation:

+
http://192.168.150.35
+

Log in and verify: shares, users, and settings are present. Go to Storage → File Systems and confirm the ZFS pool is mounted and /export/kingdezigns-all is available.

+
+ + +
Phase 7 — Restore Docker and Application Data
+ +
+
21 Verify Docker is installed — NAS08
+
sudo docker ps
+

If Docker is not found, install it:

+
sudo apt-get install -y docker-compose-plugin
+

If the OMV Compose plugin is missing, reinstall via OMV UI:

+
OMV UI → System → Plugins → search "compose" → Install
+
+ +
+
22 Restore Docker Compose files — NAS08
+
sudo rsync -a /tmp/nas08-restore/nas08-backup-*/docker-compose/ \
+    /export/kingdezigns-all/Docker/Compose/
+
+# Verify files landed correctly:
+ls /export/kingdezigns-all/Docker/Compose/
+

Expected: nextcloud/  nginx-proxy-manager/  pihole/  plex/  vaultwarden/  global.env

+
+ +
+
23 Restore Pi-hole data — NAS08
+
sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/pihole/ \
+    /export/kingdezigns-all/Docker/pihole/
+
+ +
+
24 Restore Plex data — NAS08
+
sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/plex/ \
+    /export/kingdezigns-all/Docker/plex/
+

This restores your music organization (Media/), library metadata (Metadata/), and watch history (Plug-in Support/). These are the folders that represent weeks of manual work and are the primary reason this backup exists.

+
+ +
+
25 Restore Shared folder — NAS08
+
sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/shared/ \
+    /export/kingdezigns-all/Docker/Shared/
+
+ +
+
26 Fix permissions on all Docker folders — NAS08
+
sudo chown -R root:users /export/kingdezigns-all/Docker/
+sudo chmod -R 775 /export/kingdezigns-all/Docker/
+
+ + +
Phase 8 — Bring Containers Back Up
+ +
+
27 Start each container one at a time — NAS08
+
cd /export/kingdezigns-all/Docker/Compose/pihole && sudo docker compose up -d
+cd /export/kingdezigns-all/Docker/Compose/nginx-proxy-manager && sudo docker compose up -d
+cd /export/kingdezigns-all/Docker/Compose/vaultwarden && sudo docker compose up -d
+cd /export/kingdezigns-all/Docker/Compose/nextcloud && sudo docker compose up -d
+cd /export/kingdezigns-all/Docker/Compose/plex && sudo docker compose up -d
+

Start one at a time — not all at once — so errors from individual containers are not lost in combined output.

+
+ +
+
28 Verify all containers are running — NAS08
+
sudo docker ps
+

All containers should show status Up. If any show Exited, check that container's logs:

+
sudo docker logs pihole
+sudo docker logs nginx-proxy-manager
+sudo docker logs vaultwarden
+sudo docker logs nextcloud
+sudo docker logs plex
+
+ + +
Phase 9 — Final Steps & Verification
+ +
+
29 Verify the backup script is in place — NAS08
+
ls -lh /usr/scripts/omv/nas08-backup.sh
+

If missing (it lives outside /etc so may not have restored automatically), copy it from your workstation:

+
# On your workstation:
+scp nas08-backup.sh rufusking@192.168.150.35:/usr/scripts/omv/nas08-backup.sh
+
+# On NAS08:
+chmod +x /usr/scripts/omv/nas08-backup.sh
+
+ +
+
30 Restore the crontab — NAS08
+
sudo crontab /tmp/nas08-restore/nas08-backup-*/cron/root-crontab.txt
+
+# Verify it was applied:
+sudo crontab -l
+
+ +
+
31 Reschedule the backup job in OMV UI
+
OMV UI → System → Scheduled Jobs → Add
+  Enabled:       Yes
+  Name:          NAS08 Backup
+  Command:       /usr/scripts/omv/nas08-backup.sh
+  Minute:        0
+  Hour:          2
+  Day of month:  */3
+  Month:         *
+  Day of week:   *
+→ Save
+
+ +
+
32 Clean up the restore working directory — NAS08
+
sudo rm -rf /tmp/nas08-restore
+
+ +
+
33 Run a fresh backup immediately to create a new baseline
+
# Option A — OMV UI:
+System → Scheduled Jobs → NAS08 Backup → Run
+
+# Option B — SSH on NAS08:
+sudo /usr/scripts/omv/nas08-backup.sh
+
+# Verify it completed successfully:
+ls -lh /export/kingdezigns-all/Backups/NAS08/
+cat /export/kingdezigns-all/Backups/NAS08/backup.log
+
+ +
+
34 Run a ZFS scrub to verify data integrity — NAS08
+
sudo zpool scrub kingdezignsnas
+
+# Check results a few minutes later:
+sudo zpool status
+

A healthy result:

+
scan: scrub repaired 0B in 00:xx:xx with 0 errors
+
+ + +
ZFS Troubleshooting
+ +
+
— Pool shows DEGRADED — 1 drive failed
+
sudo zpool status   ← failed drive shows FAULTED or UNAVAIL
+

RAIDZ2 tolerates up to 2 simultaneous drive failures. The pool is still fully readable and writable. Complete the full restore procedure first, then replace the failed drive. Do not skip recovery steps.

+
+ +
+
— Pool shows DEGRADED — 2 drives failed
+
sudo zpool status   ← two drives show FAULTED or UNAVAIL
+

Data is at risk but still accessible. Complete the restore immediately and replace both drives as fast as possible. Seek assistance before doing anything else if unsure.

+
+ +
+
— Pool cannot be imported — not found
+
# Scan all connected devices for importable pools:
+sudo zpool import
+
+# If kingdezignsnas appears in the list, force import:
+sudo zpool import -f kingdezignsnas
+

If no pools are found at all, PCIe is likely not enabled. Verify Steps 8–9 (boot config) were applied correctly and reboot again before retrying.

+
+ +
+
— Pool imported but /export/kingdezigns-all is missing
+
# Access backup files directly via the ZFS mount point:
+ls /kingdezignsnas/Backups/NAS08/
+

/export/kingdezigns-all/ is created by OMV when it mounts the filesystem. Use /kingdezignsnas/ as the base path for all restore commands until OMV is fully restored.

+
+ +
+ + + +
+
Lessons learned
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LessonDetail
PCIe must be enabled before ZFS can see the drivesThe Raspberry Pi 5 Penta SATA HAT requires PCIe to be explicitly enabled in /boot/firmware/config.txt. On a fresh OS this is never set. Without it all 4 ZFS drives are invisible and the pool cannot be imported.
ZFS pool import must happen before any restore stepsWithout importing the pool, the backup files are completely inaccessible. It is now Phase 4, immediately after enabling PCIe, before any restore work begins.
Plex Media/ and Metadata/ contain irreplaceable manually curated dataThese folders hold music organization, artwork, and library structure that took weeks of manual work. They must always be included in backups even though they are large (~1.6GB combined).
Never run scripts that touch OMV without a tested recovery plan firstA prior script run by another AI tool broke OMV and wiped Plex music organization. This session was motivated by that incident. Backup first, change second.
Custom scripts belong in /usr/scripts/ not /usr/local/bin/Writing to /usr/local/bin/ requires root SCP access. A dedicated /usr/scripts/ directory owned by rufusking avoids this and keeps custom scripts separated from system binaries.
Always create output directories before the first script runThe script failed on first run because /Backups/NAS08/ did not exist. The script now handles this automatically via mkdir -p, but always verify the path exists before the first run on a new system.
Vaultwarden icon_cache is large and completely disposableThe icon cache for website favicons was inflating the archive. It regenerates automatically and must always be excluded.
Recovery documentation must be stored off the device being recoveredA recovery plan stored only on NAS08 is useless when NAS08 is down. Keep copies on a workstation, USB drive, or in print.
+
+ + + +
+
Network map — items to update
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemOld valueNew value
NAS08 — Hardware detailRaspberry PiRaspberry Pi 5 with Penta SATA HAT
NAS08 — Backup statusNoneAutomated — every 3 days — 30-day retention
NAS08 — Backup destinationNone/export/kingdezigns-all/Backups/NAS08/
NAS08 — Backup scriptNone/usr/scripts/omv/nas08-backup.sh
NAS08 — ZFS pool namenot documentedkingdezignsnas (RAIDZ2, 4 × 1TB SSD)
NAS08 — ZFS mount pointnot documented/kingdezignsnas
NAS08 — PCIe requirementnot documenteddtparam=pciex1 + dtparam=pciex1_gen=3 required in /boot/firmware/config.txt on fresh OS
NAS08 — Recovery planNoneNAS08-Backup-Summary.html — 34 steps across 9 phases + ZFS troubleshooting
+
+ + + + +
+ + diff --git a/Backup Scripts/nas08-backup.sh b/Backup Scripts/nas08-backup.sh new file mode 100644 index 0000000..b783332 --- /dev/null +++ b/Backup Scripts/nas08-backup.sh @@ -0,0 +1,595 @@ +#!/bin/bash +# ============================================================================= +# NAS08 System Backup Script +# ============================================================================= +# Backs up OMV config, Docker Compose files, app data, and system /etc +# Writes a single dated .tar.gz archive to the NAS data drive +# Retains the last 10 backups (~30 days), auto-removes older ones +# Sends an HTML email report on completion (via OMV Postfix / Gmail relay). +# +# Schedule via OMV Scheduled Jobs (cron): +# 0 2 */3 * * (2:00 AM every 3 days) +# +# Backup destination: /export/kingdezigns-all/Backups/NAS08/ +# Script location: /usr/scripts/omv/nas08-backup.sh +# ============================================================================= + +set -uo pipefail + +# ----------------------------------------------------------------------------- +# CONFIGURATION +# ----------------------------------------------------------------------------- +HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS08") +REPORT_TO="rufus.king@kingdezigns.com" +REPORT_FROM="backup-monitor@nas08.local" # Display only — Postfix uses your Gmail relay + +BACKUP_ROOT="/export/kingdezigns-all/Backups/NAS08" +TMP_BASE="/export/kingdezigns-all/Backups/.tmp" +DOCKER_DIR="/export/kingdezigns-all/Docker" +DATE=$(date +%Y-%m-%d) +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") +ARCHIVE_NAME="nas08-backup-${DATE}.tar.gz" +TMP_DIR="${TMP_BASE}/nas08-backup-${TIMESTAMP}" +RETAIN_COUNT=10 +LOG_FILE="${BACKUP_ROOT}/backup.log" + +# ----------------------------------------------------------------------------- +# STATUS TRACKING +# OVERALL_STATUS: SUCCESS | WARNING | FAILED +# ----------------------------------------------------------------------------- +OVERALL_STATUS="SUCCESS" +SECTION_ROWS_HTML="" +WARNING_COUNT=0 +ERROR_COUNT=0 + +# Append a result row to the section table +# Usage: add_section_row "Section name" "STATUS" "Detail message" +# STATUS: OK | WARNING | ERROR | SKIPPED +add_section_row() { + local section="$1" status="$2" detail="$3" + + case "$status" in + OK) + BADGE="✓ OK" + ;; + WARNING) + BADGE="⚠ WARNING" + WARNING_COUNT=$((WARNING_COUNT + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + ;; + ERROR) + BADGE="✗ ERROR" + ERROR_COUNT=$((ERROR_COUNT + 1)) + OVERALL_STATUS="FAILED" + ;; + SKIPPED) + BADGE="— SKIPPED" + ;; + esac + + SECTION_ROWS_HTML+=" + + $section + $BADGE + $detail + " +} + +# ----------------------------------------------------------------------------- +# LOGGING +# ----------------------------------------------------------------------------- +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +log_section() { + echo "" | tee -a "$LOG_FILE" + echo "------------------------------------------------------------" | tee -a "$LOG_FILE" + log "$1" + echo "------------------------------------------------------------" | tee -a "$LOG_FILE" +} + +# ----------------------------------------------------------------------------- +# PRE-FLIGHT CHECKS +# ----------------------------------------------------------------------------- +log_section "NAS08 Backup Starting" + +if [ ! -d "/export/kingdezigns-all" ]; then + log "ERROR: /export/kingdezigns-all is not mounted or accessible. Aborting." + add_section_row "Pre-flight check" "ERROR" "/export/kingdezigns-all not mounted or accessible" + OVERALL_STATUS="FAILED" + exit 1 +fi + +mkdir -p "$BACKUP_ROOT" +mkdir -p "$TMP_DIR" +log "Temporary working directory: $TMP_DIR" + +# ----------------------------------------------------------------------------- +# SECTION 1: OMV CONFIGURATION +# ----------------------------------------------------------------------------- +log_section "Backing up OMV configuration" + +OMV_DIR="${TMP_DIR}/omv-config" +mkdir -p "$OMV_DIR" +OMV_STATUS="OK" +OMV_DETAIL="" + +# omv-confbak is optional — raw config.xml is the primary backup and fully sufficient. +if command -v omv-confbak &>/dev/null; then + if omv-confbak "${OMV_DIR}/omv-config.xml" 2>/dev/null; then + log "omv-confbak completed successfully" + OMV_DETAIL="omv-confbak exported + raw config.xml copied" + else + log "WARNING: omv-confbak failed or returned non-zero — raw config.xml still captured" + OMV_DETAIL="omv-confbak failed (non-fatal) + raw config.xml copied" + fi +else + log "omv-confbak not present — using raw config.xml (this is normal)" +fi + +if [ -f /etc/openmediavault/config.xml ]; then + cp /etc/openmediavault/config.xml "${OMV_DIR}/config.xml.raw" + log "Raw OMV config.xml copied" + [[ -z "$OMV_DETAIL" ]] && OMV_DETAIL="raw config.xml copied" +else + log "WARNING: /etc/openmediavault/config.xml not found — OMV config not backed up" + OMV_STATUS="WARNING" + OMV_DETAIL="config.xml not found at /etc/openmediavault/config.xml" +fi + +add_section_row "OMV Configuration" "$OMV_STATUS" "$OMV_DETAIL" + +# ----------------------------------------------------------------------------- +# SECTION 2: SYSTEM /etc +# ----------------------------------------------------------------------------- +log_section "Backing up /etc (system configuration)" + +ETC_DIR="${TMP_DIR}/etc" +mkdir -p "$ETC_DIR" + +if rsync -a --quiet \ + --exclude='/etc/mtab' \ + --exclude='/etc/fstab.d' \ + --exclude='/etc/blkid.tab' \ + /etc/ "${ETC_DIR}/" 2>/dev/null; then + log "/etc backed up successfully" + add_section_row "System /etc" "OK" "rsync completed successfully" +else + log "WARNING: /etc rsync encountered errors" + add_section_row "System /etc" "WARNING" "rsync completed with errors" +fi + +# ----------------------------------------------------------------------------- +# SECTION 3: DOCKER COMPOSE FILES & GLOBAL ENV +# ----------------------------------------------------------------------------- +log_section "Backing up Docker Compose files" + +COMPOSE_DIR="${TMP_DIR}/docker-compose" +mkdir -p "$COMPOSE_DIR" + +if [ -d "${DOCKER_DIR}/Compose" ]; then + if rsync -a --quiet \ + --exclude='vaultwarden/icon_cache/' \ + "${DOCKER_DIR}/Compose/" "${COMPOSE_DIR}/" 2>/dev/null; then + log "Docker Compose directory backed up (Vaultwarden icon_cache excluded)" + # Count compose files for the detail line + COMPOSE_COUNT=$(find "${COMPOSE_DIR}" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null | wc -l) + add_section_row "Docker Compose Files" "OK" "${COMPOSE_COUNT} compose file(s) · vaultwarden/icon_cache excluded" + else + log "WARNING: Docker Compose rsync encountered errors" + add_section_row "Docker Compose Files" "WARNING" "rsync completed with errors" + fi +else + log "WARNING: ${DOCKER_DIR}/Compose not found — skipping" + add_section_row "Docker Compose Files" "WARNING" "${DOCKER_DIR}/Compose not found" +fi + +# ----------------------------------------------------------------------------- +# SECTION 4: APP DATA (Pi-hole, Plex, Shared) +# ----------------------------------------------------------------------------- +log_section "Backing up application data" + +APPDATA_DIR="${TMP_DIR}/app-data" +mkdir -p "$APPDATA_DIR" +APPDATA_WARN=0 +APPDATA_DETAIL="" + +# Pi-hole +if [ -d "${DOCKER_DIR}/pihole" ]; then + if rsync -a --quiet "${DOCKER_DIR}/pihole/" "${APPDATA_DIR}/pihole/" 2>/dev/null; then + log "Pi-hole data backed up" + APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ✓" + else + log "WARNING: Pi-hole rsync encountered errors" + APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ⚠" + APPDATA_WARN=$((APPDATA_WARN + 1)) + fi +else + log "WARNING: ${DOCKER_DIR}/pihole not found — skipping" + APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole —" +fi + +# Plex (Cache, Logs, Crash Reports excluded) +if [ -d "${DOCKER_DIR}/plex" ]; then + if rsync -a --quiet \ + --exclude='Plex Media Server/Cache/' \ + --exclude='Plex Media Server/Logs/' \ + --exclude='Plex Media Server/Crash Reports/' \ + "${DOCKER_DIR}/plex/" "${APPDATA_DIR}/plex/" 2>/dev/null; then + log "Plex data backed up (Cache, Logs, Crash Reports excluded)" + APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ✓" + else + log "WARNING: Plex rsync encountered errors" + APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ⚠" + APPDATA_WARN=$((APPDATA_WARN + 1)) + fi +else + log "WARNING: ${DOCKER_DIR}/plex not found — skipping" + APPDATA_DETAIL="${APPDATA_DETAIL} · Plex —" +fi + +# Shared folder +if [ -d "${DOCKER_DIR}/Shared" ]; then + if rsync -a --quiet "${DOCKER_DIR}/Shared/" "${APPDATA_DIR}/shared/" 2>/dev/null; then + log "Shared folder backed up" + APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ✓" + else + log "WARNING: Shared rsync encountered errors" + APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ⚠" + APPDATA_WARN=$((APPDATA_WARN + 1)) + fi +else + log "WARNING: ${DOCKER_DIR}/Shared not found — skipping" + APPDATA_DETAIL="${APPDATA_DETAIL} · Shared —" +fi + +if [ "$APPDATA_WARN" -eq 0 ]; then + add_section_row "App Data (Pi-hole / Plex / Shared)" "OK" "${APPDATA_DETAIL# }" +else + add_section_row "App Data (Pi-hole / Plex / Shared)" "WARNING" "$APPDATA_WARN item(s) missing or failed ·${APPDATA_DETAIL}" +fi + +# ----------------------------------------------------------------------------- +# SECTION 5: CRONTAB & SCHEDULED JOBS +# ----------------------------------------------------------------------------- +log_section "Backing up crontab and scheduled jobs" + +CRON_DIR="${TMP_DIR}/cron" +mkdir -p "$CRON_DIR" +CRON_WARN=0 + +crontab -l > "${CRON_DIR}/root-crontab.txt" 2>/dev/null \ + && log "Root crontab saved" \ + || { log "No root crontab found"; CRON_WARN=$((CRON_WARN + 1)); } + +if [ -d /var/spool/cron/crontabs ]; then + cp -r /var/spool/cron/crontabs/ "${CRON_DIR}/crontabs/" \ + && log "All crontabs copied" \ + || { log "WARNING: crontabs copy failed"; CRON_WARN=$((CRON_WARN + 1)); } +fi + +if [ -d /etc/cron.d ]; then + cp -r /etc/cron.d/ "${CRON_DIR}/cron.d/" \ + && log "cron.d copied" \ + || { log "WARNING: cron.d copy failed"; CRON_WARN=$((CRON_WARN + 1)); } +fi + +if [ "$CRON_WARN" -eq 0 ]; then + add_section_row "Crontab / Scheduled Jobs" "OK" "root crontab, crontabs/, cron.d/ saved" +else + add_section_row "Crontab / Scheduled Jobs" "WARNING" "$CRON_WARN item(s) missing or failed to copy" +fi + +# ----------------------------------------------------------------------------- +# SECTION 6: BACKUP MANIFEST +# ----------------------------------------------------------------------------- +log_section "Generating backup manifest" + +OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown") +KERNEL_VER=$(uname -r) +HOST_IP=$(hostname -I | awk '{print $1}') +DOCKER_VER=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo "Unknown") + +MANIFEST="${TMP_DIR}/MANIFEST.txt" +cat > "$MANIFEST" </dev/null; then + log "Archive created: ${BACKUP_ROOT}/${ARCHIVE_NAME}" + ARCHIVE_SIZE=$(du -sh "${BACKUP_ROOT}/${ARCHIVE_NAME}" | cut -f1) + log "Archive size: ${ARCHIVE_SIZE}" + add_section_row "Archive Creation" "OK" "${ARCHIVE_NAME} · ${ARCHIVE_SIZE}" +else + log "ERROR: Archive creation failed" + ARCHIVE_SIZE="N/A" + add_section_row "Archive Creation" "ERROR" "tar failed — archive not created" + rm -rf "$TMP_DIR" + OVERALL_STATUS="FAILED" +fi + +# ----------------------------------------------------------------------------- +# SECTION 8: CLEANUP TEMP FILES +# ----------------------------------------------------------------------------- +log_section "Cleaning up temporary files" + +if rm -rf "$TMP_DIR" 2>/dev/null; then + log "Temporary directory removed" +else + log "WARNING: Failed to remove temp directory: $TMP_DIR" +fi +rmdir "$TMP_BASE" 2>/dev/null && log ".tmp directory removed (empty)" || true + +# ----------------------------------------------------------------------------- +# SECTION 9: ROTATE OLD BACKUPS +# ----------------------------------------------------------------------------- +log_section "Rotating old backups (keeping last ${RETAIN_COUNT})" + +BACKUP_LIST=$(ls -1t "${BACKUP_ROOT}"/nas08-backup-*.tar.gz 2>/dev/null || true) +BACKUP_COUNT=$(echo "$BACKUP_LIST" | grep -c . || true) +DELETED_COUNT=0 + +if [ "$BACKUP_COUNT" -gt "$RETAIN_COUNT" ]; then + DELETE_LIST=$(echo "$BACKUP_LIST" | tail -n +$((RETAIN_COUNT + 1))) + while IFS= read -r OLD_BACKUP; do + rm -f "$OLD_BACKUP" + log "Removed old backup: $(basename "$OLD_BACKUP")" + DELETED_COUNT=$((DELETED_COUNT + 1)) + done <<< "$DELETE_LIST" + add_section_row "Backup Rotation" "OK" "Kept $RETAIN_COUNT most recent · removed $DELETED_COUNT old archive(s)" +else + log "Backup count (${BACKUP_COUNT}) within retention limit — no rotation needed" + add_section_row "Backup Rotation" "OK" "$BACKUP_COUNT / $RETAIN_COUNT slots used — no rotation needed" +fi + +# ----------------------------------------------------------------------------- +# DONE — log summary +# ----------------------------------------------------------------------------- +log_section "Backup Complete" +log "Archive: ${BACKUP_ROOT}/${ARCHIVE_NAME}" +log "Size: ${ARCHIVE_SIZE:-N/A}" +log "Status: ${OVERALL_STATUS}" +log "" + +# ----------------------------------------------------------------------------- +# BUILD & SEND HTML EMAIL (pattern: nas08_zfs_scrub.sh) +# ----------------------------------------------------------------------------- + +# Overall banner & badge +case "$OVERALL_STATUS" in + SUCCESS) + BADGE_HTML="
✅ SUCCESS
" + BANNER_HTML="✅  Backup completed successfully — no action required." + ;; + WARNING) + BADGE_HTML="
⚠️ WARNING
" + BANNER_HTML="⚠️  Backup completed with warnings — review recommended." + ;; + FAILED) + BADGE_HTML="
🚨 FAILED
" + BANNER_HTML="🚨  Backup failed — immediate attention required!" + ;; +esac + +# Archive info card — only shown when archive was created +if [[ "${ARCHIVE_SIZE:-N/A}" != "N/A" ]]; then + ARCHIVE_CARD=" + +
+
🗜️ Archive
+ + + + + + + + +
+
Filename
+
$ARCHIVE_NAME
+
+
Size
+
$ARCHIVE_SIZE
+
+
Retained
+
$BACKUP_COUNT / $RETAIN_COUNT
+
+
+ " +else + ARCHIVE_CARD="" +fi + +# System versions card +VERSIONS_CARD=" + +
+
🖥️ System Versions
+ + + + + + + + + + + + + + +
OMV${OMV_VER}Kernel${KERNEL_VER}
Docker${DOCKER_VER}IP Address${HOST_IP}
+
+ " + +# Compose full HTML email +HTML_BODY=$(cat < + + + + + +
+ + + + + + + $BANNER_HTML + + + + + + $ARCHIVE_CARD + + + $VERSIONS_CARD + + + + + + + +
+ + + + + +
+
System Backup Monitor
+
💾 $HOSTNAME_LABEL
+
$REPORT_TIME  |  7 sections · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)
+
$BADGE_HTML
+
+
+
+ 📋 Backup Section Results +
+ + + + + + + $SECTION_ROWS_HTML +
SectionStatusDetail
+
+
+
+
📊 Run Summary
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Host$HOSTNAME_LABEL
Run Time$REPORT_TIME
Archive${ARCHIVE_NAME}
Archive Size${ARCHIVE_SIZE:-N/A}
Backups on Disk$BACKUP_COUNT of $RETAIN_COUNT max retained
Warnings$WARNING_COUNT
Errors$ERROR_COUNT
Overall Status$OVERALL_STATUS
+
+
+ Automated Backup Monitor  |  $HOSTNAME_LABEL  |  KingDezigns Infrastructure +
+
+ + +EOF +) + +# Send email via OMV Postfix (Gmail relay) +SUBJECT="[Backup] $HOSTNAME_LABEL — $OVERALL_STATUS | $REPORT_TIME" + +{ + echo "To: $REPORT_TO" + echo "From: $REPORT_FROM" + echo "Subject: $SUBJECT" + echo "MIME-Version: 1.0" + echo "Content-Type: text/html; charset=UTF-8" + echo "" + echo "$HTML_BODY" +} | sendmail -t + +log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" +log "===== NAS08 Backup script finished =====" diff --git a/NAS08-Kernel-Recovery-Summary.html b/NAS08-Kernel-Recovery-Summary.html new file mode 100644 index 0000000..142852e --- /dev/null +++ b/NAS08-Kernel-Recovery-Summary.html @@ -0,0 +1,682 @@ + + + + + +KingDezigns — NAS08 Kernel Recovery Summary + + + + + +
+ +
+

KingDezigns — NAS08 Kernel Recovery Summary

+
+ 📅 May 15–16, 2026 + 🖥 NAS08 — Raspberry Pi 5 Model B Rev 1.1 + ⚙️ OpenMediaVault 8.2.13 / Debian Trixie + 🔌 Radxa Penta SATA HAT (JMB585) + 🐧 Kernel 6.12.75 → 6.18.29 → rolled back → 6.18.29 (fixed) +
+
+ + + +
+
The problem
+ +

After a routine apt upgrade that included a kernel jump from 6.12 to 6.18 and a new raspi-firmware package, NAS08 lost all four SATA data drives connected via the Radxa Penta SATA HAT. ZFS reported no pools available and OMV could not see the drives.

+ +
+ zpool status: no pools available + ahci: failed to start port 0 (errno=-12) + lsblk: sdb/sdc/sdd/sde missing + BAR 0 [io size 0x0080]: can't assign; no space + DMA32: empty +
+ +

The upgrade included linux-image-6.18.29+rpt-rpi-2712, raspi-firmware 1:1.20260513-1, and openmediavault 8.2.13-1 among 20+ packages. The Radxa Penta SATA HAT uses a JMicron JMB585 PCIe SATA controller. An upstream kernel commit (ee95f3c) affecting the JMB585 chipset means kernel 6.18 now requires the pcie-32bit-dma-pi5 device tree overlay for the controller to allocate DMA memory successfully. This overlay was present in config.txt but placed under the [cm5] conditional section, which is ignored on a Raspberry Pi 5 Model B. As a result, the overlay never loaded and the JMB585 had no DMA32 address space.

+
+ + + +
+
Diagnostic commands run & why
+ +
+
1 Confirm ZFS module is loaded
+
lsmod | grep zfs
+

Rule out ZFS module as the cause — if it's not loaded, drives won't be visible to any pool.

+

ZFS module loaded correctly — zfs and spl both present. Not the cause.

+
+ +
+
2 Check running kernel vs installed kernels
+
uname -r
+dpkg -l | grep linux-image
+

Confirm which kernel booted and whether a version mismatch caused the issue.

+

Booted into 6.18.29 — confirmed the kernel jump from 6.12 happened. Both versions still installed.

+
+ +
+
3 Check if OS sees the drives at all
+
lsblk
+ls /dev/disk/by-id/
+

Determine if the problem is at the ZFS layer or the OS/hardware layer.

+

Only boot SSD visible. Data drives completely absent — hardware/driver level problem confirmed.

+
+ +
+
4 Check dmesg for USB/SATA/PCIe errors
+
dmesg | grep -iE "usb|sd|storage|attach|disconnect|reset" | grep -v "loop\|zram\|firmware" | tail -60
+

Identify whether drives were seen at boot and then lost, or never detected at all.

+

Only one USB device detected at boot (boot SSD). No SATA drives enumerated. Pointed to PCIe HAT issue.

+
+ +
+
5 Check PCIe controller visibility
+
lspci
+

Confirm whether the JMicron JMB585 SATA controller on the Radxa HAT is being detected by the PCIe bus.

+

JMicron JMB585 visible at 0001:01:00.0 — controller detected but not functional.

+
+ +
+
6 Check PCIe BAR assignment errors
+
dmesg | grep -iE "pcie|pci|ahci|sata|asm|jmb|asmedia" | head -40
+

Identify why the detected controller isn't working — look for resource allocation failures.

+

BAR 0–4 all failed to assign I/O space. PCI bridge showed zero-length address range. AHCI errno=-12 (ENOMEM).

+
+ +
+
7 Check DMA memory layout
+
cat /proc/meminfo | grep -i cma
+dmesg | grep -iE "cma|dma|coherent" | head -20
+

Determine why AHCI port start was failing with ENOMEM — check DMA zone availability.

+

DMA32 zone empty. CmaFree: 0 kB. JMB585 forced into 32-bit DMA mode with no available DMA32 address space.

+
+ +
+
8 Check hardware identity
+
cat /proc/device-tree/model
+

Confirm exact hardware model — critical for applying the correct config.txt overlay section.

+

Raspberry Pi 5 Model B Rev 1.1 — confirmed Pi 5, not CM5. This meant the [cm5] section in config.txt was being ignored.

+
+ +
+
9 Check config.txt overlay placement
+
cat /boot/firmware/config.txt
+

Identify whether the pcie-32bit-dma-pi5 overlay was present and in the correct conditional section.

+

Overlay was present but under [cm5] section — ignored on Pi 5. Moving it to [all] section was the fix.

+
+ +
+
10 Check EEPROM version and config
+
sudo rpi-eeprom-update
+sudo rpi-eeprom-config
+

Determine if pci=pcie_bus_safe was coming from the EEPROM bootloader config.

+

EEPROM was 5 months out of date. Updated during recovery session.

+
+ +
+ + + +
+
Root cause
+ +
+

An upstream Linux kernel commit (ee95f3c) changed how the JMB585 PCIe SATA controller allocates DMA memory on kernel 6.18. The JMB585 now requires the pcie-32bit-dma-pi5 device tree overlay to function correctly. This overlay was present in /boot/firmware/config.txt but placed under the [cm5] conditional section, which is silently ignored on a Raspberry Pi 5 Model B. As a result the overlay never loaded, the JMB585 had no DMA32 address space, and AHCI failed with ENOMEM on every port start attempt.

+
+ +
+
⚠ Key clue
+

The config.txt overlay section mismatch was the root cause. The overlay fix was correct — it was simply applied to the wrong conditional block during the initial recovery attempt.

+
# Wrong — ignored on Pi 5 Model B:
+[cm5]
+dtoverlay=pcie-32bit-dma-pi5
+
+# Correct — applies to all Pi hardware:
+[all]
+dtoverlay=pcie-32bit-dma-pi5
+

The kernel 6.18 requirement for pcie-32bit-dma-pi5 on JMB585 hardware is documented upstream. Any Pi 5 running kernel 6.18 with this HAT requires this overlay in the [all] or [pi5] section of config.txt.

+
+ +
+

Resolution: Moved dtoverlay=pcie-32bit-dma-pi5 from the [cm5] section to the [all] section in /boot/firmware/config.txt, then copied the 6.18.29 kernel binary back to /boot/firmware/kernel_2712.img and rebooted. All four SATA drives appeared immediately on kernel 6.18.

+
+
+ + + +
+
Recovery procedure applied
+ +
+
1 Roll back to kernel 6.12 (initial emergency recovery)
+
sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
+sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-2712 /boot/firmware/kernel_2712.img
+sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-v8 /boot/firmware/kernel8.img
+sudo reboot
+

Immediately restored drive visibility on 6.12 while the root cause was investigated. Holds prevented the firmware hook from re-upgrading kernel_2712.img on reboot.

+

All four drives (sdb/sdc/sdd/sde) returned on 6.12.

+
+ +
+
2 Fix the overlay placement in config.txt
+
sudo nano /boot/firmware/config.txt
+
+# Remove dtoverlay=pcie-32bit-dma-pi5 from the [cm5] section
+# Add it to the [all] section instead:
+
+[cm5]
+dtoverlay=dwc2,dr_mode=host
+
+[all]
+dtoverlay=pcie-32bit-dma-pi5
+

The overlay was present but in the wrong conditional section. Moving it to [all] ensures it applies to the Pi 5 Model B hardware.

+
+ +
+
3 Unhold packages and copy 6.18 kernel back
+
sudo apt-mark unhold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
+sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-2712 /boot/firmware/kernel_2712.img
+sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-v8 /boot/firmware/kernel8.img
+sudo reboot
+

6.18 was already installed — only the kernel_2712.img binary needed updating. The overlay fix was now in place before boot.

+
+ +
+
4 Verify and re-apply holds
+
uname -r        # should show 6.18.29+rpt-rpi-2712
+lsblk           # should show sdb sdc sdd sde
+sudo zpool status
+
+sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
+sudo apt-mark showhold
+

All four drives present on 6.18.29. ZFS pool ONLINE. Holds re-applied to prevent uncontrolled kernel upgrades.

+
+
+ + + +
+
Emergency rollback — if a future upgrade breaks drives again
+ +

If a future upgrade causes drives to disappear again, this is the fast rollback procedure. Takes less than 5 minutes.

+ +
+
1 Hold packages immediately
+
sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
+

The z50-raspi-firmware hook overwrites kernel_2712.img on every apt operation. Hold first to stop this.

+
+ +
+
2 Copy 6.12 kernel directly over the firmware kernel image
+
sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-2712 /boot/firmware/kernel_2712.img
+sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-v8 /boot/firmware/kernel8.img
+
+# Verify sizes — should be 9698043 and 9695883
+ls -la /boot/firmware/kernel_2712.img /boot/firmware/kernel8.img
+

raspi-firmware boots whichever kernel is in kernel_2712.img directly. Replacing it with 6.12 forces the rollback.

+
+ +
+
3 Reboot and confirm
+
sudo reboot
+
+# After reboot:
+uname -r        # should show 6.12.75+rpt-rpi-2712
+lsblk           # should show sdb sdc sdd sde
+sudo zpool status
+

Drives and ZFS pools return immediately on 6.12.

+
+
+ + + +
+
Scripts created this session
+
+
+
NAS08 ZFS Health Report
+
zfs_report_nas08.sh
+
+
+
NAS16 ZFS Health Report
+
zfs_report_nas16.sh
+
+
+
Commands executed per run
+
zpool status -v <pool>
+zpool list -H -o name,size,alloc,free,frag,cap,health <pool>
+zfs list -r -H -o name,used,avail,refer,mountpoint <pool>
+
+
+
Deploy & schedule (cron)
+
chmod +x zfs_report_nas08.sh
+sudo mv zfs_report_nas08.sh /opt/scripts/
+# Add to crontab — runs daily at 7:00 AM:
+0 7 * * * /opt/scripts/zfs_report_nas08.sh >> /var/log/zfs_report.log 2>&1
+
+
+
Email recipient
+
rufus.king@kingdezigns.com
+
+
+
+ + + +
+
NAS16 — apply same fix
+ +

NAS16 runs the identical Radxa Penta SATA HAT on a Raspberry Pi 5. It will hit the exact same kernel 6.18 / JMB585 DMA issue if updated without the overlay fix in place. SSH into NAS16 and follow these steps.

+ +
+
1 Hold kernel and firmware packages on NAS16
+
sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8
+sudo apt-mark showhold
+

Prevents kernel 6.18 and new raspi-firmware from installing automatically while the config.txt fix is applied.

+

Expected: all packages listed as held.

+
+ +
+
2 Confirm hardware identity
+
cat /proc/device-tree/model
+

Verify NAS16 is also a Pi 5 Model B so the [all] section placement is correct.

+
+ +
+
3 Fix the overlay placement in config.txt
+
sudo nano /boot/firmware/config.txt
+
+# Ensure pcie-32bit-dma-pi5 is in the [all] section:
+[all]
+dtoverlay=pcie-32bit-dma-pi5
+

Same fix as NAS08 — overlay must be in [all], not [cm5], to apply on Pi 5 hardware.

+
+ +
+
4 Unhold, upgrade, and reboot
+
sudo apt-mark unhold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8
+sudo apt upgrade
+sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-2712 /boot/firmware/kernel_2712.img
+sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-v8 /boot/firmware/kernel8.img
+sudo reboot
+
+ +
+
5 Verify and re-hold
+
uname -r
+lsblk
+sudo zpool status
+sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
+

Expected: sdb sdc sdd sde all present, uname shows 6.18.x, ZFS ONLINE.

+
+
+ + + +
+
Lessons learned
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LessonDetail
Always identify exact hardware before troubleshootingRun cat /proc/device-tree/model at the start of every session. The initial recovery misidentified NAS08 as a CM5, which placed the overlay fix under [cm5] where it was silently ignored on the Pi 5 Model B.
Never update both NAS systems at the same timeUpdate NAS08 first, confirm stable for 48hrs, then update NAS16. This approach would have prevented both systems from failing simultaneously.
config.txt conditional sections are silently ignoredOverlays placed under [cm4] or [cm5] are completely ignored on Pi 5 Model B hardware. There is no warning or error. Always verify placement with cat /proc/device-tree/model first.
Kernel 6.18 requires pcie-32bit-dma-pi5 for JMB585Upstream commit ee95f3c changed DMA behaviour for the JMB585 chipset. Any Pi 5 running kernel 6.18 with the Radxa Penta SATA HAT must have dtoverlay=pcie-32bit-dma-pi5 in the [all] section of config.txt or drives will not appear.
Hold kernel packages on all Pi-based NAS systemsKernel updates can silently break HAT compatibility. Always hold and update deliberately, never via unattended-upgrades.
raspi-firmware manages kernel_2712.img directlyThe post-install hook z50-raspi-firmware copies the kernel directly to /boot/firmware/kernel_2712.img. Copying an older kernel binary over this file is the correct rollback mechanism. Hold raspi-firmware before doing so or it will be overwritten on next boot.
Always simulate before upgradingRun sudo apt upgrade --simulate 2>&1 | grep -E "raspi-firmware|linux-image|linux-headers|Inst|Remv" before every upgrade. If any kernel or firmware packages appear as Inst lines, investigate before proceeding.
+
+ + + +
+
Current working config — NAS08
+ +

This is the known-good configuration for NAS08 on kernel 6.18.29 with all four SATA drives visible and ZFS healthy.

+ +
+
+
/boot/firmware/config.txt — bottom section
+
# Enable PCIe
+dtparam=pciex1
+dtparam=pciex1_gen=3
+
+[cm4]
+otg_mode=1
+
+[cm5]
+dtoverlay=dwc2,dr_mode=host
+
+[all]
+dtoverlay=pcie-32bit-dma-pi5
+
+
+
/boot/firmware/cmdline.txt
+
console=serial0,115200 console=tty1 root=PARTUUID=57cb028e-02 rootfstype=ext4 fsck.repair=yes rootwait cfg80211.ieee80211_regdom=US
+
+
+
Active kernel
+
6.18.29+rpt-rpi-2712
+
+
+
Held packages
+
raspi-firmware
+linux-image-rpi-2712
+linux-image-rpi-v8
+linux-image-6.18.29+rpt-rpi-2712
+linux-image-6.18.29+rpt-rpi-v8
+
+
+
+ + + +
+
Network map — items to update
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemOld valueNew value
NAS08 kernel version6.12.75+rpt-rpi-27126.18.29+rpt-rpi-2712 (held)
NAS08 config.txt overlaypcie-32bit-dma-pi5 under [cm5] — ignoredpcie-32bit-dma-pi5 under [all] — active
NAS08 raspi-firmwareauto-update enabledheld at 1:1.20260513-1
NAS16 kernel + overlay fix6.12.75 — pending upgradeapply config.txt fix first, then upgrade to 6.18.29
ZFS report scriptsnot deployedzfs_report_nas08.sh + zfs_report_nas16.sh — deploy to /opt/scripts/
+
+ + + + +
+ + diff --git a/Stocks/StockFunctions.bas b/Stocks/StockFunctions.bas new file mode 100644 index 0000000..afc2f21 --- /dev/null +++ b/Stocks/StockFunctions.bas @@ -0,0 +1,235 @@ +' ========================================================================= +' StockFunctions.bas +' Collabora / LibreOffice Basic port of the ONLYOFFICE custom functions. +' +' UPDATED 2026-07-23: STOCKPRICE / STOCKTIME (Finnhub) removed. +' Finnhub was dropped because it has no historical price coverage, which is +' why stockproxy (NAS08) was built in the first place. FUNDPRICE already +' works for any ticker stockproxy knows about, fund or stock, so it now +' doubles as the equity price function. FUNDTIME replaces STOCKTIME and +' returns stockproxy's "resolved_date" field. +' +' NOTE: resolved_date is a DATE (YYYY-MM-DD), not a time-of-day timestamp. +' Finnhub's old STOCKTIME gave true time-of-day resolution; this does not. +' If intraday precision is ever needed, stockproxy's /current endpoint +' would need a real timestamp field added on the backend side. +' +' HOW TO INSTALL IN COLLABORA: +' 1. Open the sheet in Collabora (collabora.kingdezigns.com). +' 2. Tools > Macros > Edit Macros (opens the Basic IDE). +' 3. In the tree, find "" (NOT "My Macros" - +' you want this stored IN the document so it travels with the file). +' 4. Right-click "Standard" under your document > Insert > Module. +' 5. Paste this entire file's contents into the new module. +' (Or, if StockFunctions already exists, replace its contents.) +' 6. Fill in PROXY_SECRET() below with the real value if not already set. +' 7. Save the document (Ctrl+S). Macro security must allow this +' document's macros to run - if prompted, allow them. +' +' USAGE IN CELLS: +' =FUNDPRICE(D4) +' =FUNDPRICE_HIST(D4, TEXT($B$3,"yyyy-mm-dd")) +' =FUNDPRICE(A5) <- replaces old =STOCKPRICE(A5) +' =FUNDTIME(A5) <- replaces old =STOCKTIME(A5) +' ========================================================================= + +Option Explicit + +' ---- Config ------------------------------------------------------------- + +Function PROXY_BASE() As String + PROXY_BASE = "https://stocks.kingdezigns.com" +End Function + +Function PROXY_SECRET() As String + PROXY_SECRET = "90c2528e9b5221c110f7c2c9cd6c65dc" ' <-- from Vaultwarden +End Function + +' ---- Public spreadsheet functions --------------------------------------- + +Function FUNDPRICE(ticker As String) As Variant + Dim sUrl As String, sJson As String, vPrice As Variant + sUrl = PROXY_BASE() & "/current?ticker=" & EncodeUrl(ticker) & "&key=" & PROXY_SECRET() + sJson = HttpGetText(sUrl) + If sJson = "" Then + FUNDPRICE = "#ERROR" + Exit Function + End If + vPrice = JsonNumber(sJson, "price") + If IsNull(vPrice) Then + FUNDPRICE = "#N/A" + Else + FUNDPRICE = vPrice + End If +End Function + +Function FUNDPRICE_HIST(ticker As String, dateStr As String) As Variant + Dim sUrl As String, sJson As String, vPrice As Variant + sUrl = PROXY_BASE() & "/historical?ticker=" & EncodeUrl(ticker) & _ + "&date=" & EncodeUrl(dateStr) & "&key=" & PROXY_SECRET() + sJson = HttpGetText(sUrl) + If sJson = "" Then + FUNDPRICE_HIST = "#ERROR" + Exit Function + End If + vPrice = JsonNumber(sJson, "price") + If IsNull(vPrice) Then + FUNDPRICE_HIST = "#N/A" + Else + FUNDPRICE_HIST = vPrice + End If +End Function + +' Replaces old STOCKTIME(ticker). Returns stockproxy's resolved_date +' (YYYY-MM-DD) — a date, not a time-of-day timestamp. See note at top. +Function FUNDTIME(ticker As String) As Variant + Dim sUrl As String, sJson As String, vDate As Variant + sUrl = PROXY_BASE() & "/current?ticker=" & EncodeUrl(ticker) & "&key=" & PROXY_SECRET() + sJson = HttpGetText(sUrl) + If sJson = "" Then + FUNDTIME = "#ERROR" + Exit Function + End If + vDate = JsonString(sJson, "resolved_date") + If IsNull(vDate) Then + FUNDTIME = "#N/A" + Else + FUNDTIME = vDate + End If +End Function + +' ---- Internal helpers ---------------------------------------------------- + +' Synchronous HTTP GET, returns response body as text, "" on failure. +Function HttpGetText(sUrl As String) As String + Dim oSFA As Object, oStream As Object, oTextStream As Object + Dim sResult As String + + On Error GoTo ErrHandler + oSFA = createUnoService("com.sun.star.ucb.SimpleFileAccess") + oStream = oSFA.openFileRead(sUrl) + oTextStream = createUnoService("com.sun.star.io.TextInputStream") + oTextStream.setInputStream(oStream) + oTextStream.setEncoding("UTF-8") + + sResult = "" + Do While Not oTextStream.isEOF() + sResult = sResult & oTextStream.readLine() & Chr(10) + Loop + oTextStream.closeInput() + HttpGetText = sResult + Exit Function + +ErrHandler: + HttpGetText = "" +End Function + +' Pulls a numeric value out of a flat JSON string for a given key. +' e.g. JsonNumber("{""price"":18.37}", "price") -> 18.37 +' Returns Null if the key isn't found or has no numeric value. +Function JsonNumber(sJson As String, sKey As String) As Variant + Dim iPos As Integer, iStart As Integer, iEnd As Integer + Dim sNum As String, cChar As String + + iPos = InStr(sJson, Chr(34) & sKey & Chr(34)) + If iPos = 0 Then + JsonNumber = Null + Exit Function + End If + + iPos = InStr(iPos, sJson, ":") + If iPos = 0 Then + JsonNumber = Null + Exit Function + End If + + iStart = iPos + 1 + Do While iStart <= Len(sJson) And Mid(sJson, iStart, 1) = " " + iStart = iStart + 1 + Loop + + iEnd = iStart + Do While iEnd <= Len(sJson) + cChar = Mid(sJson, iEnd, 1) + If (cChar >= "0" And cChar <= "9") Or cChar = "." Or cChar = "-" Then + iEnd = iEnd + 1 + Else + Exit Do + End If + Loop + + sNum = Mid(sJson, iStart, iEnd - iStart) + If Len(sNum) = 0 Then + JsonNumber = Null + Else + JsonNumber = CDbl(sNum) + End If +End Function + +' Pulls a quoted STRING value out of a flat JSON string for a given key. +' e.g. JsonString("{""resolved_date"":""2026-07-23""}", "resolved_date") +' -> "2026-07-23" +' Returns Null if the key isn't found or has no quoted value. +Function JsonString(sJson As String, sKey As String) As Variant + Dim iPos As Integer, iColon As Integer, iQuoteStart As Integer, iQuoteEnd As Integer + + iPos = InStr(sJson, Chr(34) & sKey & Chr(34)) + If iPos = 0 Then + JsonString = Null + Exit Function + End If + + iColon = InStr(iPos, sJson, ":") + If iColon = 0 Then + JsonString = Null + Exit Function + End If + + iQuoteStart = InStr(iColon, sJson, Chr(34)) + If iQuoteStart = 0 Then + JsonString = Null + Exit Function + End If + + iQuoteEnd = InStr(iQuoteStart + 1, sJson, Chr(34)) + If iQuoteEnd = 0 Then + JsonString = Null + Exit Function + End If + + JsonString = Mid(sJson, iQuoteStart + 1, iQuoteEnd - iQuoteStart - 1) +End Function + +' Minimal percent-encoder - sufficient for tickers and yyyy-mm-dd strings. +Function EncodeUrl(s As String) As String + Dim i As Integer, c As String, sOut As String + sOut = "" + For i = 1 To Len(s) + c = Mid(s, i, 1) + If (c >= "A" And c <= "Z") Or (c >= "a" And c <= "z") Or _ + (c >= "0" And c <= "9") Or c = "-" Or c = "_" Or c = "." Or c = "~" Then + sOut = sOut & c + Else + sOut = sOut & "%" & Right("0" & Hex(Asc(c)), 2) + End If + Next i + EncodeUrl = sOut +End Function + +Sub RefreshPriceSnapshot + Dim oDoc As Object, oSheet As Object + Dim oSrcRange As Object, oDestRange As Object + Dim oCell As Object + Dim i As Integer + + oDoc = ThisComponent + oSheet = oDoc.Sheets.getByIndex(0) ' adjust index/name if needed + + ' Copy P3:Q53 -> R3:S53 as values only + For i = 2 To 4 ' rows 3 to 53 (0-indexed: row 3 = index 2) + oSheet.getCellByPosition(17, i).setValue(oSheet.getCellByPosition(15, i).getValue()) ' P->R (col P=15, R=17) + oSheet.getCellByPosition(18, i).setValue(oSheet.getCellByPosition(16, i).getValue()) ' Q->S (col Q=16, S=18) + Next i + + MsgBox "Price snapshot refreshed." +End Sub diff --git a/Stocks/onlyoffice_macro_autostart_recalc.js b/Stocks/onlyoffice_macro_autostart_recalc.js new file mode 100644 index 0000000..c8f4c5e --- /dev/null +++ b/Stocks/onlyoffice_macro_autostart_recalc.js @@ -0,0 +1,19 @@ +// ONLYOFFICE Macro (NOT a Custom Function -- add under View -> Macros -> Macros, the "+" next +// to "Macros", not "Custom functions") +// +// Purpose: attempt to fix #NAME? / #BUSY errors that appear on file open for custom-function +// cells (FUNDPRICE, FUNDPRICE_HIST, STOCKPRICE, STOCKTIME) until manually clicked + Enter. +// +// IMPORTANT -- STATUS: PARTIAL FIX ONLY, ISSUE NOT FULLY RESOLVED. +// A screenshot after applying this showed only 2 of 34 "Price" cells resolved correctly; +// the rest stayed #NAME?, while a parallel "Price History" column resolved for all rows. +// The asymmetry was not explained before the session moved on. See +// onlyoffice_stock_tracking.md "Known Issues" section for next debugging steps if resuming. +// +// Setup: paste this in as a new Macro, check the "Autostart" checkbox, Save. + +(function () { + setTimeout(function () { + Api.RecalculateAllFormulas(); + }, 3000); +})(); diff --git a/Stocks/onlyoffice_macro_fundprice_proxy.js b/Stocks/onlyoffice_macro_fundprice_proxy.js new file mode 100644 index 0000000..836ab8f --- /dev/null +++ b/Stocks/onlyoffice_macro_fundprice_proxy.js @@ -0,0 +1,61 @@ +// ONLYOFFICE Custom Function +// Location: View -> Macros -> Custom Functions +// Used on: "HSA Investiments Choices" tab (mutual funds -- Finnhub has no NAV coverage) +// Data source: self-hosted STOCKPROXY service (see server_stockproxy.md / stockproxy_app.py) +// https://stocks.kingdezigns.com +// +// Real shared secret lives in Vaultwarden -- never commit the real secret to this file. +// +// Usage in a cell: +// =FUNDPRICE("MKDVX") +// =FUNDPRICE_HIST("MKDVX", TEXT($B$3,"yyyy-mm-dd")) <- date MUST be passed as text + +(function () { + var PROXY_BASE = "https://stocks.kingdezigns.com"; + var PROXY_SECRET = "YOUR_PROXY_SECRET_HERE"; + + /** + * Current mutual fund price via your own proxy. + * @customfunction + * @param {string} ticker Ticker symbol, e.g. "MKDVX" + * @returns {any} Latest price, or an error string + */ + async function FUNDPRICE(ticker) { + var url = PROXY_BASE + "/current?ticker=" + encodeURIComponent(ticker) + "&key=" + PROXY_SECRET; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && typeof data.price === "number") { + return data.price; + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + /** + * Historical fund price on/before a given date via your own proxy. + * @customfunction + * @param {string} ticker Ticker symbol, e.g. "MKDVX" + * @param {string} dateStr Date as YYYY-MM-DD (wrap the date cell with TEXT(cell,"yyyy-mm-dd")) + * @returns {any} Historical closing price, or an error string + */ + async function FUNDPRICE_HIST(ticker, dateStr) { + var url = PROXY_BASE + "/historical?ticker=" + encodeURIComponent(ticker) + + "&date=" + encodeURIComponent(dateStr) + "&key=" + PROXY_SECRET; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && typeof data.price === "number") { + return data.price; + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + Api.AddCustomFunction(FUNDPRICE); + Api.AddCustomFunction(FUNDPRICE_HIST); +})(); diff --git a/Stocks/onlyoffice_macro_stockprice_finnhub (1).js b/Stocks/onlyoffice_macro_stockprice_finnhub (1).js new file mode 100644 index 0000000..8290df0 --- /dev/null +++ b/Stocks/onlyoffice_macro_stockprice_finnhub (1).js @@ -0,0 +1,55 @@ +// ONLYOFFICE Custom Function +// Location: View -> Macros -> Custom Functions +// Used on: "Dashboard" tab (regular equities only -- Finnhub does NOT support mutual funds) +// Data source: Finnhub (https://finnhub.io) -- free tier, real-time US equity quotes +// +// Real API key lives in Vaultwarden -- never commit the real key to this file. + +(function () { + /** + * Latest stock price for a ticker, via Finnhub. + * @customfunction + * @param {string} ticker Stock ticker symbol, e.g. "AAPL" + * @returns {any} Latest price, or an error string + */ + async function STOCKPRICE(ticker) { + var apiKey = "YOUR_FINNHUB_API_KEY"; + var url = "https://finnhub.io/api/v1/quote?symbol=" + + encodeURIComponent(ticker) + "&token=" + apiKey; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && typeof data.c === "number" && data.c !== 0) { + return data.c; + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + /** + * Exchange-reported timestamp of the last quote for a ticker, via Finnhub. + * @customfunction + * @param {string} ticker Stock ticker symbol, e.g. "AAPL" + * @returns {any} Last-updated time, or an error string + */ + async function STOCKTIME(ticker) { + var apiKey = "YOUR_FINNHUB_API_KEY"; + var url = "https://finnhub.io/api/v1/quote?symbol=" + + encodeURIComponent(ticker) + "&token=" + apiKey; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && data.t) { + return new Date(data.t * 1000).toLocaleString(); + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + Api.AddCustomFunction(STOCKPRICE); + Api.AddCustomFunction(STOCKTIME); +})(); diff --git a/Stocks/onlyoffice_macro_stockprice_finnhub.js b/Stocks/onlyoffice_macro_stockprice_finnhub.js new file mode 100644 index 0000000..8290df0 --- /dev/null +++ b/Stocks/onlyoffice_macro_stockprice_finnhub.js @@ -0,0 +1,55 @@ +// ONLYOFFICE Custom Function +// Location: View -> Macros -> Custom Functions +// Used on: "Dashboard" tab (regular equities only -- Finnhub does NOT support mutual funds) +// Data source: Finnhub (https://finnhub.io) -- free tier, real-time US equity quotes +// +// Real API key lives in Vaultwarden -- never commit the real key to this file. + +(function () { + /** + * Latest stock price for a ticker, via Finnhub. + * @customfunction + * @param {string} ticker Stock ticker symbol, e.g. "AAPL" + * @returns {any} Latest price, or an error string + */ + async function STOCKPRICE(ticker) { + var apiKey = "YOUR_FINNHUB_API_KEY"; + var url = "https://finnhub.io/api/v1/quote?symbol=" + + encodeURIComponent(ticker) + "&token=" + apiKey; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && typeof data.c === "number" && data.c !== 0) { + return data.c; + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + /** + * Exchange-reported timestamp of the last quote for a ticker, via Finnhub. + * @customfunction + * @param {string} ticker Stock ticker symbol, e.g. "AAPL" + * @returns {any} Last-updated time, or an error string + */ + async function STOCKTIME(ticker) { + var apiKey = "YOUR_FINNHUB_API_KEY"; + var url = "https://finnhub.io/api/v1/quote?symbol=" + + encodeURIComponent(ticker) + "&token=" + apiKey; + try { + var r = await fetch(url); + var data = await r.json(); + if (data && data.t) { + return new Date(data.t * 1000).toLocaleString(); + } + return "#N/A"; + } catch (e) { + return "#ERROR"; + } + } + + Api.AddCustomFunction(STOCKPRICE); + Api.AddCustomFunction(STOCKTIME); +})(); diff --git a/Stocks/stockproxy_Dockerfile b/Stocks/stockproxy_Dockerfile new file mode 100644 index 0000000..e4139b4 --- /dev/null +++ b/Stocks/stockproxy_Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN mkdir -p /data + +EXPOSE 5005 + +CMD ["gunicorn", "--bind", "0.0.0.0:5005", "--workers", "2", "--timeout", "30", "app:app"] diff --git a/Stocks/stockproxy_app.py b/Stocks/stockproxy_app.py new file mode 100644 index 0000000..45ca514 --- /dev/null +++ b/Stocks/stockproxy_app.py @@ -0,0 +1,161 @@ +import os +import time +import sqlite3 +import threading +from datetime import datetime, timedelta + +import requests +from flask import Flask, request, jsonify + +app = Flask(__name__) + +# ---- Config ---- +SHARED_SECRET = os.environ.get("PROXY_SECRET", "") +DB_PATH = os.environ.get("DB_PATH", "/data/cache.db") +CURRENT_TTL_SECONDS = 15 * 60 # re-check "today" every 15 min in case NAV just posted + +_db_lock = threading.Lock() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS historical_cache ( + ticker TEXT NOT NULL, + requested_date TEXT NOT NULL, + resolved_date TEXT NOT NULL, + price REAL NOT NULL, + PRIMARY KEY (ticker, requested_date) + ) + """) + return conn + +_current_cache = {} # ticker -> (price, resolved_date, fetched_at) +_current_cache_lock = threading.Lock() + + +@app.after_request +def add_cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + + +def check_auth(): + key = request.args.get("key", "") + return bool(SHARED_SECRET) and key == SHARED_SECRET + + +def fetch_yahoo_price(ticker, target_date): + """Ask Yahoo for the last trading-day close on or before target_date.""" + period2 = int((target_date + timedelta(days=1)).timestamp()) + period1 = int((target_date - timedelta(days=10)).timestamp()) + + url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + r = requests.get(url, params={"period1": period1, "period2": period2, "interval": "1d"}, headers=headers, timeout=10) + + if r.status_code != 200: + return None, f"yahoo http {r.status_code}" + + data = r.json() + try: + result = data["chart"]["result"][0] + timestamps = result["timestamp"] + closes = result["indicators"]["quote"][0]["close"] + except (KeyError, IndexError, TypeError): + return None, "no historical data" + + best = None + for ts, close in zip(timestamps, closes): + if close is None: + continue + day = datetime.utcfromtimestamp(ts) + if day.date() <= target_date.date(): + if best is None or day > best[0]: + best = (day, close) + + if best is None: + return None, "no trading day found in window" + + return {"resolved_date": best[0].strftime("%Y-%m-%d"), "price": round(best[1], 4)}, None + + +@app.route("/current") +def current_price(): + if not check_auth(): + return jsonify({"error": "unauthorized"}), 401 + + ticker = request.args.get("ticker", "").upper().strip() + if not ticker: + return jsonify({"error": "missing ticker"}), 400 + + now = time.time() + with _current_cache_lock: + cached = _current_cache.get(ticker) + if cached and (now - cached[2]) < CURRENT_TTL_SECONDS: + return jsonify({"ticker": ticker, "price": cached[0], "resolved_date": cached[1], "cached": True}) + + result, err = fetch_yahoo_price(ticker, datetime.utcnow()) + if err: + return jsonify({"ticker": ticker, "error": err}), 502 + + with _current_cache_lock: + _current_cache[ticker] = (result["price"], result["resolved_date"], now) + + return jsonify({"ticker": ticker, "price": result["price"], "resolved_date": result["resolved_date"], "cached": False}) + + +@app.route("/historical") +def historical_price(): + if not check_auth(): + return jsonify({"error": "unauthorized"}), 401 + + ticker = request.args.get("ticker", "").upper().strip() + date_str = request.args.get("date", "").strip() + if not ticker or not date_str: + return jsonify({"error": "missing ticker or date"}), 400 + + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return jsonify({"error": "date must be YYYY-MM-DD"}), 400 + + # If someone asks "historical" for today/future, treat it like /current instead + if target_date.date() >= datetime.utcnow().date(): + return current_price() + + with _db_lock: + conn = get_db() + row = conn.execute( + "SELECT resolved_date, price FROM historical_cache WHERE ticker=? AND requested_date=?", + (ticker, date_str), + ).fetchone() + conn.close() + + if row: + return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": row[0], "price": row[1], "cached": True}) + + result, err = fetch_yahoo_price(ticker, target_date) + if err: + return jsonify({"ticker": ticker, "error": err}), 502 + + with _db_lock: + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO historical_cache (ticker, requested_date, resolved_date, price) VALUES (?, ?, ?, ?)", + (ticker, date_str, result["resolved_date"], result["price"]), + ) + conn.commit() + conn.close() + + return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": result["resolved_date"], "price": result["price"], "cached": False}) + + +@app.route("/health") +def health(): + return jsonify({"status": "ok"}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5005) diff --git a/Stocks/stockproxy_docker-compose.yml b/Stocks/stockproxy_docker-compose.yml new file mode 100644 index 0000000..2bf6a91 --- /dev/null +++ b/Stocks/stockproxy_docker-compose.yml @@ -0,0 +1,12 @@ +services: + stockproxy: + build: . + container_name: stockproxy + restart: unless-stopped + ports: + - "5005:5005" + environment: + - PROXY_SECRET=PASTE_YOUR_RANDOM_SECRET_HERE + - DB_PATH=/data/cache.db + volumes: + - /opt/stockproxy/data:/data diff --git a/Stocks/stockproxy_requirements.txt b/Stocks/stockproxy_requirements.txt new file mode 100644 index 0000000..e3562d5 --- /dev/null +++ b/Stocks/stockproxy_requirements.txt @@ -0,0 +1,3 @@ +flask +requests +gunicorn diff --git a/nextcloud/nextcloud_update_check (1).sh b/nextcloud/nextcloud_update_check (1).sh new file mode 100644 index 0000000..9f0850a --- /dev/null +++ b/nextcloud/nextcloud_update_check (1).sh @@ -0,0 +1,371 @@ +#!/bin/bash +# ============================================================= +# nextcloud_update_check.sh +# Checks for Nextcloud CORE and app updates daily. +# Sends an HTML email report with update commands ready to +# copy-paste into terminal. +# +# Schedule: Daily (recommended 7:00 AM) +# Install: /usr/scripts/omv/nextcloud_update_check.sh +# Log: /var/log/nextcloud-updates/ +# +# 2026-07-24 fix: previously only checked `occ app:update --all +# --showonly`, which reports APP updates only. It never checked +# for a Nextcloud CORE/server version update, so a core release +# (e.g. Nextcloud 34.0.2) was silently missed and the report +# said "up to date" even when it wasn't. Added a separate core +# check via `occ update:check`. +# ============================================================= + +# --- Config -------------------------------------------------- +CONTAINER="nextcloud" +OCC="sudo docker exec -u www-data ${CONTAINER} php occ" +# OMV Compose project location — NOT ~/docker/nextcloud. Confirmed via: +# docker inspect nextcloud --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' +# Directory is root-only (drwx------ root root), so compose commands +# must run under sudo. OMV names its compose files ".yml" + +# "compose.override.yml" — NOT the default "docker-compose.yml" — so +# both -f flags are required or `docker compose` won't find them. +NEXTCLOUD_COMPOSE_DIR="/kingdezignsnas/Docker/Compose/nextcloud" +NEXTCLOUD_COMPOSE_FILES="-f nextcloud.yml -f compose.override.yml" +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT="465" +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS_FILE="/etc/nextcloud-smtp-pass" +FROM_NAME="NAS08" +FROM_EMAIL="rufus.king@kingdezigns.com" +TO_EMAIL="rufus.king@kingdezigns.com" +HOSTNAME_LABEL="NAS08" +LOG_DIR="/var/log/nextcloud-updates" +LOG_RETENTION_DAYS=90 +# ------------------------------------------------------------- + +mkdir -p "$LOG_DIR" +DATESTAMP=$(date +"%Y%m%d") +TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") +LOGFILE="$LOG_DIR/update-check-${DATESTAMP}.log" + +log() { echo "[$(date +"%H:%M:%S")] $*" | tee -a "$LOGFILE"; } + +# --- Load SMTP password -------------------------------------- +if [[ ! -f "$SMTP_PASS_FILE" ]]; then + log "ERROR: SMTP password file not found at $SMTP_PASS_FILE" + exit 1 +fi +SMTP_PASS=$(cat "$SMTP_PASS_FILE") + +# --- Check container is running ------------------------------ +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + log "ERROR: Container '${CONTAINER}' is not running." + # Send critical alert + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud container is not running" + BODY_HTML="

The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.

" + NEEDS_ACTION=true + APP_ROWS="" + CMD_BLOCK="" +else + # --- Get Nextcloud status ---------------------------------- + log "Checking Nextcloud status..." + NC_STATUS=$($OCC status 2>/dev/null | grep -v '^{') + MAINTENANCE=$(echo "$NC_STATUS" | grep "maintenance:" | awk '{print $3}') + NEEDS_UPGRADE=$(echo "$NC_STATUS" | grep "needsDbUpgrade:" | awk '{print $3}') + NC_VERSION=$(echo "$NC_STATUS" | grep "versionstring:" | awk '{print $3}') + + log "Nextcloud version: $NC_VERSION | maintenance: $MAINTENANCE | needsDbUpgrade: $NEEDS_UPGRADE" + + # --- Check for CORE (server) update ------------------------- + # occ app:update only reports app updates, never core/server + # releases. occ update:check is the command that actually + # checks the updater channel for a new Nextcloud version. + log "Checking core (server) update..." + CORE_CHECK_RAW=$($OCC update:check 2>/dev/null | grep -v '^{') + CORE_UPDATE_AVAILABLE=false + CORE_NEW_VERSION="" + if echo "$CORE_CHECK_RAW" | grep -qi "is available"; then + CORE_UPDATE_AVAILABLE=true + CORE_NEW_VERSION=$(echo "$CORE_CHECK_RAW" | grep -oE 'Nextcloud [0-9]+\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}') + log "Core update available: Nextcloud ${CORE_NEW_VERSION}" + else + log "Core is up to date." + fi + + # --- Check for app updates -------------------------------- + log "Checking app updates..." + # Filter out admin_audit JSON log lines that appear when admin_audit app is enabled + APP_UPDATE_RAW=$($OCC app:update --all --showonly 2>/dev/null | grep -v '^{' ) + + # Parse apps with available updates + # Format from occ app:update --showonly: "appname new version available: X.Y.Z" + UPDATABLE_APPS=() + while IFS= read -r line; do + if echo "$line" | grep -q "new version available"; then + APP_NAME=$(echo "$line" | awk '{print $1}') + NEW_VER=$(echo "$line" | awk '{print $NF}') + UPDATABLE_APPS+=("${APP_NAME}|||${NEW_VER}") + log "Update available: $APP_NAME → $NEW_VER" + fi + done <<< "$APP_UPDATE_RAW" + + APP_COUNT=${#UPDATABLE_APPS[@]} + + # --- Build status ----------------------------------------- + NEEDS_ACTION=false + WARNINGS=() + + if [[ "$MAINTENANCE" == "true" ]]; then + WARNINGS+=("Nextcloud is currently in maintenance mode") + NEEDS_ACTION=true + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + WARNINGS+=("Database upgrade required (needsDbUpgrade: true)") + NEEDS_ACTION=true + fi + if [[ $APP_COUNT -gt 0 ]]; then + NEEDS_ACTION=true + fi + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + NEEDS_ACTION=true + fi + + # --- Determine overall status color ----------------------- + if [[ "$MAINTENANCE" == "true" ]] || [[ "$NEEDS_UPGRADE" == "true" ]]; then + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud requires immediate attention" + elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]] && [[ $APP_COUNT -gt 0 ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  Core update to ${CORE_NEW_VERSION} + ${APP_COUNT} app update(s) available" + elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  Nextcloud core update available (${NC_VERSION} → ${CORE_NEW_VERSION})" + elif [[ $APP_COUNT -gt 0 ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  ${APP_COUNT} app update(s) available" + else + STATUS_COLOR="#1a7f4b" + STATUS_LABEL="UP TO DATE" + STATUS_MSG="✅  Nextcloud is fully up to date" + fi + + # --- Maintenance mode color (was previously unset/unused) -- + if [[ "$MAINTENANCE" == "true" ]]; then + MAINTENANCE_COLOR="#b91c1c" + else + MAINTENANCE_COLOR="#1a7f4b" + fi + + # --- Build app update rows -------------------------------- + APP_ROWS="" + if [[ $APP_COUNT -gt 0 ]]; then + for entry in "${UPDATABLE_APPS[@]}"; do + APP_NAME=$(echo "$entry" | cut -d'|' -f1) + NEW_VER=$(echo "$entry" | cut -d'|' -f4) + APP_ROWS+=" + ${APP_NAME} + ${NEW_VER} + " + done + fi + + # --- Build warning rows ----------------------------------- + WARN_HTML="" + for w in "${WARNINGS[@]}"; do + WARN_HTML+="
+ ⚠ ${w} +
" + done + + # --- Build copy-paste command block ----------------------- + # NOTE: must use $'...' (ANSI-C quoting) below, not "...". Plain + # double quotes do NOT interpret \n as a newline in bash — they + # insert the two literal characters \ and n, which is why the + # previous version of this script emailed out commands with + # literal "\n" text instead of real line breaks. + CMD_LINES="" + if [[ "$MAINTENANCE" == "true" ]]; then + CMD_LINES+=$'# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n' + fi + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + # The nextcloud image's own entrypoint auto-detects the version + # bump and runs the upgrade itself on container start — it does + # NOT need `occ upgrade` run manually. Running `occ upgrade` + # immediately after `up -d` can race the container's own startup + # and falsely report "No upgrade required" if checked too early. + # The 30s sleep gives the entrypoint's internal upgrade time to + # finish before the confirming `occ status` call. + CMD_LINES+="# Update Nextcloud CORE to ${CORE_NEW_VERSION} (do this before app updates)"$'\n' + CMD_LINES+="sudo bash -c \"cd ${NEXTCLOUD_COMPOSE_DIR} && docker compose ${NEXTCLOUD_COMPOSE_FILES} pull && docker compose ${NEXTCLOUD_COMPOSE_FILES} up -d\""$'\n\n' + CMD_LINES+="# Give the container's built-in upgrade entrypoint time to finish,"$'\n' + CMD_LINES+="# then confirm the new version is active (watch for versionstring: ${CORE_NEW_VERSION})"$'\n' + CMD_LINES+="sleep 30"$'\n' + CMD_LINES+="sudo docker exec -u www-data nextcloud php occ status"$'\n\n' + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + CMD_LINES+=$'# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n' + fi + if [[ $APP_COUNT -gt 0 ]]; then + CMD_LINES+=$'# Update all apps\nsudo docker exec -u www-data nextcloud php occ app:update --all\n\n' + fi + if [[ -z "$CMD_LINES" ]]; then + CMD_LINES="# No action required — everything is up to date"$'\n' + fi + CMD_LINES+=$'# Verify status after updates\nsudo docker exec -u www-data nextcloud php occ status' + + CMD_BLOCK="
+
📋 Commands to run on NAS08
+
${CMD_LINES}
+
" + + # --- Maintenance/upgrade block ---------------------------- + MAINT_BLOCK="" + if [[ -n "$WARN_HTML" ]]; then + MAINT_BLOCK="
+
⚠ Issues Detected
+ ${WARN_HTML} +
" + fi + + # --- Core update block -------------------------------------- + CORE_BLOCK="" + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + CORE_BLOCK="
+
🆙 Core Update Available
+ + + + + + + + + +
Current Version${NC_VERSION}
New Version${CORE_NEW_VERSION}
+
" + fi + + # --- App update table ------------------------------------- + APP_TABLE="" + if [[ $APP_COUNT -gt 0 ]]; then + APP_TABLE="
+
🔄 App Updates Available (${APP_COUNT})
+ + + + + + + + ${APP_ROWS} +
AppNew Version
+
" + else + APP_TABLE="
+ ✓ All apps are up to date +
" + fi + + BODY_HTML="${MAINT_BLOCK} + + +
+ Nextcloud Version + ${NC_VERSION} + Maintenance Mode + ${MAINTENANCE:-false} +
+ + ${CORE_BLOCK} + ${APP_TABLE} + ${CMD_BLOCK}" +fi + +# --- Determine email subject --------------------------------- +if [[ "$NEEDS_ACTION" == "true" ]]; then + SUBJECT="[NAS08] Nextcloud — ${STATUS_LABEL} — Action Required" +else + SUBJECT="[NAS08] Nextcloud — All Up To Date" +fi + +log "Composing email: $SUBJECT" + +# --- Build full HTML email ----------------------------------- +HTML_EMAIL=$(cat < + + + + + +
+ + + + + + + + + + + + + + +
+ + + +
+
Nextcloud · Daily Update Report
+
☁ ${HOSTNAME_LABEL}
+
${TIMESTAMP} | cloud.kingdezigns.com
+
+
${STATUS_LABEL}
+
+
+ ${STATUS_MSG} +
+ ${BODY_HTML} +
+ Nextcloud Update Check  |  ${HOSTNAME_LABEL}  |  KingDezigns Infrastructure +
+
+ + +HTML +) + +# --- Send email via curl/SMTP -------------------------------- +log "Sending email to ${TO_EMAIL}..." + +SEND_RESULT=$(curl --silent --show-error \ + --url "smtps://${SMTP_SERVER}:${SMTP_PORT}" \ + --ssl-reqd \ + --mail-from "${FROM_EMAIL}" \ + --mail-rcpt "${TO_EMAIL}" \ + --user "${SMTP_USER}:${SMTP_PASS}" \ + --upload-file - <&1 +From: ${FROM_NAME} <${FROM_EMAIL}> +To: ${TO_EMAIL} +Subject: ${SUBJECT} +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +${HTML_EMAIL} +EOF +) + +if [[ $? -eq 0 ]]; then + log "Email sent successfully." +else + log "ERROR sending email: ${SEND_RESULT}" +fi + +# --- Rotate old logs ----------------------------------------- +find "$LOG_DIR" -name "update-check-*.log" -mtime +${LOG_RETENTION_DAYS} -delete +log "Done." diff --git a/nextcloud/nextcloud_update_check.sh b/nextcloud/nextcloud_update_check.sh new file mode 100644 index 0000000..1cd824c --- /dev/null +++ b/nextcloud/nextcloud_update_check.sh @@ -0,0 +1,352 @@ +#!/bin/bash +# ============================================================= +# nextcloud_update_check.sh +# Checks for Nextcloud CORE and app updates daily. +# Sends an HTML email report with update commands ready to +# copy-paste into terminal. +# +# Schedule: Daily (recommended 7:00 AM) +# Install: /usr/scripts/omv/nextcloud_update_check.sh +# Log: /var/log/nextcloud-updates/ +# +# 2026-07-24 fix: previously only checked `occ app:update --all +# --showonly`, which reports APP updates only. It never checked +# for a Nextcloud CORE/server version update, so a core release +# (e.g. Nextcloud 34.0.2) was silently missed and the report +# said "up to date" even when it wasn't. Added a separate core +# check via `occ update:check`. +# ============================================================= + +# --- Config -------------------------------------------------- +CONTAINER="nextcloud" +OCC="sudo docker exec -u www-data ${CONTAINER} php occ" +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT="465" +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS_FILE="/etc/nextcloud-smtp-pass" +FROM_NAME="NAS08" +FROM_EMAIL="rufus.king@kingdezigns.com" +TO_EMAIL="rufus.king@kingdezigns.com" +HOSTNAME_LABEL="NAS08" +LOG_DIR="/var/log/nextcloud-updates" +LOG_RETENTION_DAYS=90 +# ------------------------------------------------------------- + +mkdir -p "$LOG_DIR" +DATESTAMP=$(date +"%Y%m%d") +TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") +LOGFILE="$LOG_DIR/update-check-${DATESTAMP}.log" + +log() { echo "[$(date +"%H:%M:%S")] $*" | tee -a "$LOGFILE"; } + +# --- Load SMTP password -------------------------------------- +if [[ ! -f "$SMTP_PASS_FILE" ]]; then + log "ERROR: SMTP password file not found at $SMTP_PASS_FILE" + exit 1 +fi +SMTP_PASS=$(cat "$SMTP_PASS_FILE") + +# --- Check container is running ------------------------------ +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + log "ERROR: Container '${CONTAINER}' is not running." + # Send critical alert + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud container is not running" + BODY_HTML="

The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.

" + NEEDS_ACTION=true + APP_ROWS="" + CORE_ROW="" + CMD_BLOCK="" +else + # --- Get Nextcloud status ---------------------------------- + log "Checking Nextcloud status..." + NC_STATUS=$($OCC status 2>/dev/null | grep -v '^{') + MAINTENANCE=$(echo "$NC_STATUS" | grep "maintenance:" | awk '{print $3}') + NEEDS_UPGRADE=$(echo "$NC_STATUS" | grep "needsDbUpgrade:" | awk '{print $3}') + NC_VERSION=$(echo "$NC_STATUS" | grep "versionstring:" | awk '{print $3}') + + log "Nextcloud version: $NC_VERSION | maintenance: $MAINTENANCE | needsDbUpgrade: $NEEDS_UPGRADE" + + # --- Check for CORE (server) update ------------------------- + # occ app:update only reports app updates, never core/server + # releases. occ update:check is the command that actually + # checks the updater channel for a new Nextcloud version. + log "Checking core (server) update..." + CORE_CHECK_RAW=$($OCC update:check 2>/dev/null | grep -v '^{') + CORE_UPDATE_AVAILABLE=false + CORE_NEW_VERSION="" + if echo "$CORE_CHECK_RAW" | grep -qi "is available"; then + CORE_UPDATE_AVAILABLE=true + CORE_NEW_VERSION=$(echo "$CORE_CHECK_RAW" | grep -oE 'Nextcloud [0-9]+\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}') + log "Core update available: Nextcloud ${CORE_NEW_VERSION}" + else + log "Core is up to date." + fi + + # --- Check for app updates -------------------------------- + log "Checking app updates..." + # Filter out admin_audit JSON log lines that appear when admin_audit app is enabled + APP_UPDATE_RAW=$($OCC app:update --all --showonly 2>/dev/null | grep -v '^{' ) + + # Parse apps with available updates + # Format from occ app:update --showonly: "appname new version available: X.Y.Z" + UPDATABLE_APPS=() + while IFS= read -r line; do + if echo "$line" | grep -q "new version available"; then + APP_NAME=$(echo "$line" | awk '{print $1}') + NEW_VER=$(echo "$line" | awk '{print $NF}') + UPDATABLE_APPS+=("${APP_NAME}|||${NEW_VER}") + log "Update available: $APP_NAME → $NEW_VER" + fi + done <<< "$APP_UPDATE_RAW" + + APP_COUNT=${#UPDATABLE_APPS[@]} + + # --- Build status ----------------------------------------- + NEEDS_ACTION=false + WARNINGS=() + + if [[ "$MAINTENANCE" == "true" ]]; then + WARNINGS+=("Nextcloud is currently in maintenance mode") + NEEDS_ACTION=true + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + WARNINGS+=("Database upgrade required (needsDbUpgrade: true)") + NEEDS_ACTION=true + fi + if [[ $APP_COUNT -gt 0 ]]; then + NEEDS_ACTION=true + fi + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + NEEDS_ACTION=true + fi + + # --- Determine overall status color ----------------------- + if [[ "$MAINTENANCE" == "true" ]] || [[ "$NEEDS_UPGRADE" == "true" ]]; then + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud requires immediate attention" + elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]] && [[ $APP_COUNT -gt 0 ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  Core update to ${CORE_NEW_VERSION} + ${APP_COUNT} app update(s) available" + elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  Nextcloud core update available (${NC_VERSION} → ${CORE_NEW_VERSION})" + elif [[ $APP_COUNT -gt 0 ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  ${APP_COUNT} app update(s) available" + else + STATUS_COLOR="#1a7f4b" + STATUS_LABEL="UP TO DATE" + STATUS_MSG="✅  Nextcloud is fully up to date" + fi + + # --- Maintenance mode color (was previously unset/unused) -- + if [[ "$MAINTENANCE" == "true" ]]; then + MAINTENANCE_COLOR="#b91c1c" + else + MAINTENANCE_COLOR="#1a7f4b" + fi + + # --- Build app update rows -------------------------------- + APP_ROWS="" + if [[ $APP_COUNT -gt 0 ]]; then + for entry in "${UPDATABLE_APPS[@]}"; do + APP_NAME=$(echo "$entry" | cut -d'|' -f1) + NEW_VER=$(echo "$entry" | cut -d'|' -f4) + APP_ROWS+=" + ${APP_NAME} + ${NEW_VER} + " + done + fi + + # --- Build warning rows ----------------------------------- + WARN_HTML="" + for w in "${WARNINGS[@]}"; do + WARN_HTML+="
+ ⚠ ${w} +
" + done + + # --- Build copy-paste command block ----------------------- + # NOTE: must use $'...' (ANSI-C quoting) below, not "...". Plain + # double quotes do NOT interpret \n as a newline in bash — they + # insert the two literal characters \ and n, which is why the + # previous version of this script emailed out commands with + # literal "\n" text instead of real line breaks. + CMD_LINES="" + if [[ "$MAINTENANCE" == "true" ]]; then + CMD_LINES+=$'# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n' + fi + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + CMD_LINES+="# Update Nextcloud CORE to ${CORE_NEW_VERSION} (do this before app updates)"$'\n'"cd ~/docker/nextcloud"$'\n'"docker compose pull"$'\n'"docker compose up -d"$'\n'"sudo docker exec -u www-data nextcloud php occ upgrade --no-interaction"$'\n\n' + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + CMD_LINES+=$'# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n' + fi + if [[ $APP_COUNT -gt 0 ]]; then + CMD_LINES+=$'# Update all apps\nsudo docker exec -u www-data nextcloud php occ app:update --all\n\n' + fi + if [[ -z "$CMD_LINES" ]]; then + CMD_LINES="# No action required — everything is up to date"$'\n' + fi + CMD_LINES+=$'# Verify status after updates\nsudo docker exec -u www-data nextcloud php occ status' + + CMD_BLOCK="
+
📋 Commands to run on NAS08
+
${CMD_LINES}
+
" + + # --- Maintenance/upgrade block ---------------------------- + MAINT_BLOCK="" + if [[ -n "$WARN_HTML" ]]; then + MAINT_BLOCK="
+
⚠ Issues Detected
+ ${WARN_HTML} +
" + fi + + # --- Core update block -------------------------------------- + CORE_BLOCK="" + if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then + CORE_BLOCK="
+
🆙 Core Update Available
+ + + + + + + + + +
Current Version${NC_VERSION}
New Version${CORE_NEW_VERSION}
+
" + fi + + # --- App update table ------------------------------------- + APP_TABLE="" + if [[ $APP_COUNT -gt 0 ]]; then + APP_TABLE="
+
🔄 App Updates Available (${APP_COUNT})
+ + + + + + + + ${APP_ROWS} +
AppNew Version
+
" + else + APP_TABLE="
+ ✓ All apps are up to date +
" + fi + + BODY_HTML="${MAINT_BLOCK} + + +
+ Nextcloud Version + ${NC_VERSION} + Maintenance Mode + ${MAINTENANCE:-false} +
+ + ${CORE_BLOCK} + ${APP_TABLE} + ${CMD_BLOCK}" +fi + +# --- Determine email subject --------------------------------- +if [[ "$NEEDS_ACTION" == "true" ]]; then + SUBJECT="[NAS08] Nextcloud — ${STATUS_LABEL} — Action Required" +else + SUBJECT="[NAS08] Nextcloud — All Up To Date" +fi + +log "Composing email: $SUBJECT" + +# --- Build full HTML email ----------------------------------- +HTML_EMAIL=$(cat < + + + + + +
+ + + + + + + + + + + + + + +
+ + + +
+
Nextcloud · Daily Update Report
+
☁ ${HOSTNAME_LABEL}
+
${TIMESTAMP} | cloud.kingdezigns.com
+
+
${STATUS_LABEL}
+
+
+ ${STATUS_MSG} +
+ ${BODY_HTML} +
+ Nextcloud Update Check  |  ${HOSTNAME_LABEL}  |  KingDezigns Infrastructure +
+
+ + +HTML +) + +# --- Send email via curl/SMTP -------------------------------- +log "Sending email to ${TO_EMAIL}..." + +SEND_RESULT=$(curl --silent --show-error \ + --url "smtps://${SMTP_SERVER}:${SMTP_PORT}" \ + --ssl-reqd \ + --mail-from "${FROM_EMAIL}" \ + --mail-rcpt "${TO_EMAIL}" \ + --user "${SMTP_USER}:${SMTP_PASS}" \ + --upload-file - <&1 +From: ${FROM_NAME} <${FROM_EMAIL}> +To: ${TO_EMAIL} +Subject: ${SUBJECT} +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +${HTML_EMAIL} +EOF +) + +if [[ $? -eq 0 ]]; then + log "Email sent successfully." +else + log "ERROR sending email: ${SEND_RESULT}" +fi + +# --- Rotate old logs ----------------------------------------- +find "$LOG_DIR" -name "update-check-*.log" -mtime +${LOG_RETENTION_DAYS} -delete +log "Done." diff --git a/omv/SSD-fstrim-Summary.html b/omv/SSD-fstrim-Summary.html new file mode 100644 index 0000000..c8d03fe --- /dev/null +++ b/omv/SSD-fstrim-Summary.html @@ -0,0 +1,521 @@ + + + + + +KingDezigns — SSD Hardening Summary + + + + + +
+ +
+

KingDezigns — SSD Hardening (TRIM + noatime) summary

+
+ 📅 2026-05-23 + 🖥 NAS08 & NAS16 + ⚙️ OpenMediaVault + 🔧 Raspberry Pi 5 / Raspberry Pi +
+
+ + + +
+
Objective
+

Harden both NAS08 and NAS16 to extend SSD lifespan by ensuring TRIM is running correctly, noatime is active, and SMART health is being monitored — with all scheduled jobs visible in OMV's scheduler for easy management.

+
+ NAS08 — 5 drives (1x PNY CS900 + 4x Crucial BX500) + NAS16 — 1 drive (unknown model, standard SMART attrs) +
+
+ + + +
+
Investigation steps — both units
+ +
+
1 Verify TRIM support on all drives
+
lsblk --discard /dev/sda
+

Confirm DISC-GRAN and DISC-MAX are non-zero before enabling TRIM.

+

Both units: DISC-GRAN 4K / DISC-MAX 4G — TRIM fully supported on all drives.

+
+ +
+
2 Check if systemd fstrim.timer was already active
+
systemctl status fstrim.timer
+

Determine whether a TRIM schedule already existed before creating a new one.

+

Both units: timer active and enabled, running weekly. NAS08 next run Monday 12:33 AM — NAS16 next run Monday 1:37 AM.

+
+ +
+
3 Confirm which volumes were being trimmed
+
journalctl -u fstrim.service --no-pager
+

Verify the timer was actually running and catching all mounted volumes.

+

Both units: / and /boot/firmware trimmed successfully on May 11 and May 18. NAS08 ~400 GiB freed per run. NAS16 ~228 GiB on first run, 3.3 GiB on second (less free space).

+
+ +
+
4 Check noatime mount status
+
findmnt -t ext4,xfs,btrfs -o TARGET,SOURCE,OPTIONS
+

Confirm noatime was already set on all filesystems to prevent reads generating writes.

+

Both units: every mount point already showing noatime — handled automatically by the openmediavault-flashmemory plugin. No action required.

+
+ +
+
5 Inspect SMART attributes per drive
+
sudo smartctl -A /dev/sda
+sudo smartctl -A /dev/sdb   # NAS08 data drives
+

Identify vendor-specific SMART attribute names before writing the monitoring script — attribute names differ significantly between PNY and Crucial.

+

NAS08 PNY CS900: uses SSD_Life_Left (attr 231), Bad_Blk_Ct_Lat/Erl (attr 170), no pending/uncorrectable attrs. NAS08 Crucial BX500: uses Percent_Lifetime_Remain (attr 202), Reallocate_NAND_Blk_Cnt (attr 5), Reported_Uncorrect. NAS16 drive: uses standard attribute names — no vendor mapping required.

+
+ +
+ + + +
+
What was already handled by OMV
+ +
+

noatime: Already active on all filesystems on both units via the openmediavault-flashmemory plugin. No manual configuration required.

+
+ +
+

Swap disabled, logs in tmpfs: Also handled automatically by the flashmemory plugin — swap off, /var/log and /tmp in RAM, reducing write amplification significantly.

+
+ +
+
⚠ Note
+

The systemd fstrim.timer was already running weekly on both units but was invisible in OMV's scheduler. It was replaced with an OMV Scheduled Job so all jobs are visible and manageable in one place.

+
+
+ + + +
+
TRIM report script — created and deployed
+ +

A custom script was written to replace the bare fstrim call with a full monitoring run that emails an HTML report matching the KingDezigns backup monitor style (dark header, badge/banner, section rows, cards, footer).

+ +

The email report includes: TRIM results per volume with total bytes freed, SSD SMART health per drive (health, temperature, wear, power-on hours, sector counts), filesystem usage with colour-coded warnings at 75% and 90%, and system info (OMV version, kernel, IP, uptime, load, memory, next run time).

+ +
+
+
Script location
+
/usr/scripts/omv/fstrim-report.sh
+
+
+
Log location
+
/var/log/fstrim/fstrim-YYYY-MM-DD.log   (90-day retention)
+
+
+
Deploy (preferred method — sudo nano)
+
sudo nano /usr/scripts/omv/fstrim-report.sh
+# paste script content, save with Ctrl+O, exit with Ctrl+X
+sudo chmod +x /usr/scripts/omv/fstrim-report.sh
+
+
+
Test run
+
sudo systemctl start fstrim.service
+sudo journalctl -u fstrim.service -f
+
+
+
Email delivery
+
sendmail -t   (via OMV's configured SMTP relay)
+
+
+ +
+
⚠ SMART attribute fixes required
+

Initial script used generic attribute names. Two rounds of fixes were needed after testing:

+
Round 1 — Crucial BX500 fix:
+  Reallocated: Reallocated_Sector → Reallocate_NAND_Blk_Cnt
+  Pending:     Current_Pending_Sector → Current_Pending_ECC_Cnt
+  Uncorrect:   Offline_Uncorrectable → Reported_Uncorrect
+  Temperature: $10 → $10+0  (strips "32 (Min/Max 25/56)" format)
+
+Round 2 — PNY CS900 fix:
+  Wear:        Wear_Leveling_Count → SSD_Life_Left
+  Reallocated: Reallocate_NAND_Blk_Cnt → Bad_Blk_Ct_Lat/Erl (first value before /)
+

Each pattern now tries vendor-specific names first, then falls back to generic names — script works correctly on any brand.

+
+
+ + + +
+
Migrating from systemd timer to OMV scheduler
+ +

The systemd fstrim.timer was disabled on both units and replaced with OMV Scheduled Jobs so all scheduled tasks are visible in one place.

+ +
+
1 Disable systemd timer (both units)
+
sudo systemctl disable fstrim.timer
+sudo systemctl stop fstrim.timer
+

Prevent the systemd timer from running alongside the new OMV job — avoids double execution.

+

Timer stopped and disabled on both NAS08 and NAS16.

+
+ +
+
2 Remove systemd override (both units)
+
sudo rm /etc/systemd/system/fstrim.service.d/override.conf
+sudo systemctl daemon-reload
+

Clean up the override that pointed fstrim.service to the script — no longer needed since OMV cron calls the script directly.

+

Override removed, systemd reloaded cleanly on both units.

+
+ +
+
3 Add OMV Scheduled Job (each unit)
+
System → Scheduled Jobs → Add
+Command: /usr/scripts/omv/fstrim-report.sh
+User:    root
+NAS08:   Wednesday at 1:00 AM
+NAS16:   Wednesday at 1:30 AM
+

Wednesday was chosen as it sits midweek, away from Sunday ZFS scrubs and the rolling every-3-day backup jobs.

+

Both units now show TRIM Report in OMV scheduler alongside all other scheduled jobs.

+
+ +
+ + + +
+
Final OMV scheduled jobs — both units
+ +

NAS08

+ + + + + + + + + + +
ScheduleScriptTag
Every 3 days at 2:00 AM/usr/scripts/omv/nas08-backup.shNAS08 Backup
Daily at 12:00 AM/usr/scripts/zfs/nas08_zfs_report.shZFS Status Report
Sunday at 3:00 AM/usr/scripts/zfs/nas08_zfs_scrub.shZFS Scrub
Wednesday at 1:00 AM/usr/scripts/omv/fstrim-report.shTRIM Report
+ +

NAS16

+ + + + + + + + + + + + +
ScheduleScriptTag
Every 3 days at 2:00 AM/usr/scripts/omv/nas16-backup.shNAS16 Backup
Every 2 days at 2:30 AMbash /usr/scripts/nas/nas08_to_nas16_sync.shNAS Drive Backup
Daily at 12:00 AM/usr/scripts/zfs/nas16_zfs_report.shZFS Status Report
Sunday at 3:00 AM/usr/scripts/zfs/nas16_zfs_scrub.shZFS Scrub
Every 15 minutes/usr/scripts/ddns/ddns_update.shDDNS Update
Wednesday at 1:30 AM/usr/scripts/omv/fstrim-report.shTRIM Report
+
+ + + +
+
Lessons learned
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LessonDetail
Always investigate before configuringBoth noatime and fstrim were already handled by OMV's flashmemory plugin — checking first avoided duplicating work.
SMART attribute names are vendor-specificPNY and Crucial use completely different attribute names for wear, reallocated sectors, and temperature format. Always run smartctl -A /dev/sdX first to map the real names.
systemd timers are invisible in OMV schedulerThe default fstrim.timer runs correctly but doesn't appear in OMV's job list — migrate to OMV Scheduled Jobs for full visibility alongside ZFS scrubs and backups.
Never run script without sudo for testingRunning fstrim-report.sh without sudo causes permission denied on /var/log/fstrim/ and returns N/A for all SMART data — always test with sudo or via systemctl start.
Use sudo nano over cp for file editsPreferred method for placing and editing scripts on the NAS — faster navigation to the correct path, avoids confusion about source file location.
Inline TRIM (discard mount option) is not recommendedReal-time per-delete TRIM adds latency under heavy load. Periodic batch TRIM via fstrim is the correct approach for NAS workloads.
PNY CS900 has no pending/uncorrectable attributesN/A for those fields is correct and expected — not a script bug. The drive simply does not expose those attributes.
+
+ + + +
+
Documentation updated
+ + + + + + + + + + + + + + +
FileWhat changed
server_nas08.mdAdded drive inventory table, full SSD Hardening & Maintenance section (TRIM, noatime, flashmemory plugin, SMART attribute mapping), and complete OMV Scheduled Jobs table.
server_nas16.mdAdded drive inventory table with Power_Cycle_Count monitoring note, full SSD Hardening & Maintenance section, and complete OMV Scheduled Jobs table including NAS drive backup sync job.
+
+ + + + +
+ + diff --git a/omv/fstrim-override.conf b/omv/fstrim-override.conf new file mode 100644 index 0000000..ef054b6 --- /dev/null +++ b/omv/fstrim-override.conf @@ -0,0 +1,4 @@ +[Service] +# Clear the default ExecStart then replace with our script +ExecStart= +ExecStart=/usr/scripts/omv/fstrim-report.sh diff --git a/omv/fstrim-report.sh b/omv/fstrim-report.sh new file mode 100644 index 0000000..09c7aa4 --- /dev/null +++ b/omv/fstrim-report.sh @@ -0,0 +1,471 @@ +#!/bin/bash +# ============================================================================= +# NAS08 Weekly TRIM Report Script +# ============================================================================= +# Runs fstrim on all mounted filesystems, captures results, gathers SMART +# health data for each SSD, and sends an HTML email report matching the +# KingDezigns backup monitor style. +# +# Triggered by: systemd fstrim.service (via drop-in override) +# Schedule: Weekly (Monday ~12:33 AM — managed by fstrim.timer) +# +# Script location: /usr/scripts/omv/fstrim-report.sh +# Log location: /var/log/fstrim/ +# ============================================================================= + +set -uo pipefail + +# ----------------------------------------------------------------------------- +# CONFIGURATION +# ----------------------------------------------------------------------------- +HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS08") +REPORT_TO="rufus.king@kingdezigns.com" +REPORT_FROM="trim-monitor@nas08.local" # Display only — OMV uses your configured SMTP relay + +LOG_DIR="/var/log/fstrim" +KEEP_LOGS=90 # days to retain log files + +DATE=$(date +%Y-%m-%d) +REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") +LOG_FILE="${LOG_DIR}/fstrim-${DATE}.log" + +# ----------------------------------------------------------------------------- +# STATUS TRACKING +# OVERALL_STATUS: SUCCESS | WARNING | FAILED +# ----------------------------------------------------------------------------- +OVERALL_STATUS="SUCCESS" +SECTION_ROWS_HTML="" +WARNING_COUNT=0 +ERROR_COUNT=0 + +# Append a result row to the section table +# Usage: add_section_row "Section name" "STATUS" "Detail message" +# STATUS: OK | WARNING | ERROR | SKIPPED +add_section_row() { + local section="$1" status="$2" detail="$3" + + case "$status" in + OK) + BADGE="✓ OK" + ;; + WARNING) + BADGE="⚠ WARNING" + WARNING_COUNT=$((WARNING_COUNT + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + ;; + ERROR) + BADGE="✗ ERROR" + ERROR_COUNT=$((ERROR_COUNT + 1)) + OVERALL_STATUS="FAILED" + ;; + SKIPPED) + BADGE="— SKIPPED" + ;; + esac + + SECTION_ROWS_HTML+=" + + $section + $BADGE + $detail + " +} + +# ----------------------------------------------------------------------------- +# LOGGING +# ----------------------------------------------------------------------------- +mkdir -p "$LOG_DIR" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +log_section() { + echo "" | tee -a "$LOG_FILE" + echo "------------------------------------------------------------" | tee -a "$LOG_FILE" + log "$1" + echo "------------------------------------------------------------" | tee -a "$LOG_FILE" +} + +# ----------------------------------------------------------------------------- +# SECTION 1: RUN FSTRIM +# ----------------------------------------------------------------------------- +log_section "Running fstrim on all mounted filesystems" + +TRIM_RAW=$(fstrim -av 2>&1) +TRIM_EXIT=$? + +TRIM_ROWS_HTML="" +TOTAL_BYTES=0 +VOLUME_COUNT=0 + +while IFS= read -r line; do + if [[ "$line" =~ ^(/.+):[[:space:]]+(.+)[[:space:]]+\(([0-9]+)[[:space:]]bytes\) ]]; then + MOUNT="${BASH_REMATCH[1]}" + HUMAN="${BASH_REMATCH[2]}" + BYTES="${BASH_REMATCH[3]}" + TOTAL_BYTES=$((TOTAL_BYTES + BYTES)) + VOLUME_COUNT=$((VOLUME_COUNT + 1)) + log "Trimmed ${MOUNT}: ${HUMAN}" + TRIM_ROWS_HTML+=" + + $MOUNT + $HUMAN freed + " + fi +done <<< "$TRIM_RAW" + +# Convert total bytes to human readable +if [ $TOTAL_BYTES -ge 1073741824 ]; then + TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1f GiB\", $TOTAL_BYTES/1073741824}") +elif [ $TOTAL_BYTES -ge 1048576 ]; then + TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1f MiB\", $TOTAL_BYTES/1048576}") +else + TOTAL_HUMAN="${TOTAL_BYTES} bytes" +fi + +if [ $TRIM_EXIT -eq 0 ]; then + log "fstrim completed — $VOLUME_COUNT volume(s) · $TOTAL_HUMAN total freed" + add_section_row "TRIM Execution" "OK" "$VOLUME_COUNT volume(s) processed · $TOTAL_HUMAN total freed" +else + log "ERROR: fstrim exited with code $TRIM_EXIT" + add_section_row "TRIM Execution" "ERROR" "fstrim exited with code $TRIM_EXIT" +fi + +# ----------------------------------------------------------------------------- +# SECTION 2: SSD SMART HEALTH +# ----------------------------------------------------------------------------- +log_section "Gathering SSD SMART health data" + +SMART_ROWS_HTML="" +SMART_WARN=0 +SMART_ERR=0 + +for DISK in $(lsblk -dpno NAME | grep -E '^/dev/sd|^/dev/nvme'); do + MODEL=$(smartctl -i "$DISK" 2>/dev/null | awk -F': ' '/Device Model|Model Number/{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}') + SERIAL=$(smartctl -i "$DISK" 2>/dev/null | awk -F': ' '/Serial Number/{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}') + CAPACITY=$(lsblk -dno SIZE "$DISK" 2>/dev/null) + HEALTH=$(smartctl -H "$DISK" 2>/dev/null | awk '/overall-health|test result/{print $NF}') + # Temperature: +0 forces numeric conversion, handling "32 (Min/Max 25/56)" Crucial format + TEMP=$(smartctl -A "$DISK" 2>/dev/null | awk '/Temperature_Celsius|Temperature_Internal/{print $10+0; exit}') + POWER_ON=$(smartctl -A "$DISK" 2>/dev/null | awk '/Power_On_Hours/{print $10; exit}') + # Wear: PNY=SSD_Life_Left, Crucial=Percent_Lifetime_Remain, Samsung/WD=Wear_Leveling_Count, Intel=Media_Wearout_Indicator + WEAR=$(smartctl -A "$DISK" 2>/dev/null | awk '/SSD_Life_Left|Percent_Lifetime_Remain|Wear_Leveling_Count|Media_Wearout_Indicator/{print $10; exit}') + # Reallocated: PNY=Bad_Blk_Ct_Lat/Erl (RAW_VALUE is "0/88" — take first number), Crucial=Reallocate_NAND_Blk_Cnt, others=Reallocated_Sector_Ct + REALLOCATED=$(smartctl -A "$DISK" 2>/dev/null | awk '/Bad_Blk_Ct_Lat\/Erl/{split($10,a,"/"); print a[1]; exit} /Reallocate_NAND_Blk_Cnt|Reallocated_Sector_Ct|Reallocated_Sector/{print $10; exit}') + # Pending: Crucial=Current_Pending_ECC_Cnt, others=Current_Pending_Sector (PNY has no equivalent — will show N/A) + PENDING=$(smartctl -A "$DISK" 2>/dev/null | awk '/Current_Pending_ECC_Cnt|Current_Pending_Sector/{print $10; exit}') + # Uncorrectable: Crucial=Reported_Uncorrect, others=Offline_Uncorrectable (PNY has no equivalent — will show N/A) + UNCORRECTABLE=$(smartctl -A "$DISK" 2>/dev/null | awk '/Reported_Uncorrect|Offline_Uncorrectable/{print $10; exit}') + + [ -z "$MODEL" ] && MODEL="Unknown" + [ -z "$SERIAL" ] && SERIAL="Unknown" + [ -z "$HEALTH" ] && HEALTH="Unknown" + [ -z "$TEMP" ] && TEMP="N/A" + [ -z "$POWER_ON" ] && POWER_ON="N/A" + [ -z "$WEAR" ] && WEAR="N/A" + [ -z "$REALLOCATED" ] && REALLOCATED="N/A" + [ -z "$PENDING" ] && PENDING="N/A" + [ -z "$UNCORRECTABLE" ] && UNCORRECTABLE="N/A" + + # Health colour + if [ "$HEALTH" = "PASSED" ]; then + HEALTH_COLOUR="#065f46" + HEALTH_BG="#d1fae5" + DISK_STATUS="OK" + else + HEALTH_COLOUR="#991b1b" + HEALTH_BG="#fee2e2" + DISK_STATUS="WARNING" + SMART_WARN=$((SMART_WARN + 1)) + fi + + # Flag non-zero reallocated/pending/uncorrectable sectors + SECTOR_NOTE="" + if [ "$REALLOCATED" != "N/A" ] && [ "$REALLOCATED" -gt 0 ] 2>/dev/null; then + SECTOR_NOTE+=" ⚠ Reallocated: $REALLOCATED" + SMART_WARN=$((SMART_WARN + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + fi + if [ "$PENDING" != "N/A" ] && [ "$PENDING" -gt 0 ] 2>/dev/null; then + SECTOR_NOTE+=" ⚠ Pending: $PENDING" + SMART_WARN=$((SMART_WARN + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + fi + if [ "$UNCORRECTABLE" != "N/A" ] && [ "$UNCORRECTABLE" -gt 0 ] 2>/dev/null; then + SECTOR_NOTE+=" 🚨 Uncorrectable: $UNCORRECTABLE" + SMART_ERR=$((SMART_ERR + 1)) + OVERALL_STATUS="FAILED" + fi + + log "Drive $DISK ($MODEL) — Health: $HEALTH | Temp: ${TEMP}°C | Wear: $WEAR | Power-on: ${POWER_ON}h${SECTOR_NOTE}" + + SMART_ROWS_HTML+=" + + + $DISK  $MODEL  ·  $CAPACITY  ·  S/N: $SERIAL + + + + Overall health + $HEALTH${SECTOR_NOTE:+$SECTOR_NOTE} + + + Temperature + ${TEMP}°C + + + Power-on hours + ${POWER_ON} hrs + + + Wear leveling + $WEAR + + + Sectors (realloc / pending / uncorrectable) + $REALLOCATED  /  $PENDING  /  $UNCORRECTABLE + " +done + +if [ $SMART_ERR -gt 0 ]; then + add_section_row "SSD SMART Health" "ERROR" "$SMART_ERR drive(s) with uncorrectable sectors — immediate attention required" +elif [ $SMART_WARN -gt 0 ]; then + add_section_row "SSD SMART Health" "WARNING" "$SMART_WARN warning(s) — review drive details below" +else + add_section_row "SSD SMART Health" "OK" "All drives PASSED · no sector errors detected" +fi + +# ----------------------------------------------------------------------------- +# SECTION 3: FILESYSTEM USAGE +# ----------------------------------------------------------------------------- +log_section "Filesystem usage" + +FS_ROWS_HTML="" +FS_WARN=0 + +while IFS= read -r line; do + USE_PCT=$(echo "$line" | awk '{print $5}' | tr -d '%') + MOUNT_PT=$(echo "$line" | awk '{print $6}') + USED=$(echo "$line" | awk '{print $3}') + SIZE=$(echo "$line" | awk '{print $2}') + + if [ "$USE_PCT" -ge 90 ] 2>/dev/null; then + ROW_COLOUR="#fee2e2"; PCT_COLOUR="#991b1b" + FS_WARN=$((FS_WARN + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + elif [ "$USE_PCT" -ge 75 ] 2>/dev/null; then + ROW_COLOUR="#fef3c7"; PCT_COLOUR="#92400e" + FS_WARN=$((FS_WARN + 1)) + [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" + else + ROW_COLOUR="transparent"; PCT_COLOUR="#065f46" + fi + + log "Filesystem $MOUNT_PT: ${USED} used of ${SIZE} (${USE_PCT}%)" + + FS_ROWS_HTML+=" + + $MOUNT_PT + $USED used of $SIZE + ${USE_PCT}% + " +done < <(df -h | grep -E '^/dev/') + +if [ $FS_WARN -gt 0 ]; then + add_section_row "Filesystem Usage" "WARNING" "$FS_WARN volume(s) above 75% — review recommended" +else + add_section_row "Filesystem Usage" "OK" "All volumes within normal range" +fi + +# ----------------------------------------------------------------------------- +# ROTATE OLD LOGS +# ----------------------------------------------------------------------------- +find "$LOG_DIR" -name "fstrim-*.log" -mtime +${KEEP_LOGS} -delete + +# ----------------------------------------------------------------------------- +# SYSTEM CONTEXT +# ----------------------------------------------------------------------------- +OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown") +KERNEL_VER=$(uname -r) +HOST_IP=$(hostname -I | awk '{print $1}') +UPTIME_STR=$(uptime -p) +LOAD_STR=$(uptime | awk -F'load average:' '{print $2}' | xargs) +MEM_USED=$(free -h | awk '/^Mem:/{print $3}') +MEM_TOTAL=$(free -h | awk '/^Mem:/{print $2}') +NEXT_RUN=$(systemctl show fstrim.timer --property=NextElapseUSecRealtime 2>/dev/null \ + | awk -F= '{print $2}' || echo "See: systemctl status fstrim.timer") + +log_section "Report complete — Overall: $OVERALL_STATUS" + +# ----------------------------------------------------------------------------- +# BUILD HTML EMAIL (style: KingDezigns monitor) +# ----------------------------------------------------------------------------- + +# Overall banner & badge +case "$OVERALL_STATUS" in + SUCCESS) + BADGE_HTML="
✅ SUCCESS
" + BANNER_HTML="✅  TRIM completed successfully — no action required." + ;; + WARNING) + BADGE_HTML="
⚠️ WARNING
" + BANNER_HTML="⚠️  TRIM completed with warnings — review recommended." + ;; + FAILED) + BADGE_HTML="
🚨 FAILED
" + BANNER_HTML="🚨  TRIM run failed — immediate attention required!" + ;; +esac + +HTML_BODY=$(cat < + + + + + +
+ + + + + + + $BANNER_HTML + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
SSD Maintenance Monitor
+
💽 $HOSTNAME_LABEL
+
$REPORT_TIME  |  Weekly TRIM · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)
+
$BADGE_HTML
+
+
+
+ 📋 Run Summary +
+ + + + + + + $SECTION_ROWS_HTML +
SectionStatusDetail
+
+
+
+
✂️ TRIM Results — $TOTAL_HUMAN Total Freed
+ + + + + + $TRIM_ROWS_HTML +
Mount pointSpace freed
+
+
+
+
🔬 SSD SMART Health
+ + $SMART_ROWS_HTML +
+
+
+
+
💾 Filesystem Usage
+ + + + + + + $FS_ROWS_HTML +
Mount pointUsageUsed %
+
+
+
+
🖥️ System Info
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OMV${OMV_VER}Kernel${KERNEL_VER}
IP Address${HOST_IP}Uptime${UPTIME_STR}
Load average${LOAD_STR}Memory${MEM_USED} / ${MEM_TOTAL}
Log file${LOG_FILE}
Next TRIM run${NEXT_RUN}
+
+
+ SSD Maintenance Monitor  |  $HOSTNAME_LABEL  |  KingDezigns Infrastructure +
+
+ + +EOF +) + +# ----------------------------------------------------------------------------- +# SEND EMAIL +# ----------------------------------------------------------------------------- +SUBJECT="[TRIM] $HOSTNAME_LABEL — $OVERALL_STATUS | $REPORT_TIME" + +{ + echo "To: $REPORT_TO" + echo "From: $REPORT_FROM" + echo "Subject: $SUBJECT" + echo "MIME-Version: 1.0" + echo "Content-Type: text/html; charset=UTF-8" + echo "" + echo "$HTML_BODY" +} | sendmail -t + +log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" +log "===== fstrim-report.sh finished =====" diff --git a/scripts/nextcloud_update_check.sh b/scripts/nextcloud_update_check.sh new file mode 100644 index 0000000..53d18de --- /dev/null +++ b/scripts/nextcloud_update_check.sh @@ -0,0 +1,288 @@ +#!/bin/bash +# ============================================================= +# nextcloud_update_check.sh +# Checks for Nextcloud core and app updates daily. +# Sends an HTML email report with update commands ready to +# copy-paste into terminal. +# +# Schedule: Daily (recommended 7:00 AM) +# Install: /usr/scripts/omv/nextcloud_update_check.sh +# Log: /var/log/nextcloud-updates/ +# ============================================================= + +# --- Config -------------------------------------------------- +CONTAINER="nextcloud" +OCC="sudo docker exec -u www-data ${CONTAINER} php occ" +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT="465" +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS_FILE="/etc/nextcloud-smtp-pass" +FROM_NAME="NAS08" +FROM_EMAIL="rufus.king@kingdezigns.com" +TO_EMAIL="rufus.king@kingdezigns.com" +HOSTNAME_LABEL="NAS08" +LOG_DIR="/var/log/nextcloud-updates" +LOG_RETENTION_DAYS=90 +# ------------------------------------------------------------- + +mkdir -p "$LOG_DIR" +DATESTAMP=$(date +"%Y%m%d") +TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S") +LOGFILE="$LOG_DIR/update-check-${DATESTAMP}.log" + +log() { echo "[$(date +"%H:%M:%S")] $*" | tee -a "$LOGFILE"; } + +# --- Load SMTP password -------------------------------------- +if [[ ! -f "$SMTP_PASS_FILE" ]]; then + log "ERROR: SMTP password file not found at $SMTP_PASS_FILE" + exit 1 +fi +SMTP_PASS=$(cat "$SMTP_PASS_FILE") + +# --- Check container is running ------------------------------ +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + log "ERROR: Container '${CONTAINER}' is not running." + # Send critical alert + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud container is not running" + BODY_HTML="

The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.

" + NEEDS_ACTION=true + APP_ROWS="" + CORE_ROW="" + CMD_BLOCK="" +else + # --- Get Nextcloud status ---------------------------------- + log "Checking Nextcloud status..." + NC_STATUS=$($OCC status 2>/dev/null) + MAINTENANCE=$(echo "$NC_STATUS" | grep "maintenance:" | awk '{print $3}') + NEEDS_UPGRADE=$(echo "$NC_STATUS" | grep "needsDbUpgrade:" | awk '{print $3}') + NC_VERSION=$(echo "$NC_STATUS" | grep "versionstring:" | awk '{print $3}') + + log "Nextcloud version: $NC_VERSION | maintenance: $MAINTENANCE | needsDbUpgrade: $NEEDS_UPGRADE" + + # --- Check for app updates -------------------------------- + log "Checking app updates..." + APP_UPDATE_RAW=$($OCC app:update --all --dry-run 2>/dev/null) + # Fall back if --dry-run not supported (older NC versions) + if echo "$APP_UPDATE_RAW" | grep -q "Unknown option"; then + log "dry-run not supported, using app:list to detect updates..." + APP_UPDATE_RAW=$($OCC app:list 2>/dev/null | grep -E "\(installed") + fi + + # Parse apps with available updates + # Format from occ app:update --dry-run: "appname new version available: X.Y.Z" + UPDATABLE_APPS=() + while IFS= read -r line; do + if echo "$line" | grep -q "new version available"; then + APP_NAME=$(echo "$line" | awk '{print $1}') + NEW_VER=$(echo "$line" | awk '{print $NF}') + UPDATABLE_APPS+=("${APP_NAME}|||${NEW_VER}") + log "Update available: $APP_NAME → $NEW_VER" + fi + done <<< "$APP_UPDATE_RAW" + + APP_COUNT=${#UPDATABLE_APPS[@]} + + # --- Build status ----------------------------------------- + NEEDS_ACTION=false + WARNINGS=() + + if [[ "$MAINTENANCE" == "true" ]]; then + WARNINGS+=("Nextcloud is currently in maintenance mode") + NEEDS_ACTION=true + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + WARNINGS+=("Database upgrade required (needsDbUpgrade: true)") + NEEDS_ACTION=true + fi + if [[ $APP_COUNT -gt 0 ]]; then + NEEDS_ACTION=true + fi + + # --- Determine overall status color ----------------------- + if [[ "$MAINTENANCE" == "true" ]] || [[ "$NEEDS_UPGRADE" == "true" ]]; then + STATUS_COLOR="#b91c1c" + STATUS_LABEL="CRITICAL" + STATUS_MSG="⛔  Nextcloud requires immediate attention" + elif [[ $APP_COUNT -gt 0 ]]; then + STATUS_COLOR="#b45309" + STATUS_LABEL="UPDATES AVAILABLE" + STATUS_MSG="🔄  ${APP_COUNT} app update(s) available" + else + STATUS_COLOR="#1a7f4b" + STATUS_LABEL="UP TO DATE" + STATUS_MSG="✅  Nextcloud is fully up to date" + fi + + # --- Build app update rows -------------------------------- + APP_ROWS="" + if [[ $APP_COUNT -gt 0 ]]; then + for entry in "${UPDATABLE_APPS[@]}"; do + APP_NAME=$(echo "$entry" | cut -d'|' -f1) + NEW_VER=$(echo "$entry" | cut -d'|' -f4) + APP_ROWS+=" + ${APP_NAME} + ${NEW_VER} + " + done + fi + + # --- Build warning rows ----------------------------------- + WARN_HTML="" + for w in "${WARNINGS[@]}"; do + WARN_HTML+="
+ ⚠ ${w} +
" + done + + # --- Build copy-paste command block ----------------------- + CMD_LINES="" + if [[ "$MAINTENANCE" == "true" ]]; then + CMD_LINES+="# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n" + fi + if [[ "$NEEDS_UPGRADE" == "true" ]]; then + CMD_LINES+="# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n" + fi + if [[ $APP_COUNT -gt 0 ]]; then + CMD_LINES+="# Update all apps\nsudo docker exec -u www-data nextcloud php occ app:update --all\n\n" + fi + if [[ -z "$CMD_LINES" ]]; then + CMD_LINES="# No action required — everything is up to date" + fi + CMD_LINES+="# Verify status after updates\nsudo docker exec -u www-data nextcloud php occ status" + + CMD_BLOCK="
+
📋 Commands to run on NAS08
+
${CMD_LINES}
+
" + + # --- Maintenance/upgrade block ---------------------------- + MAINT_BLOCK="" + if [[ -n "$WARN_HTML" ]]; then + MAINT_BLOCK="
+
⚠ Issues Detected
+ ${WARN_HTML} +
" + fi + + # --- App update table ------------------------------------- + APP_TABLE="" + if [[ $APP_COUNT -gt 0 ]]; then + APP_TABLE="
+
🔄 App Updates Available (${APP_COUNT})
+ + + + + + + + ${APP_ROWS} +
AppNew Version
+
" + else + APP_TABLE="
+ ✓ All apps are up to date +
" + fi + + BODY_HTML="${MAINT_BLOCK} + + +
+ Nextcloud Version + ${NC_VERSION} + Maintenance Mode + ${MAINTENANCE:-false} +
+ + ${APP_TABLE} + ${CMD_BLOCK}" +fi + +# --- Determine email subject --------------------------------- +if [[ "$NEEDS_ACTION" == "true" ]]; then + SUBJECT="[NAS08] Nextcloud — ${STATUS_LABEL} — Action Required" +else + SUBJECT="[NAS08] Nextcloud — All Up To Date" +fi + +log "Composing email: $SUBJECT" + +# --- Build full HTML email ----------------------------------- +HTML_EMAIL=$(cat < + + + + + +
+ + + + + + + + + + + + + + +
+ + + +
+
Nextcloud · Daily Update Report
+
☁ ${HOSTNAME_LABEL}
+
${TIMESTAMP} | cloud.kingdezigns.com
+
+
${STATUS_LABEL}
+
+
+ ${STATUS_MSG} +
+ ${BODY_HTML} +
+ Nextcloud Update Check  |  ${HOSTNAME_LABEL}  |  KingDezigns Infrastructure +
+
+ + +HTML +) + +# --- Send email via curl/SMTP -------------------------------- +log "Sending email to ${TO_EMAIL}..." + +SEND_RESULT=$(curl --silent --show-error \ + --url "smtps://${SMTP_SERVER}:${SMTP_PORT}" \ + --ssl-reqd \ + --mail-from "${FROM_EMAIL}" \ + --mail-rcpt "${TO_EMAIL}" \ + --user "${SMTP_USER}:${SMTP_PASS}" \ + --upload-file - <&1 +From: ${FROM_NAME} <${FROM_EMAIL}> +To: ${TO_EMAIL} +Subject: ${SUBJECT} +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +${HTML_EMAIL} +EOF +) + +if [[ $? -eq 0 ]]; then + log "Email sent successfully." +else + log "ERROR sending email: ${SEND_RESULT}" +fi + +# --- Rotate old logs ----------------------------------------- +find "$LOG_DIR" -name "update-check-*.log" -mtime +${LOG_RETENTION_DAYS} -delete +log "Done." diff --git a/zfs/NAS08-zfs_pools.html b/zfs/NAS08-zfs_pools.html new file mode 100644 index 0000000..f067f01 --- /dev/null +++ b/zfs/NAS08-zfs_pools.html @@ -0,0 +1,506 @@ + + + + + +KingDezigns — NAS08 ZFS Pool Maintenance Scripts + + + + + +
+ +
+

KingDezigns — NAS08 ZFS Pool Maintenance Scripts

+
+ 📅 2026-05-14 + 🖥 NAS08 / Raspberry Pi 5 + ⚙️ OpenMediaVault (OMV) + 🐳 Docker Compose + 💾 ZFS RAIDZ2 — Penta SATA HAT +
+
+ + + +
+
The goal
+ +

NAS08 serves as a Pi-Hole DNS server, nginx proxy server, Vaultwarden Server, Plex server, as well as an OpenMediaVault Nas Server

+ +
+ ZFS Scrubbing + ZFS Reporting +
+ +

The goal is to create regular maintenance to the ZFS pools for this NAS. This is done to protect and keep the pools healthy.

+ +
+
⚠ Critical — store this document off NAS16
+

Keep a copy of this document on your workstation, a USB drive, or in print. If NAS08 is down you cannot read files stored on it.

+
+
+ + +
+
Script to Create ZFS report
+ +
+
1 Create the folders on NAS08
+

This script gets the current ZFS pool status and reports it back to the administrator via email. Use these commands to setup the scripts on the NAS.

+
ssh rufusking@192.168.150.35
+sudo mkdir /usr/scripts/zfs
+sudo nano nas08_zfs_report.sh
+
+ +
+
2 Copy the content of the entire nas08_zfs_report.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes
+
nas08_zfs_report.sh
+
+ +
+
3 Make the script executable — NAS08
+
chmod +x /usr/scripts/zfs/nas08_zfs_report.sh
+
+ +
+
4 Run a test report — NAS08
+
sudo /usr/scripts/zfs/nas08_zfs_report.sh
+

Watch for the report in your inbox

+
+
+ + +
+
Script to perform a ZFS pool scrub
+ +
+
1 Create the folders on NAS08
+

This script initiates a ZFS pool scrub and reports back the status to the administrator via email. Use these commands to setup this script on the NAS.

+
ssh rufusking@192.168.150.35
+sudo mkdir /usr/scripts/zfs
+sudo nano nas08_zfs_scrub.sh
+
+ +
+
2 Copy the content of the entire nas08_zfs_scrub.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes
+
nas08_zfs_scrub.sh
+
+ +
+
3 Make the script executable — NAS08
+
chmod +x /usr/scripts/zfs/nas08_zfs_scrub.sh
+
+ +
+
4 Run a test report — NAS08
+
sudo /usr/scripts/zfs/nas08_zfs_report.sh
+

Watch for the report in your inbox

+
+
+ + + +
+
Key differences from NAS16
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaNAS08NAS16
Primary roleDocker containers (Pi-hole, Plex, Vaultwarden, Nextcloud, NPM)Web server (Apache/PHP), databases (MariaDB), Webmin admin
What's backed up beyond /etc + OMVDocker Compose files, Pi-hole data, Plex config/metadataApache vhosts, PHP config, MariaDB dumps, Webmin config
Website filesN/AIntentionally excluded — stored on NAS drives, covered by redundancy
Database backupNone (no databases)Full mysqldump of all user databases, .sql.gz per database
Backup destination/export/kingdezigns-all/Backups/NAS08//export/kingdezignsnas-16/Backups/NAS16/
ZFS pool namekingdezignsnaskingdezignsnas-16 (assumed — verify with sudo zpool list)
Recovery phases9 phases, 34 steps10 phases, 40 steps
+
+ + + +
+
Notes & assumptions to verify
+ +
+
⚠ Verify these before first run
+

The script was written based on information provided and follows Raspberry Pi OS / Debian conventions. Verify the following on NAS16 before treating any backup as production-ready.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAssumed valueHow to verify
ZFS pool namekingdezignsnassudo zpool list
Backup destination path/export/kingdezigns08/Backups/NAS08/ls /export/ — confirm share name matches
MariaDB auth methodUses /etc/mysql/debian.cnf (maintenance account, no password when run as root)Run: sudo mysql --defaults-file=/etc/mysql/debian.cnf -e "SHOW DATABASES;" — should work without password
PHP version(s)Auto-detected from /etc/php/*/php -v and ls /etc/php/
Apache config location/etc/apache2/ (standard Debian)apache2 -V — confirms config path
Webmin config location/etc/webmin/ls /etc/webmin/
+
+ + + +
+
Network map — items to update
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemOld valueNew value
NAS16 — Backup statusNoneAutomated — every 3 days — 30-day retention
NAS16 — Backup destinationNone/export/kingdezignsnas-16/Backups/NAS16/
NAS16 — Backup scriptNone/usr/scripts/omv/nas16-backup.sh
NAS16 — PCIe requirementnot documenteddtparam=pciex1 + dtparam=pciex1_gen=3 required in /boot/firmware/config.txt on fresh OS
NAS16 — Web stacknot documentedApache · PHP · MariaDB · Webmin · Adminer
NAS16 — Recovery planNoneNAS16-Backup-Summary.html — 40 steps across 10 phases + ZFS troubleshooting
+
+ + + + +
+ + diff --git a/zfs/NAS08-zfs_pools.html~ b/zfs/NAS08-zfs_pools.html~ new file mode 100644 index 0000000..f067f01 --- /dev/null +++ b/zfs/NAS08-zfs_pools.html~ @@ -0,0 +1,506 @@ + + + + + +KingDezigns — NAS08 ZFS Pool Maintenance Scripts + + + + + +
+ +
+

KingDezigns — NAS08 ZFS Pool Maintenance Scripts

+
+ 📅 2026-05-14 + 🖥 NAS08 / Raspberry Pi 5 + ⚙️ OpenMediaVault (OMV) + 🐳 Docker Compose + 💾 ZFS RAIDZ2 — Penta SATA HAT +
+
+ + + +
+
The goal
+ +

NAS08 serves as a Pi-Hole DNS server, nginx proxy server, Vaultwarden Server, Plex server, as well as an OpenMediaVault Nas Server

+ +
+ ZFS Scrubbing + ZFS Reporting +
+ +

The goal is to create regular maintenance to the ZFS pools for this NAS. This is done to protect and keep the pools healthy.

+ +
+
⚠ Critical — store this document off NAS16
+

Keep a copy of this document on your workstation, a USB drive, or in print. If NAS08 is down you cannot read files stored on it.

+
+
+ + +
+
Script to Create ZFS report
+ +
+
1 Create the folders on NAS08
+

This script gets the current ZFS pool status and reports it back to the administrator via email. Use these commands to setup the scripts on the NAS.

+
ssh rufusking@192.168.150.35
+sudo mkdir /usr/scripts/zfs
+sudo nano nas08_zfs_report.sh
+
+ +
+
2 Copy the content of the entire nas08_zfs_report.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes
+
nas08_zfs_report.sh
+
+ +
+
3 Make the script executable — NAS08
+
chmod +x /usr/scripts/zfs/nas08_zfs_report.sh
+
+ +
+
4 Run a test report — NAS08
+
sudo /usr/scripts/zfs/nas08_zfs_report.sh
+

Watch for the report in your inbox

+
+
+ + +
+
Script to perform a ZFS pool scrub
+ +
+
1 Create the folders on NAS08
+

This script initiates a ZFS pool scrub and reports back the status to the administrator via email. Use these commands to setup this script on the NAS.

+
ssh rufusking@192.168.150.35
+sudo mkdir /usr/scripts/zfs
+sudo nano nas08_zfs_scrub.sh
+
+ +
+
2 Copy the content of the entire nas08_zfs_scrub.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes
+
nas08_zfs_scrub.sh
+
+ +
+
3 Make the script executable — NAS08
+
chmod +x /usr/scripts/zfs/nas08_zfs_scrub.sh
+
+ +
+
4 Run a test report — NAS08
+
sudo /usr/scripts/zfs/nas08_zfs_report.sh
+

Watch for the report in your inbox

+
+
+ + + +
+
Key differences from NAS16
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaNAS08NAS16
Primary roleDocker containers (Pi-hole, Plex, Vaultwarden, Nextcloud, NPM)Web server (Apache/PHP), databases (MariaDB), Webmin admin
What's backed up beyond /etc + OMVDocker Compose files, Pi-hole data, Plex config/metadataApache vhosts, PHP config, MariaDB dumps, Webmin config
Website filesN/AIntentionally excluded — stored on NAS drives, covered by redundancy
Database backupNone (no databases)Full mysqldump of all user databases, .sql.gz per database
Backup destination/export/kingdezigns-all/Backups/NAS08//export/kingdezignsnas-16/Backups/NAS16/
ZFS pool namekingdezignsnaskingdezignsnas-16 (assumed — verify with sudo zpool list)
Recovery phases9 phases, 34 steps10 phases, 40 steps
+
+ + + +
+
Notes & assumptions to verify
+ +
+
⚠ Verify these before first run
+

The script was written based on information provided and follows Raspberry Pi OS / Debian conventions. Verify the following on NAS16 before treating any backup as production-ready.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemAssumed valueHow to verify
ZFS pool namekingdezignsnassudo zpool list
Backup destination path/export/kingdezigns08/Backups/NAS08/ls /export/ — confirm share name matches
MariaDB auth methodUses /etc/mysql/debian.cnf (maintenance account, no password when run as root)Run: sudo mysql --defaults-file=/etc/mysql/debian.cnf -e "SHOW DATABASES;" — should work without password
PHP version(s)Auto-detected from /etc/php/*/php -v and ls /etc/php/
Apache config location/etc/apache2/ (standard Debian)apache2 -V — confirms config path
Webmin config location/etc/webmin/ls /etc/webmin/
+
+ + + +
+
Network map — items to update
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemOld valueNew value
NAS16 — Backup statusNoneAutomated — every 3 days — 30-day retention
NAS16 — Backup destinationNone/export/kingdezignsnas-16/Backups/NAS16/
NAS16 — Backup scriptNone/usr/scripts/omv/nas16-backup.sh
NAS16 — PCIe requirementnot documenteddtparam=pciex1 + dtparam=pciex1_gen=3 required in /boot/firmware/config.txt on fresh OS
NAS16 — Web stacknot documentedApache · PHP · MariaDB · Webmin · Adminer
NAS16 — Recovery planNoneNAS16-Backup-Summary.html — 40 steps across 10 phases + ZFS troubleshooting
+
+ + + + +
+ + diff --git a/zfs/nas08_zfs_report.sh b/zfs/nas08_zfs_report.sh new file mode 100644 index 0000000..9f757fb --- /dev/null +++ b/zfs/nas08_zfs_report.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# ============================================================================= +# ZFS Pool Health Report — NAS08 +# Sends an HTML status email to rufus.king@kingdezigns.com +# Cron example: 0 7 * * * /opt/scripts/zfs_report_nas08.sh +# +# Commands executed per pool: +# zpool status -v — full status + verbose per-file error listing +# zpool list — pool-level size / capacity / health +# zfs list -r — per-dataset breakdown (used, avail, refer, mount) +# ============================================================================= + +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────────────────── +TARGET_HOST="NAS08" +REPORT_TO="rufus.king@kingdezigns.com" +REPORT_FROM="zfs-monitor@${TARGET_HOST,,}.local" +SCRUB_MAX_AGE_DAYS=8 # Warn if last scrub is older than this +CAP_WARN=80 # Capacity % that triggers WARNING +CAP_CRIT=90 # Capacity % that triggers CRITICAL +# ────────────────────────────────────────────────────────────────────────────── + +NOW=$(date '+%Y-%m-%d %H:%M:%S') +HOSTNAME_REAL=$(hostname -s 2>/dev/null || echo "$TARGET_HOST") + +# ── HTML escape helper ──────────────────────────────────────────────────────── +html_escape() { sed 's/&/\&/g; s//\>/g; s/"/\"/g'; } + +# ── Collect pool list ───────────────────────────────────────────────────────── +POOL_LIST=$(zpool list -H -o name 2>/dev/null) || { echo "ERROR: zpool not found"; exit 1; } + +OVERALL_STATUS="HEALTHY" +POOL_BLOCKS="" +POOL_COUNT=0 +PROBLEM_COUNT=0 + +# ── Per-pool loop ───────────────────────────────────────────────────────────── +while IFS= read -r POOL; do + [[ -z "$POOL" ]] && continue + POOL_COUNT=$((POOL_COUNT + 1)) + + # 1. zpool status -v ── verbose: includes per-file permanent error listing + STATUS_RAW=$(zpool status -v "$POOL" 2>&1) + + # 2. zpool list ── raw (-p) for math, human for display + ZPOOL_RAW=$(zpool list -H -p -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1) + ZPOOL_HUM=$(zpool list -H -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1) + + HEALTH=$(echo "$ZPOOL_RAW" | awk '{print $7}') + CAP_RAW=$(echo "$ZPOOL_RAW" | awk '{print $6}') + + SIZE_H=$(echo "$ZPOOL_HUM" | awk '{print $2}') + ALLOC_H=$(echo "$ZPOOL_HUM" | awk '{print $3}') + FREE_H=$(echo "$ZPOOL_HUM" | awk '{print $4}') + FRAG_H=$(echo "$ZPOOL_HUM" | awk '{print $5}') + CAP_H=$(echo "$ZPOOL_HUM" | awk '{print $6}') + + # 3. zfs list -r ── per-dataset: name, used, avail, refer, mountpoint + ZFS_LIST_RAW=$(zfs list -r -H -o name,used,avail,refer,mountpoint "$POOL" 2>&1) + + # Build dataset table rows + DATASET_ROWS="" + DATASET_COUNT=0 + while IFS=$'\t' read -r DS_NAME DS_USED DS_AVAIL DS_REFER DS_MOUNT; do + [[ -z "$DS_NAME" ]] && continue + DATASET_COUNT=$((DATASET_COUNT + 1)) + (( DATASET_COUNT % 2 == 0 )) && ROW_SHADE="#f9fafb" || ROW_SHADE="white" + DS_NAME_ESC=$(echo "$DS_NAME" | html_escape) + DS_MOUNT_ESC=$(echo "$DS_MOUNT" | html_escape) + DATASET_ROWS+=" + + ${DS_NAME_ESC} + ${DS_USED} + ${DS_AVAIL} + ${DS_REFER} + ${DS_MOUNT_ESC} + " + done <<< "$ZFS_LIST_RAW" + + DATASET_TABLE=" +
+ ▶ Datasets / Filesystems — zfs list -r  (${DATASET_COUNT} entries) +
+ + + + + + + + + + + ${DATASET_ROWS} + +
DatasetUsedAvailableReferencedMountpoint
+
+
" + + # ── Scrub parse ──────────────────────────────────────────────────────────── + SCRUB_LINE=$(echo "$STATUS_RAW" | grep -E "scan:|scrub" | head -1 || true) + if echo "$SCRUB_LINE" | grep -q "scrub repaired"; then + LAST_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\w{3} \w{3} +\d+ \d+:\d+:\d+ \d{4}' | head -1 || echo "unknown") + ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors") + SCRUB_STATUS="Completed" + elif echo "$SCRUB_LINE" | grep -q "in progress"; then + LAST_SCRUB="In Progress"; SCRUB_STATUS="Running"; ERRORS_SCRUB="—" + elif echo "$SCRUB_LINE" | grep -q "none requested"; then + LAST_SCRUB="Never"; SCRUB_STATUS="Never Run"; ERRORS_SCRUB="—" + else + LAST_SCRUB=$(echo "$SCRUB_LINE" | sed 's/.*on //' | sed 's/ with.*//' || echo "unknown") + SCRUB_STATUS="Completed" + ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors") + fi + + # ── Severity logic ───────────────────────────────────────────────────────── + POOL_SEVERITY="ok" + POOL_LABEL="HEALTHY" + + if [[ "$HEALTH" != "ONLINE" ]]; then + POOL_SEVERITY="critical"; POOL_LABEL="$HEALTH" + OVERALL_STATUS="CRITICAL"; PROBLEM_COUNT=$((PROBLEM_COUNT + 1)) + fi + + CAP_NUM=${CAP_RAW//%/}; CAP_NUM=${CAP_NUM%%.*} + if [[ "$CAP_NUM" =~ ^[0-9]+$ ]]; then + if (( CAP_NUM >= CAP_CRIT )); then + POOL_SEVERITY="critical"; POOL_LABEL="CRITICAL — CAPACITY ${CAP_H}" + OVERALL_STATUS="CRITICAL" + [[ "$HEALTH" == "ONLINE" ]] && PROBLEM_COUNT=$((PROBLEM_COUNT + 1)) + elif (( CAP_NUM >= CAP_WARN )); then + [[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning" + [[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — CAPACITY ${CAP_H}" + [[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING" + fi + fi + + if [[ "$LAST_SCRUB" == "Never" ]]; then + [[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning" + [[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — NO SCRUB" + [[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING" + fi + + case "$POOL_SEVERITY" in + ok) BADGE_BG="#1a7f4b"; BADGE_TEXT="white"; ROW_BG="#f0fdf4" ;; + warning) BADGE_BG="#b45309"; BADGE_TEXT="white"; ROW_BG="#fffbeb" ;; + critical) BADGE_BG="#b91c1c"; BADGE_TEXT="white"; ROW_BG="#fff1f2" ;; + esac + + # ── Error blocks ─────────────────────────────────────────────────────────── + # a) Pool/vdev state errors + ERROR_LINES=$(echo "$STATUS_RAW" \ + | grep -E "(DEGRADED|FAULTED|OFFLINE|REMOVED|UNAVAIL|errors:)" \ + | grep -v "errors: No known data errors" || true) + + # b) Permanent per-file errors exposed by -v flag + VERBOSE_ERRORS=$(echo "$STATUS_RAW" \ + | awk '/Permanent errors have been detected/,0' \ + | grep -v "^$" || true) + + ERROR_HTML="" + if [[ -n "$ERROR_LINES" ]]; then + ERROR_LINES_ESC=$(echo "$ERROR_LINES" | head -30 | html_escape) + ERROR_HTML+=" +
+
⚠ Pool / vdev Errors
+
${ERROR_LINES_ESC}
+
" + fi + + if [[ -n "$VERBOSE_ERRORS" ]]; then + VERBOSE_ESC=$(echo "$VERBOSE_ERRORS" | head -50 | html_escape) + ERROR_HTML+=" +
+
📄 Affected Files — zpool status -v
+
${VERBOSE_ESC}
+
" + fi + + # ── vdev config tree ─────────────────────────────────────────────────────── + VDEV_TREE=$(echo "$STATUS_RAW" \ + | sed -n '/config:/,/errors:/p' \ + | grep -v "^$" | head -60 | html_escape || true) + + # ── Assemble pool card ───────────────────────────────────────────────────── + POOL_BLOCKS+=" +
+ +
+ + + +
📉 ${POOL} + pool${POOL_LABEL}
+
+ +
+ + + + + + + + + + +
+
Total Size
+
${SIZE_H}
+
+
Used
+
${ALLOC_H}
+
+
Free
+
${FREE_H}
+
+
Capacity
+
${CAP_H}
+
+
Fragmentation
+
${FRAG_H}
+
+
Health
+
${HEALTH}
+
+ +
+ 🔍 Last Scrub + ${SCRUB_STATUS}  |  ${LAST_SCRUB}  |  Errors: ${ERRORS_SCRUB} +
+ + ${ERROR_HTML} + +
+ ▶ vdev / Drive Configuration — zpool status -v +
${VDEV_TREE}
+
+ + ${DATASET_TABLE} + +
+
" + +done <<< "$POOL_LIST" + +# ── Overall banner ───────────────────────────────────────────────────────────── +case "$OVERALL_STATUS" in + HEALTHY) BANNER_BG="#1a7f4b"; BANNER_ICON="✅"; BANNER_MSG="All pools are healthy — no action required." ;; + WARNING) BANNER_BG="#b45309"; BANNER_ICON="⚠️"; BANNER_MSG="One or more pools require your attention." ;; + CRITICAL) BANNER_BG="#b91c1c"; BANNER_ICON="🚨"; BANNER_MSG="CRITICAL issue detected — immediate action required!" ;; +esac + +SUBJECT="[ZFS] ${TARGET_HOST} — ${OVERALL_STATUS} — ${NOW}" + +# ── Build HTML email ─────────────────────────────────────────────────────────── +HTML=$(cat < + + + + + +
+ + + + + + + + + + + + +
+ + + +
+
ZFS Pool Health Monitor
+
🔌 ${TARGET_HOST}
+
${NOW}  |  ${POOL_COUNT} pool(s) monitored
+
+
${BANNER_ICON} ${OVERALL_STATUS}
+
+
+ ${BANNER_ICON}  ${BANNER_MSG} +
${POOL_BLOCKS}
+
+
📋 Report Summary
+ + + + + + + + + + + + + + + +
Host${TARGET_HOST} (${HOSTNAME_REAL})
Report Time${NOW}
Pools Checked${POOL_COUNT}
Pools with Issues${PROBLEM_COUNT}
Capacity Warning Threshold≥ ${CAP_WARN}%
Capacity Critical Threshold≥ ${CAP_CRIT}%
Scrub Age Warning> ${SCRUB_MAX_AGE_DAYS} days
+
+
Commands Executed
+ + zpool status -v <pool>
+ zpool list -H -o name,size,alloc,free,frag,cap,health <pool>
+ zfs list -r -H -o name,used,avail,refer,mountpoint <pool> +
+
+
+
+ Automated ZFS Monitor  |  ${TARGET_HOST}  |  KingDezigns Infrastructure +
+
+ + +EOF +) + +# ── Send email ───────────────────────────────────────────────────────────────── +send_email() { + if command -v sendmail &>/dev/null; then + { echo "To: ${REPORT_TO}"; echo "From: ${REPORT_FROM}"; echo "Subject: ${SUBJECT}" + echo "MIME-Version: 1.0"; echo "Content-Type: text/html; charset=UTF-8"; echo "" + echo "$HTML"; } | sendmail -t + echo "Report sent via sendmail to ${REPORT_TO}" + elif command -v mailx &>/dev/null; then + echo "$HTML" | mailx -a "Content-Type: text/html" -s "$SUBJECT" "$REPORT_TO" + echo "Report sent via mailx to ${REPORT_TO}" + elif command -v mail &>/dev/null; then + echo "$HTML" | mail -a "Content-Type: text/html; charset=UTF-8" -s "$SUBJECT" "$REPORT_TO" + echo "Report sent via mail to ${REPORT_TO}" + else + echo "ERROR: No mail transport found. Install sendmail, mailx, or msmtp." + FALLBACK="/tmp/zfs_report_${TARGET_HOST}_$(date +%Y%m%d_%H%M%S).html" + echo "$HTML" > "$FALLBACK" + echo "HTML report saved to: $FALLBACK" + exit 1 + fi +} + +send_email +echo "[$(date '+%Y-%m-%d %H:%M:%S')] ZFS report for ${TARGET_HOST} — Status: ${OVERALL_STATUS} — Pools: ${POOL_COUNT} — Problems: ${PROBLEM_COUNT}" diff --git a/zfs/nas08_zfs_scrub-1.sh b/zfs/nas08_zfs_scrub-1.sh new file mode 100644 index 0000000..54811dd --- /dev/null +++ b/zfs/nas08_zfs_scrub-1.sh @@ -0,0 +1,322 @@ +#!/bin/bash +# ============================================================================= +# ZFS Pool Scrub & Email Report — NAS08 +# Location: /usr/scripts/zfs/nas08_zfs_scrub.sh +# Schedule via OMV Scheduler (cron) +# ============================================================================= + +# ── Configuration ───────────────────────────────────────────────────────────── +HOSTNAME="NAS08" +REPORT_TO="rufus.king@kingdezigns.com" +REPORT_FROM="zfs-monitor@nas08.local" # Display only — Postfix uses your Gmail relay +SCRUB_WAIT_SECONDS=28800 # Max wait per pool scrub (8 hrs for large pools) +CAPACITY_WARN_THRESHOLD=80 # % capacity to flag as WARNING +SCRUB_AGE_WARN_DAYS=8 # Days since last scrub before flagging stale +LOG_FILE="/var/log/zfs_scrub_nas08.log" +# Note: Email is sent via OMV-configured Postfix (Gmail relay). No SMTP config needed here. +# ────────────────────────────────────────────────────────────────────────────── + +REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") +POOLS=$(zpool list -H -o name) +POOL_COUNT=$(echo "$POOLS" | wc -l) + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } + +log "===== ZFS Scrub started on $HOSTNAME =====" + +# ── Run scrubs sequentially ─────────────────────────────────────────────────── +for pool in $POOLS; do + log "Starting scrub on pool: $pool" + zpool scrub "$pool" + + ELAPSED=0 + while true; do + SCRUB_STATE=$(zpool status "$pool" | grep -E "scan:" | awk '{print $2}') + [[ "$SCRUB_STATE" == "scrub" ]] || break + sleep 30 + ELAPSED=$((ELAPSED + 30)) + if [[ $ELAPSED -ge $SCRUB_WAIT_SECONDS ]]; then + log "WARNING: Scrub on $pool exceeded wait time. Moving on." + break + fi + done + log "Scrub complete (or timed out) for pool: $pool" +done + +# ── Gather data & build pool cards ─────────────────────────────────────────── + +OVERALL_STATUS="HEALTHY" # HEALTHY | WARNING | CRITICAL +POOLS_WITH_ISSUES=0 +POOL_CARDS_HTML="" + +for pool in $POOLS; do + STATUS_RAW=$(zpool list -H -o health "$pool") + SIZE=$(zpool list -H -o size "$pool") + USED=$(zpool list -H -o alloc "$pool") + FREE=$(zpool list -H -o free "$pool") + CAP=$(zpool list -H -o cap "$pool" | tr -d '%') + FRAG=$(zpool list -H -o frag "$pool") + SCRUB_LINE=$(zpool status "$pool" | grep -E "scan:") + ERRORS_LINE=$(zpool status "$pool" | grep "errors:") + CONFIG_BLOCK=$(zpool status "$pool" | awk '/config:/,/errors:/' | head -n -1) + FAULTED_LINES=$(echo "$CONFIG_BLOCK" | grep -E "FAULTED|DEGRADED|UNAVAIL|REMOVED" | grep -v "^$") + + # Per-pool severity + POOL_SEVERITY="HEALTHY" + ISSUE_DETAIL="" + + if [[ "$STATUS_RAW" == "DEGRADED" || "$STATUS_RAW" == "FAULTED" || "$STATUS_RAW" == "UNAVAIL" ]]; then + POOL_SEVERITY="CRITICAL" + OVERALL_STATUS="CRITICAL" + ISSUE_DETAIL="$FAULTED_LINES" + elif [[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]]; then + POOL_SEVERITY="WARNING" + [[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING" + fi + + # Scrub error count + SCRUB_ERRORS=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1) + [[ -z "$SCRUB_ERRORS" ]] && SCRUB_ERRORS="0 errors" + + # Scrub date + SCRUB_DATE=$(echo "$SCRUB_LINE" | grep -oP '[A-Z][a-z]{2} \d+ \d+:\d+:\d+ \d{4}' | head -1) + [[ -z "$SCRUB_DATE" ]] && SCRUB_DATE="No scrub data" + + # Scrub age warning + SCRUB_AGE_NOTE="" + if [[ -n "$SCRUB_DATE" && "$SCRUB_DATE" != "No scrub data" ]]; then + SCRUB_EPOCH=$(date -d "$SCRUB_DATE" +%s 2>/dev/null) + NOW_EPOCH=$(date +%s) + DIFF_DAYS=$(( (NOW_EPOCH - SCRUB_EPOCH) / 86400 )) + if [[ $DIFF_DAYS -ge $SCRUB_AGE_WARN_DAYS ]]; then + SCRUB_AGE_NOTE=" — ⚠️ Last scrub was ${DIFF_DAYS} days ago" + [[ "$POOL_SEVERITY" == "HEALTHY" ]] && POOL_SEVERITY="WARNING" + [[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING" + fi + fi + + [[ "$POOL_SEVERITY" != "HEALTHY" ]] && POOLS_WITH_ISSUES=$((POOLS_WITH_ISSUES + 1)) + + # Colors + case "$STATUS_RAW" in + ONLINE) HEALTH_COLOR="#1a7f4b" ;; + DEGRADED) HEALTH_COLOR="#b91c1c" ;; + FAULTED) HEALTH_COLOR="#b91c1c" ;; + *) HEALTH_COLOR="#b45309" ;; + esac + + case "$POOL_SEVERITY" in + CRITICAL) CARD_BORDER="#fca5a5"; CARD_HEADER_BG="#fff1f2"; POOL_BADGE_BG="#b91c1c"; POOL_BADGE_LABEL="$STATUS_RAW" ;; + WARNING) CARD_BORDER="#fcd34d"; CARD_HEADER_BG="#fffbeb"; POOL_BADGE_BG="#b45309"; POOL_BADGE_LABEL="WARNING" ;; + *) CARD_BORDER="#86efac"; CARD_HEADER_BG="#f0fdf4"; POOL_BADGE_BG="#1a7f4b"; POOL_BADGE_LABEL="HEALTHY" ;; + esac + + CAP_COLOR="#111827" + [[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]] && CAP_COLOR="#b45309" + [[ "$CAP" -ge 90 ]] && CAP_COLOR="#b91c1c" + + # Issue block + ISSUE_BLOCK="" + if [[ -n "$ISSUE_DETAIL" ]]; then + ESCAPED_DETAIL=$(echo "$ISSUE_DETAIL" | sed 's//\>/g') + ISSUE_BLOCK=" + +
$ESCAPED_DETAIL
+ " + fi + + # Config block + ESCAPED_CONFIG=$(echo "$CONFIG_BLOCK" | sed 's//\>/g') + + POOL_CARDS_HTML+=" +
+ + +
+ + + + + +
+ 🗄️ $pool + pool + + $POOL_BADGE_LABEL +
+
+ + +
+ + + + + + + + + + + + + + + $ISSUE_BLOCK +
+
Total
+
$SIZE
+
+
Used
+
$USED
+
+
Free
+
$FREE
+
+
Capacity
+
${CAP}%
+
+
Frag
+
$FRAG
+
+
Health
+
$STATUS_RAW
+
+ + +
+ 🔍 Last Scrub + Completed  |  $SCRUB_DATE  |  $SCRUB_ERRORS${SCRUB_AGE_NOTE} +
+ + +
+ ▶ vdev / drive configuration +
$ESCAPED_CONFIG
+
+
+
" +done + +# ── Overall banner & badge ──────────────────────────────────────────────────── +case "$OVERALL_STATUS" in + CRITICAL) + BADGE_HTML="
🚨 CRITICAL
" + BANNER_HTML="🚨  CRITICAL issue detected — immediate action required!" + ISSUES_COLOR="#b91c1c" + ;; + WARNING) + BADGE_HTML="
⚠️ WARNING
" + BANNER_HTML="⚠️  Warning condition detected — review recommended." + ISSUES_COLOR="#b45309" + ;; + *) + BADGE_HTML="
✅ ALL HEALTHY
" + BANNER_HTML="✅  All pools are healthy — no action required." + ISSUES_COLOR="#1a7f4b" + ;; +esac + +# ── Compose full HTML email ─────────────────────────────────────────────────── +HTML_BODY=$(cat < + + + + + +
+ + + + + + + $BANNER_HTML + + + + + + + + + + +
+ + + + + +
+
ZFS Pool Health Monitor
+
🔌 $HOSTNAME
+
$REPORT_TIME  |  $POOL_COUNT pool(s) monitored
+
$BADGE_HTML
+
+ $POOL_CARDS_HTML +
+
+
📊 Report Summary
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Host$HOSTNAME
Report Time$REPORT_TIME
Pools Checked$POOL_COUNT
Pools with Issues$POOLS_WITH_ISSUES
Scrub Age Warning Threshold$SCRUB_AGE_WARN_DAYS days
Capacity Warning Threshold${CAPACITY_WARN_THRESHOLD}%
Overall Status$OVERALL_STATUS
+
+
+ Automated ZFS Monitor  |  $HOSTNAME  |  KingDezigns Infrastructure +
+
+ + +EOF +) + +# ── Send email via OMV Postfix (Gmail relay) ────────────────────────────────── +SUBJECT="[ZFS] $HOSTNAME — $OVERALL_STATUS | $REPORT_TIME" + +{ + echo "To: $REPORT_TO" + echo "From: $REPORT_FROM" + echo "Subject: $SUBJECT" + echo "MIME-Version: 1.0" + echo "Content-Type: text/html; charset=UTF-8" + echo "" + echo "$HTML_BODY" +} | sendmail -t + +log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" +log "===== ZFS Scrub complete on $HOSTNAME =====" diff --git a/zfs/nas08_zfs_scrub.sh b/zfs/nas08_zfs_scrub.sh new file mode 100644 index 0000000..4da08cf --- /dev/null +++ b/zfs/nas08_zfs_scrub.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# ============================================================================= +# ZFS Pool Scrub & Email Report — NAS08 +# Location: /usr/scripts/zfs/nas08_zfs_scrub.sh +# Schedule via OMV Scheduler (cron) +# ============================================================================= + +# ── Configuration ───────────────────────────────────────────────────────────── +HOSTNAME="NAS08" +REPORT_TO="rufus.king@kingdezigns.com" +REPORT_FROM="zfs-monitor@nas08.local" # Display only — Postfix uses your Gmail relay +SCRUB_WAIT_SECONDS=28800 # Max wait per pool scrub (8 hrs for large pools) +CAPACITY_WARN_THRESHOLD=80 # % capacity to flag as WARNING +SCRUB_AGE_WARN_DAYS=8 # Days since last scrub before flagging stale +LOG_FILE="/var/log/zfs_scrub_nas08.log" +# Note: Email is sent via OMV-configured Postfix (Gmail relay). No SMTP config needed here. +# ────────────────────────────────────────────────────────────────────────────── + +REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") +POOLS=$(zpool list -H -o name) +POOL_COUNT=$(echo "$POOLS" | wc -l) + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } + +log "===== ZFS Scrub started on $HOSTNAME =====" + +# ── Run scrubs sequentially ─────────────────────────────────────────────────── +for pool in $POOLS; do + log "Starting scrub on pool: $pool" + zpool scrub "$pool" + + # Wait for scrub to finish + ELAPSED=0 + while true; do + SCRUB_STATE=$(zpool status "$pool" | grep -E "scan:" | awk '{print $2}') + [[ "$SCRUB_STATE" == "scrub" ]] || break + sleep 30 + ELAPSED=$((ELAPSED + 30)) + if [[ $ELAPSED -ge $SCRUB_WAIT_SECONDS ]]; then + log "WARNING: Scrub on $pool exceeded wait time. Moving on." + break + fi + done + log "Scrub complete (or timed out) for pool: $pool" +done + +# ── Gather data & build HTML report ────────────────────────────────────────── + +OVERALL_STATUS="HEALTHY" # HEALTHY | WARNING | CRITICAL +POOLS_WITH_ISSUES=0 +POOL_CARDS_HTML="" + +for pool in $POOLS; do + STATUS_RAW=$(zpool list -H -o health "$pool") + SIZE=$(zpool list -H -o size "$pool") + USED=$(zpool list -H -o alloc "$pool") + FREE=$(zpool list -H -o free "$pool") + CAP=$(zpool list -H -o cap "$pool" | tr -d '%') + FRAG=$(zpool list -H -o frag "$pool") + SCRUB_LINE=$(zpool status "$pool" | grep -E "scan:") + ERRORS_LINE=$(zpool status "$pool" | grep "errors:") + CONFIG_BLOCK=$(zpool status "$pool" | awk '/config:/,/errors:/' | head -n -1) + FAULTED_LINES=$(echo "$CONFIG_BLOCK" | grep -E "FAULTED|DEGRADED|UNAVAIL|REMOVED" | grep -v "^$") + + # Determine per-pool severity + POOL_SEVERITY="HEALTHY" + ISSUE_DETAIL="" + + if [[ "$STATUS_RAW" == "DEGRADED" || "$STATUS_RAW" == "FAULTED" || "$STATUS_RAW" == "UNAVAIL" ]]; then + POOL_SEVERITY="CRITICAL" + OVERALL_STATUS="CRITICAL" + ISSUE_DETAIL="$FAULTED_LINES" + elif [[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]]; then + POOL_SEVERITY="WARNING" + [[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING" + fi + + # Scrub error count from status + SCRUB_ERRORS=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1) + [[ -z "$SCRUB_ERRORS" ]] && SCRUB_ERRORS="0 errors" + + # Scrub date + SCRUB_DATE=$(echo "$SCRUB_LINE" | grep -oP '[A-Z][a-z]{2} \d+ \d+:\d+:\d+ \d{4}' | head -1) + [[ -z "$SCRUB_DATE" ]] && SCRUB_DATE="No scrub data" + + # Scrub age warning + SCRUB_AGE_NOTE="" + if [[ -n "$SCRUB_DATE" && "$SCRUB_DATE" != "No scrub data" ]]; then + SCRUB_EPOCH=$(date -d "$SCRUB_DATE" +%s 2>/dev/null) + NOW_EPOCH=$(date +%s) + DIFF_DAYS=$(( (NOW_EPOCH - SCRUB_EPOCH) / 86400 )) + if [[ $DIFF_DAYS -ge $SCRUB_AGE_WARN_DAYS ]]; then + SCRUB_AGE_NOTE=" — ⚠️ Last scrub was ${DIFF_DAYS} days ago" + [[ "$POOL_SEVERITY" == "HEALTHY" ]] && POOL_SEVERITY="WARNING" + [[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING" + fi + fi + + [[ "$POOL_SEVERITY" != "HEALTHY" ]] && POOLS_WITH_ISSUES=$((POOLS_WITH_ISSUES + 1)) + + # Health value color + case "$STATUS_RAW" in + ONLINE) HEALTH_COLOR="#1a7f4b" ;; + DEGRADED) HEALTH_COLOR="#b91c1c" ;; + FAULTED) HEALTH_COLOR="#b91c1c" ;; + *) HEALTH_COLOR="#b45309" ;; + esac + + # Pool card header color + case "$POOL_SEVERITY" in + CRITICAL) CARD_BG="#fff1f2"; BADGE_BG="#b91c1c"; BADGE_LABEL="$STATUS_RAW" ;; + WARNING) CARD_BG="#fffbeb"; BADGE_BG="#b45309"; BADGE_LABEL="WARNING" ;; + *) CARD_BG="#f0fdf4"; BADGE_BG="#1a7f4b"; BADGE_LABEL="HEALTHY" ;; + esac + + # Capacity color + CAP_COLOR="#111827" + [[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]] && CAP_COLOR="#b45309" + [[ "$CAP" -ge 90 ]] && CAP_COLOR="#b91c1c" + + # Issue block (only shown when there's a problem) + ISSUE_BLOCK="" + if [[ -n "$ISSUE_DETAIL" ]]; then + ESCAPED_DETAIL=$(echo "$ISSUE_DETAIL" | sed 's//\>/g') + ISSUE_BLOCK="
$ESCAPED_DETAIL
" + fi + + # Config block + ESCAPED_CONFIG=$(echo "$CONFIG_BLOCK" | sed 's//\>/g') + + POOL_CARDS_HTML+=" +
+
+
+ 🗄️ $pool + pool +
+ $BADGE_LABEL +
+
+ + + + + + + + + + + + + + +
+
Total Size
+
$SIZE
+
+
Used
+
$USED
+
+
Free
+
$FREE
+
+
Capacity
+
${CAP}%
+
+
Fragmentation
+
$FRAG
+
+
Health
+
$STATUS_RAW
+
+
+ 🔍 Last Scrub + Completed  |  $SCRUB_DATE  |  $SCRUB_ERRORS$SCRUB_AGE_NOTE +
+ $ISSUE_BLOCK +
+ ▶ vdev / drive configuration +
$ESCAPED_CONFIG
+
+
+
" +done + +# ── Overall banner ──────────────────────────────────────────────────────────── +case "$OVERALL_STATUS" in + CRITICAL) + BADGE_HTML="
🚨 CRITICAL
" + BANNER_HTML="🚨  CRITICAL issue detected — immediate action required!" + ;; + WARNING) + BADGE_HTML="
⚠️ WARNING
" + BANNER_HTML="⚠️  Warning condition detected — review recommended." + ;; + *) + BADGE_HTML="
✅ ALL HEALTHY
" + BANNER_HTML="✅  All pools are healthy — no action required." + ;; +esac + +# ── Compose full HTML email ─────────────────────────────────────────────────── +HTML_BODY=$(cat < + + + + + +
+ + + + + $BANNER_HTML + + + + + + + +
+ + + + + +
+
ZFS Pool Health Monitor
+
🔌 $HOSTNAME
+
$REPORT_TIME  |  $POOL_COUNT pool(s) monitored
+
$BADGE_HTML
+
+ $POOL_CARDS_HTML +
+
+
📋 Report Summary
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Host$HOSTNAME
Report Time$REPORT_TIME
Pools Checked$POOL_COUNT
Pools with Issues$POOLS_WITH_ISSUES
Scrub Age Warning Threshold$SCRUB_AGE_WARN_DAYS days
Capacity Warning Threshold${CAPACITY_WARN_THRESHOLD}%
+
+
+ Automated ZFS Monitor  |  $HOSTNAME  |  KingDezigns Infrastructure +
+
+ + +EOF +) + +# ── Send email via OMV Postfix (Gmail relay) ────────────────────────────────── +SUBJECT="[ZFS] $HOSTNAME — $OVERALL_STATUS | $REPORT_TIME" + +{ + echo "To: $REPORT_TO" + echo "From: $REPORT_FROM" + echo "Subject: $SUBJECT" + echo "MIME-Version: 1.0" + echo "Content-Type: text/html; charset=UTF-8" + echo "" + echo "$HTML_BODY" +} | sendmail -t + +log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" +log "===== ZFS Scrub complete on $HOSTNAME =====" diff --git a/zfs_report_nas08_howto.md b/zfs_report_nas08_howto.md new file mode 100644 index 0000000..ce53d36 --- /dev/null +++ b/zfs_report_nas08_howto.md @@ -0,0 +1,11 @@ +1. SSH into NAS08 +2. cd /usr/scripts/ +3. sudo mkdir zfs +4. sudo nano zfs_report_nas08.sh +5. Copy the .sh file here +6. Save and close the file +7. cd /usr/scripts/zfs +8. chmod +x zfs_report_nas08.sh +9. run the script to test + +/usr/scripts/zfs_report_nas08.sh