Skip to content

patchmon 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

GITHUB_TOKEN=

CONFIG_DIR=/etc/patchmon
INSTALL_DIR=/opt
SERVICE_NAME=patchmon-server
APP_NAME=patchmon
GITHUB_REPO="PatchMon/PatchMon"
DEBUG="false"

🤝 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

task service:install
sudo cp ./patchmon-server.service /etc/systemd/system/
sudo systemctl enable --now patchmon-server.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

task settings:install
sudo cp ./update-settings.js /opt/patchmon/backend/update-settings.js
node /opt/patchmon/backend/update-settings.js

🚀 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.

Upgrade

task update
sudo ./update.sh
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.

Backup

task backup
sudo ./backup.sh
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

🔗 References