PatchMon¶
PatchMon is a web-based application designed to monitor and manage software patches and updates across multiple systems.
Installation¶
The application is installed by cloning the GitHub repository and running a series of npm commands to build the frontend and backend. The update.sh script handles the installation of a new version.
Default Port: 3399
Installation path: /opt/patchmon
Config¶
Configuration for PatchMon is managed through .env files for both the frontend and backend, and a patchmon.creds file for credentials.
pve/patchmon/.env.tmpl
Service¶
PatchMon runs as a systemd service.
pve/patchmon/patchmon-server.service
[Unit]
Description=PatchMon Service
After=network.target postgresql.service
[Service]
Type=simple
WorkingDirectory=/opt/patchmon/backend
ExecStart=/usr/bin/node src/server.js
Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PATH=/usr/bin:/usr/local/bin
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/patchmon
[Install]
WantedBy=multi-user.target
To install the service, you can use the service:install task.
Install Service
Settings¶
The update-settings.js script is used to configure the database settings for PatchMon.
pve/patchmon/update-settings.js
const { PrismaClient } = require('@prisma/client');
const { v4: uuidv4 } = require('uuid');
const prisma = new PrismaClient();
async function updateSettings() {
try {
const existingSettings = await prisma.settings.findFirst();
const settingsData = {
id: uuidv4(),
server_url: 'http://192.168.2.187',
server_protocol: 'http',
server_host: '192.168.2.187',
server_port: 3399,
update_interval: 60,
auto_update: true,
signup_enabled: false,
ignore_ssl_self_signed: false,
updated_at: new Date()
};
if (existingSettings) {
// Update existing settings
await prisma.settings.update({
where: { id: existingSettings.id },
data: settingsData
});
} else {
// Create new settings record
await prisma.settings.create({
data: settingsData
});
}
console.log('✅ Database settings updated successfully');
} catch (error) {
console.error('❌ Error updating settings:', error.message);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
updateSettings();
This can be run using the settings:install task.
Update Settings
Upgrade¶
The update.sh script automates the process of downloading and installing the latest version of PatchMon. It checks for the latest release on GitHub, backs up existing configuration, downloads the new version, builds it, and restarts the service.
pve/patchmon/update.sh
#!/usr/bin/env bash
################################################################################
#
# Script Name: update.sh
# ----------------
# Checks for the latest release of PatchMon and compares it to
# the local version. If out of date, it stops the service, downloads the
# latest version, and restarts the service.
#
# @author Nicholas Wilde, 0xb299a622
# @date 16 Nov 2025
# @version 1.0.0
#
################################################################################
# Options
set -e
set -o pipefail
# These are constants
readonly BLUE=$(tput setaf 4)
readonly RED=$(tput setaf 1)
readonly YELLOW=$(tput setaf 3)
readonly PURPLE=$(tput setaf 5)
readonly RESET=$(tput sgr0)
APP_NAME="patchmon"
SERVICE_NAME="patchmon-server"
INSTALL_DIR="/opt"
GITHUB_REPO="PatchMon/PatchMon"
DEBUG="false"
# Source .env file if it exists
if [ -f "$(dirname "$0")/.env" ]; then
source "$(dirname "$0")/.env"
fi
# Logging function
function log() {
local type="$1"
local color="$RESET"
if [ "${type}" = "DEBU" ] && [ "${DEBUG}" != "true" ]; then
return 0
fi
case "$type" in
INFO)
color="$BLUE";;
WARN)
color="$YELLOW";;
ERRO)
color="$RED";;
DEBU)
color="$PURPLE";;
*)
type="LOGS";;
esac
if [[ -t 0 ]]; then
local message="$2"
echo -e "${color}${type}${RESET}[$(date +'%Y-%m-%d %H:%M:%S')] ${message}"
else
while IFS= read -r line; do
echo -e "${color}${type}${RESET}[$(date +'%Y-%m-%d %H:%M:%S')] ${line}"
done
fi
}
function usage() {
cat <<EOF
Usage: $0 [options]
Checks for the latest release of PatchMon.
Options:
-d, --debug Enable debug mode, which prints more info.
-h, --help Display this help message.
EOF
}
# Checks if a command exists.
function command_exists() {
command -v "$1" >/dev/null 2>&1
}
function check_dependencies() {
if ! command_exists curl || ! command_exists jq || ! command_exists npm || ! command_exists npx; then
log "ERRO" "Required dependencies (curl, jq, npm) are not installed." >&2
exit 1
fi
}
function get_latest_version() {
log "INFO" "Getting latest version of ${APP_NAME} from GitHub..."
local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
log "DEBU" "api_url: ${api_url}"
export json_response
if [ -n "${GITHUB_TOKEN}" ]; then
local curl_args+=('-H' "Authorization: Bearer ${GITHUB_TOKEN}")
fi
json_response=$(curl -s "${curl_args[@]}" "${api_url}")
# log "DEBU" "json_response \n ${json_response}"
if ! echo "${json_response}" | jq -e '.tag_name' >/dev/null 2>&1; then
log "ERRO" "Failed to get latest version for ${APP_NAME} from GitHub API."
echo "${json_response}" | while IFS= read -r line; do log "ERRO" "$line"; done
return 1
fi
local tag_name
tag_name=$(echo "${json_response}" | jq -r '.tag_name')
LATEST_VERSION=${tag_name#v}
log "INFO" "Latest ${APP_NAME} version: ${LATEST_VERSION}"
}
function get_current_version() {
if [ ! -f "${INSTALL_DIR}/${APP_NAME}/backend/package.json" ]; then
log "WARN" "${APP_NAME} is not installed"
CURRENT_VERSION="0"
return
fi
log "INFO" "Getting current version of ${APP_NAME}..."
# The version output is like: "patchmon version 1.0.0"
local current_version_full
current_version_full=$(grep '"version"' "${INSTALL_DIR}/${APP_NAME}/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
CURRENT_VERSION=$(echo "${current_version_full}" | sed 's/v//')
log "INFO" "Current ${APP_NAME} version: ${CURRENT_VERSION}"
}
function make_temp_dir(){
export TEMP_PATH=$(mktemp -d)
if [ ! -d "${TEMP_PATH}" ]; then
log "ERRO" "Could not create temp dir"
exit 1
fi
log "INFO" "Temp path: ${TEMP_PATH}"
}
function backup() {
log "INFO" "Creating Backups"
if [[ -f "${INSTALL_DIR}/${APP_NAME}/backend/.env" ]]; then
if ! sudo cp "${INSTALL_DIR}/${APP_NAME}/backend/.env" "${INSTALL_DIR}/backend.env"; then
return 1
fi
else
log "WARN" "File /opt/patchmon/backend/.env doesn't exist"
fi
if [[ -f "${INSTALL_DIR}/${APP_NAME}/frontend/.env" ]]; then
if ! sudo cp "${INSTALL_DIR}/${APP_NAME}/frontend/.env" "${INSTALL_DIR}/frontend.env"; then
return 1
fi
else
log "WARN" "File /opt/patchmon/frontend/.env doesn't exist"
fi
if [[ -f "${INSTALL_DIR}/${APP_NAME}/backend/update-settings.js" ]]; then
if ! sudo cp "${INSTALL_DIR}/${APP_NAME}/backend/update-settings.js" "${INSTALL_DIR}/update-settings.js"; then
return 1
fi
else
log "WARN" "File ${INSTALL_DIR}/${APP_NAME}/backend/update-settings.js doesn't exist"
fi
}
function download() {
log "INFO" "Downloading and installing update..."
local tarball_url
# log "DEBU" "json_response: ${json_response}"
tarball_url=$(echo "${json_response}" | jq -r '.tarball_url')
log "DEBU" "tarball_url: ${tarball_url}"
if [ -z "${tarball_url}" ] || [ "${tarball_url}" == "null" ]; then
log "ERRO" "Could not find tarball URL in GitHub API response."
echo "${json_response}"
exit 1
fi
log "INFO" "Downloading and extracting ${APP_NAME} version ${TAG_NAME} from ${tarball_url}"
mkdir -p "${TEMP_PATH}/${APP_NAME}"
curl -sL "${tarball_url}" | tar -xz --strip-components=1 -C "${TEMP_PATH}/${APP_NAME}" 2>&1 | log "INFO"
}
function stop_service() {
if systemctl status "${SERVICE_NAME}.service" &> /dev/null; then
log "INFO" "Stopping ${SERVICE_NAME} service..."
sudo systemctl stop "${SERVICE_NAME}.service" 2>&1 | log "INFO"
else
log "WARN" "Service ${SERVICE_NAME}.service not found, skipping stop."
fi
}
function restart_service() {
if systemctl status "${SERVICE_NAME}.service" &> /dev/null; then
log "INFO" "Restarting ${SERVICE_NAME} service..."
sudo systemctl restart "${SERVICE_NAME}.service" 2>&1 | log "INFO"
else
log "WARN" "Service ${SERVICE_NAME}.service not found, skipping restart."
fi
}
# Cleanup function to remove temporary directory
function cleanup() {
if [ -d "${TEMP_PATH}" ]; then
log "INFO" "Cleaning up temporary directory: ${TEMP_PATH}"
rm -rf "${TEMP_PATH}"
fi
}
function build_update() {
cd "${TEMP_PATH}/${APP_NAME}"
export NODE_ENV=production
log "INFO" "Installing ${APP_NAME} ..."
if ! npm install --no-audit --no-fund --no-save --ignore-scripts 2>&1 | log "DEBU"; then
log "ERRO" "Could not build ${APP_NAME}"
exit 1
fi
cd "${TEMP_PATH}/${APP_NAME}/backend"
rm -rf "${TEMP_PATH}/${APP_NAME}/backend/node_modules"
rm -f "${TEMP_PATH}/${APP_NAME}/backend/package-lock.json"
log "INFO" "Installing ${APP_NAME}/backend ..."
if ! npm install --include=dev --no-audit --no-fund --ignore-scripts 2>&1 | log "DEBU"; then
log "ERRO" "Could not build ${APP_NAME}/backend"
exit 1
fi
log "INFO" "Rebuilding vite ..."
if ! npm rebuild vite 2>&1 | log "DEBU"; then
log "ERRO" "Could not rebuild vite"
exit 1
fi
log "INFO" "Rebuilding rollup ..."
if ! npm rebuild rollup 2>&1 | log "DEBU"; then
log "ERRO" "Could not rebuild rollup"
exit 1
fi
log "INFO" "Building ${APP_NAME}/backend ..."
if ! npm run build 2>&1 | log "DEBU"; then
log "ERRO" "Could not build ${APP_NAME}/backend"
exit 1
fi
cd "${TEMP_PATH}/${APP_NAME}/frontend"
log "INFO" "Installing ${APP_NAME}/frontend ..."
if ! npm install --include=dev --no-audit --no-fund --no-save --ignore-scripts 2>&1 | log "DEBU"; then
log "ERRO" "Could not build ${APP_NAME}/frontend"
exit 1
fi
log "INFO" "Building ${APP_NAME}/frontend ..."
if ! npm run build 2>&1 | log "DEBU"; then
log "ERRO" "Could not build ${APP_NAME}/frontend"
exit 1
fi
cp /opt/backend.env "${TEMP_PATH}/${APP_NAME}/backend/.env"
cp /opt/frontend.env "${TEMP_PATH}/${APP_NAME}/frontend/.env"
cd "${TEMP_PATH}/${APP_NAME}/backend"
log "INFO" "Running 'npx prisma migrate deploy' ..."
if ! npx prisma migrate deploy 2>&1 | log "DEBU"; then
log "ERRO" "Could not run 'npx prisma migrate deploy'"
exit 1
fi
log "INFO" "Running 'npx prisma generate' ..."
if ! npx prisma generate 2>&1 | log "DEBU"; then
log "ERRO" "Could not run 'npx prisma generate'"
exit 1
fi
}
function replace_me() {
log "INFO" "Removing install directory"
if ! sudo rm -r "${INSTALL_DIR}/${APP_NAME}"; then
log "ERRO" "Failed to remove the install directory ${INSTALL_DIR}/${APP_NAME}"
exit 1
fi
log "INFO" "Installing update..."
if ! sudo mv "${TEMP_PATH}/${APP_NAME}" "${INSTALL_DIR}/${APP_NAME}"; then
log "ERRO" "Failed to move ${APP_NAME} to ${INSTALL_DIR}. Aborting update."
exit 1
fi
}
function update_settings(){
cd "${INSTALL_DIR}/${APP_NAME}/backend"
cp "${INSTALL_DIR}/update-settings.js" "${TEMP_PATH}/${APP_NAME}/backend/update-settings.js"
log "INFO" "Updating settings ..."
if ! node update-settings.js 2>&1 | log "DEBU"; then
log "ERRO" "Failed to update settings"
exit 1
fi
}
# Main function to orchestrate the script execution
function main() {
trap cleanup EXIT
while [[ "$#" -gt 0 ]]; do
case $1 in
-d|--debug) DEBUG="true"; shift;;
-h|--help) usage; exit 0;;
*) log "ERRO" "Unknown parameter passed: $1"; usage; exit 1;;
esac
done
log "INFO" "Starting ${APP_NAME} update script..."
check_dependencies
make_temp_dir
get_latest_version
get_current_version
if [[ "${LATEST_VERSION}" == "${CURRENT_VERSION}" ]]; then
log "INFO" "${APP_NAME} is already up-to-date: ${CURRENT_VERSION}"
log "INFO" "Script finished."
exit 0
fi
log "INFO" "New version available for ${APP_NAME}: ${LATEST_VERSION}"
stop_service
if ! backup; then
log "ERRO" "Backup unsuccesful"
exit 1
else
log "INFO" "Backup successful"
fi
download
build_update
replace_me
restart_service
update_settings
get_current_version
if [[ "${LATEST_VERSION}" == "${CURRENT_VERSION}" ]]; then
log "INFO" "Successfully updated ${APP_NAME} to ${LATEST_VERSION}."
else
log "ERRO" "Failed to update ${APP_NAME}. Still on ${CURRENT_VERSION}."
fi
log "INFO" "Script finished."
}
# Call main to start the script
main "$@"
Backup¶
The backup.sh script is used to back up the Redis database. It triggers a BGSAVE, waits for it to complete, and then encrypts the dump file using sops.
pve/patchmon/backup.sh
#!/usr/bin/env bash
################################################################################
#
# Script Name: backup.sh
# ----------------
# This script backs up a Redis database by triggering a BGSAVE, waits for it
# to complete, and then encrypts the dump file using SOPS.
#
# @author Nicholas Wilde, 0xb299a622
# @date 16 Nov 2025
# @version 1.1.0
#
################################################################################
# Options
set -e
set -o pipefail
# These are constants
readonly BLUE=$(tput setaf 4)
readonly RED=$(tput setaf 1)
readonly YELLOW=$(tput setaf 3)
readonly PURPLE=$(tput setaf 5)
readonly RESET=$(tput sgr0)
DEBUG="false"
# Logging function
function log() {
local type="$1"
local color="$RESET"
if [ "${type}" = "DEBU" ] && [ "${DEBUG}" != "true" ]; then
return 0
fi
case "$type" in
INFO)
color="$BLUE";;
WARN)
color="$YELLOW";;
ERRO)
color="$RED";;
DEBU)
color="$PURPLE";;
*)
type="LOGS";;
esac
if [[ -t 0 ]]; then
local message="$2"
echo -e "${color}${type}${RESET}[$(date +'%Y-%m-%d %H:%M:%S')] ${message}"
else
while IFS= read -r line; do
echo -e "${color}${type}${RESET}[$(date +'%Y-%m-%d %H:%M:%S')] ${line}"
done
fi
}
# Checks if a command exists.
function command_exists() {
command -v "$1" >/dev/null 2>&1
}
function check_dependencies() {
if ! command_exists redis-cli; then
log "ERRO" "Required dependency 'redis-cli' is not installed." >&2
exit 1
fi
if ! command_exists sops; then
log "ERRO" "Required dependency 'sops' is not installed." >&2
exit 1
fi
}
function get_redis_config() {
log "INFO" "Fetching Redis database directory and filename..."
local redis_dir
redis_dir=$(redis-cli CONFIG GET dir | sed -n '2p')
local redis_dbfilename
redis_dbfilename=$(redis-cli CONFIG GET dbfilename | sed -n '2p')
REDIS_DUMP_FILE="${redis_dir}/${redis_dbfilename}"
log "INFO" "Redis data is stored in: ${REDIS_DUMP_FILE}"
}
function wait_for_persistence() {
while [[ $(redis-cli INFO persistence | grep 'rdb_bgsave_in_progress:1') ]]; do
log "WARN" "A BGSAVE operation is already in progress. Waiting..."
sleep 5
done
while [[ $(redis-cli INFO persistence | grep 'aof_rewrite_in_progress:1') ]]; do
log "WARN" "An AOF rewrite operation is already in progress. Waiting..."
sleep 5
done
}
function ensure_initial_dump_exists() {
if [[ ! -f "$REDIS_DUMP_FILE" ]]; then
log "WARN" "Redis dump file not found at ${REDIS_DUMP_FILE}. Attempting to save."
wait_for_persistence
redis-cli SAVE
if [ $? -ne 0 ]; then
log "ERRO" "Initial SAVE command failed."
exit 1
fi
if [[ ! -f "$REDIS_DUMP_FILE" ]]; then
log "ERRO" "Redis dump file could not be created at ${REDIS_DUMP_FILE}."
exit 1
fi
fi
}
function trigger_bgsave() {
log "INFO" "Initiating BGSAVE..."
INITIAL_LASTSAVE=$(redis-cli LASTSAVE)
log "INFO" "Initial LASTSAVE timestamp: ${INITIAL_LASTSAVE}"
local bgsave_output
bgsave_output=$(redis-cli BGSAVE)
if [[ "$bgsave_output" != "Background saving started" ]]; then
log "ERRO" "Failed to start BGSAVE: $bgsave_output"
exit 1
fi
}
function wait_for_bgsave_completion() {
log "INFO" "Waiting for BGSAVE to complete..."
while true; do
local current_lastsave
current_lastsave=$(redis-cli LASTSAVE)
if [[ "$current_lastsave" -gt "$INITIAL_LASTSAVE" ]]; then
log "INFO" "BGSAVE completed. New LASTSAVE timestamp: ${current_lastsave}"
break
fi
log "DEBU" "Still waiting for BGSAVE... (current: ${current_lastsave}, initial: ${INITIAL_LASTSAVE})"
sleep 1
done
}
function verify_bgsave_status() {
if [[ $(redis-cli INFO persistence | grep 'rdb_last_bgsave_status:ok') ]]; then
log "INFO" "BGSAVE was successful."
else
log "ERRO" "BGSAVE failed. Check Redis logs for more information."
exit 1
fi
}
function encrypt_and_cleanup() {
local encrypted_dump_file="${REDIS_DUMP_FILE}.enc"
log "INFO" "Encrypting ${REDIS_DUMP_FILE} to ${encrypted_dump_file} using sops..."
sops --encrypt "${REDIS_DUMP_FILE}" > "${encrypted_dump_file}"
if [ $? -ne 0 ]; then
log "ERRO" "SOPS encryption failed."
exit 1
fi
log "INFO" "Encryption complete."
log "INFO" "Removing unencrypted dump file..."
rm "${REDIS_DUMP_FILE}"
}
# Main function to orchestrate the script execution
function main() {
log "INFO" "Starting Redis backup script..."
check_dependencies
get_redis_config
ensure_initial_dump_exists
wait_for_persistence
trigger_bgsave
wait_for_bgsave_completion
verify_bgsave_status
encrypt_and_cleanup
log "INFO" "Backup script finished successfully."
}
# Call main to start the script
main "$@"
Traefik¶
homelab/pve/traefik/conf.d/patchmon.yaml
---
http:
#region routers
routers:
patchmon:
entryPoints:
- "websecure"
rule: "Host(`patchmon.l.nicholaswilde.io`)"
middlewares:
- default-headers@file
- https-redirectscheme@file
tls: {}
service: patchmon
#endregion
#region services
services:
patchmon:
loadBalancer:
servers:
- url: "http://192.168.2.187"
passHostHeader: true
#endregion
Task List¶
task: Available tasks for this project:
* backup: Run the Redis backup script and encrypt the dump file.
* create-template: Create template from existing .env file
* decrypt: Decrypt sensitive configuration files using SOPS.
* default: List all available tasks.
* enable: Enable the application's systemd service.
* encrypt: Encrypt sensitive configuration files using SOPS.
* export: Export the task list to `task-list.txt`.
* init: Initialize the application's environment and configuration files.
* mklinks: Create symbolic links for configuration files.
* restart: Restart the application's systemd service.
* start: Start the application's systemd service.
* status: Check the status of the application's systemd service.
* stop: Stop the application's systemd service.
* update: Update the application or its running containers.
* upgrade: Upgrade the application by pulling the latest changes and updating.
* service:install: Install service
* settings:install: Install settings