#!/usr/bin/env bash # Copyright (c) 2021-2026 community-scripts ORG # Author: FinalFactory # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE # Source: https://git.finalfactory.de/FinalFactory/MeterVault # # Runs inside the LXC: installs PostgreSQL + TimescaleDB and the .NET SDK, builds the MeterVault # Blazor app from the public Gitea repo, and runs it as a systemd service. MeterVault publishes a # container image (not a prebuilt tarball), so — unlike MQTTower — this builds from source. : "${GITEA_BASE:=https://git.finalfactory.de}" : "${GITEA_REPO:=FinalFactory/MeterVault}" : "${METERVAULT_REPO_URL:=${GITEA_BASE}/${GITEA_REPO}.git}" : "${METERVAULT_BRANCH:=master}" : "${METERVAULT_PORT:=8760}" : "${METERVAULT_TIMEZONE:=Europe/Berlin}" : "${METERVAULT_SEED:=false}" : "${METERVAULT_DB_PASSWORD:=}" : "${PG_VERSION:=16}" : "${DOTNET_CHANNEL:=10.0}" : "${INSTALL_DIR:=/opt/metervault}" : "${SOURCE_DIR:=/opt/metervault-src}" : "${ENV_FILE:=/etc/metervault/environment}" : "${KEYRING_DIR:=/var/lib/metervault/keys}" : "${DB_NAME:=metervault}" : "${DB_USER:=metervault}" # SAFETY GUARD — this must run INSIDE the MeterVault LXC, never on the Proxmox host. # The community-scripts install.func setup below runs `apt upgrade` and installs PostgreSQL/.NET at # the top level; on a hypervisor that is destructive. Refuse unless we're in a container. This runs # BEFORE install.func is sourced, so a mistaken host invocation exits without touching anything. # Override for unusual setups with METERVAULT_ALLOW_HOST=1. if ! systemd-detect-virt --container --quiet 2>/dev/null && [[ "${METERVAULT_ALLOW_HOST:-}" != "1" ]]; then echo "ERROR: Run this installer INSIDE the MeterVault LXC, not on the Proxmox host." >&2 echo " It installs PostgreSQL/.NET and runs 'apt upgrade' — destructive on a hypervisor." >&2 echo " Enter the container first (e.g. 'pct enter ') then re-run. Override: METERVAULT_ALLOW_HOST=1." >&2 exit 1 fi INSTALL_FUNC_URL="${INSTALL_FUNC_URL:-https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func}" FUNCTIONS_FILE_PATH="${FUNCTIONS_FILE_PATH:-$(curl -fsSL "$INSTALL_FUNC_URL")}" # shellcheck disable=SC1091 # community-scripts install.func is fetched at runtime source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" color verb_ip6 catch_errors setting_up_container network_check update_os export APPLICATION="${APPLICATION:-MeterVault}" export app="${app:-metervault}" need_cmd() { command -v "$1" >/dev/null 2>&1 || { msg_error "Missing command: $1"; exit 127; } } get_ipv4() { hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true } rand_hex() { openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p } # Unprivileged LXC: systemctl enable may fail creating unit symlinks; start often works anyway. systemctl_enable_now_best_effort() { local svc="$1" if systemctl enable -q --now "$svc" 2>/dev/null; then return 0 fi msg_info "systemctl enable --now failed for ${svc}; trying start only (common in unprivileged LXC)." systemctl daemon-reload 2>/dev/null || true if systemctl start "$svc" 2>/dev/null && systemctl is-active --quiet "$svc" 2>/dev/null; then return 0 fi msg_error "Could not start ${svc}. See: journalctl -xeu ${svc}" return 1 } # Append KEY=VALUE only if KEY is absent (safe re-runs / new keys across versions). ensure_env_key() { local file="$1" key="$2" value="$3" if ! grep -q "^${key}=" "$file" 2>/dev/null; then printf '%s=%s\n' "$key" "$value" >>"$file" fi } install_postgres_timescaledb() { export DEBIAN_FRONTEND=noninteractive msg_info "Adding PostgreSQL (PGDG) and TimescaleDB apt repositories" $STD apt-get install -y gnupg postgresql-common apt-transport-https lsb-release wget ca-certificates # PGDG: the official PostgreSQL apt repo (provides a consistent postgresql-${PG_VERSION}). $STD /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y local codename codename="$(lsb_release -cs)" echo "deb https://packagecloud.io/timescale/timescaledb/debian/ ${codename} main" >/etc/apt/sources.list.d/timescaledb.list wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg $STD apt-get update msg_ok "Repositories added" msg_info "Installing PostgreSQL ${PG_VERSION} + TimescaleDB" $STD apt-get install -y "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}" "timescaledb-2-postgresql-${PG_VERSION}" msg_ok "Installed PostgreSQL + TimescaleDB" msg_info "Enabling the timescaledb preload" local pgconf="/etc/postgresql/${PG_VERSION}/main/postgresql.conf" if command -v timescaledb-tune >/dev/null 2>&1; then timescaledb-tune --quiet --yes --pg-config "/usr/lib/postgresql/${PG_VERSION}/bin/pg_config" >/dev/null 2>&1 || true fi if ! grep -qE "^\s*shared_preload_libraries\s*=.*timescaledb" "$pgconf" 2>/dev/null; then echo "shared_preload_libraries = 'timescaledb'" >>"$pgconf" fi systemctl restart postgresql msg_ok "PostgreSQL restarted with timescaledb preloaded" } provision_database() { msg_info "Creating the ${DB_NAME} database and role" [[ -n "${METERVAULT_DB_PASSWORD}" ]] || METERVAULT_DB_PASSWORD="$(rand_hex)" # Role: create or reset the password (idempotent re-runs). if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1; then sudo -u postgres psql -c "ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null else sudo -u postgres psql -c "CREATE ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null fi if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" >/dev/null fi # Pre-create the extension as superuser (CREATE EXTENSION timescaledb needs superuser); the app's # migration then finds it present and its own CREATE EXTENSION IF NOT EXISTS is a no-op. sudo -u postgres psql -d "${DB_NAME}" -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >/dev/null msg_ok "Database ready" } install_dotnet_sdk() { export DEBIAN_FRONTEND=noninteractive msg_info "Installing the .NET SDK (${DOTNET_CHANNEL}) to build from source" local deb_ver deb_ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID%%.*}" || echo "12")" if [[ "$deb_ver" != "11" && "$deb_ver" != "12" && "$deb_ver" != "13" ]]; then deb_ver="12" fi if [[ ! -f /etc/apt/sources.list.d/microsoft-prod.list && ! -f /etc/apt/sources.list.d/microsoft-prod.sources ]]; then $STD curl -fsSL "https://packages.microsoft.com/config/debian/${deb_ver}/packages-microsoft-prod.deb" -o /tmp/packages-microsoft-prod.deb $STD dpkg -i /tmp/packages-microsoft-prod.deb rm -f /tmp/packages-microsoft-prod.deb fi $STD apt-get update -y $STD apt-get install -y "dotnet-sdk-${DOTNET_CHANNEL}" msg_ok "Installed .NET SDK" } build_metervault() { export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 msg_info "Fetching MeterVault source (${METERVAULT_REPO_URL})" if [[ -d "${SOURCE_DIR}/.git" ]]; then git -C "${SOURCE_DIR}" fetch --depth 1 origin "${METERVAULT_BRANCH}" git -C "${SOURCE_DIR}" reset --hard "origin/${METERVAULT_BRANCH}" else rm -rf "${SOURCE_DIR}" $STD git clone --depth 1 --branch "${METERVAULT_BRANCH}" "${METERVAULT_REPO_URL}" "${SOURCE_DIR}" fi msg_ok "Source ready at ${SOURCE_DIR}" msg_info "Building MeterVault (dotnet publish — this can take a few minutes)" # sampledata/ is at the repo root; the App csproj links it as Content so publish ships the demo CSVs. $STD dotnet publish "${SOURCE_DIR}/src/App/MeterVault.App.csproj" -c Release -o "${INSTALL_DIR}" /p:UseAppHost=false msg_ok "Published to ${INSTALL_DIR}" } write_env() { mkdir -p "$(dirname "${ENV_FILE}")" umask 077 local conn="Host=127.0.0.1;Port=5432;Database=${DB_NAME};Username=${DB_USER};Password=${METERVAULT_DB_PASSWORD}" if [[ -f "${ENV_FILE}" ]]; then msg_info "Environment exists; merging new keys and refreshing the connection string" ensure_env_key "${ENV_FILE}" "ASPNETCORE_ENVIRONMENT" "Production" ensure_env_key "${ENV_FILE}" "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT" "false" ensure_env_key "${ENV_FILE}" "ASPNETCORE_URLS" "http://+:${METERVAULT_PORT}" ensure_env_key "${ENV_FILE}" "MeterVault__TimeZone" "${METERVAULT_TIMEZONE}" ensure_env_key "${ENV_FILE}" "MeterVault__Currency" "EUR" ensure_env_key "${ENV_FILE}" "MeterVault__Locale" "en" ensure_env_key "${ENV_FILE}" "MeterVault__SeedReferenceData" "${METERVAULT_SEED}" sed -i "s|^ConnectionStrings__Default=.*|ConnectionStrings__Default=${conn}|" "${ENV_FILE}" grep -q '^ConnectionStrings__Default=' "${ENV_FILE}" || printf 'ConnectionStrings__Default=%s\n' "${conn}" >>"${ENV_FILE}" msg_ok "Updated ${ENV_FILE}" else cat <"${ENV_FILE}" ASPNETCORE_ENVIRONMENT=Production DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false ASPNETCORE_URLS=http://+:${METERVAULT_PORT} ConnectionStrings__Default=${conn} MeterVault__TimeZone=${METERVAULT_TIMEZONE} MeterVault__Currency=EUR MeterVault__Locale=en MeterVault__SeedReferenceData=${METERVAULT_SEED} EOF msg_ok "Wrote ${ENV_FILE}" fi chmod 600 "${ENV_FILE}" } # Key ring for connector secrets typed into the admin UI (SDD §6.4). The app creates this itself if # missing, but with the default umask — created here instead so it is 0700 from the start, and so it # is visibly outside /opt/metervault, which the updater republishes on every run. write_keyring_dir() { install -d -m 0700 "${KEYRING_DIR}" } write_systemd() { cat <<'EOF' >/etc/systemd/system/metervault.service [Unit] Description=MeterVault (self-hosted energy & utility metering) After=network-online.target postgresql.service Wants=network-online.target Requires=postgresql.service [Service] Type=simple EnvironmentFile=/etc/metervault/environment WorkingDirectory=/opt/metervault ExecStart=/usr/bin/dotnet /opt/metervault/MeterVault.App.dll Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF } # Install the in-container updater from the checkout we just built, rather than emitting it from a # heredoc here. Keeping it a real file in the repo means an already-provisioned container can # bootstrap /usr/bin/update itself after a git pull, instead of being stranded on whatever the # installer wrote the day it ran. write_update_command() { local src="${SOURCE_DIR}/deploy/install/metervault-update.sh" if [[ ! -f "${src}" ]]; then echo "Updater script not found at ${src} — skipping 'update' command." >&2 return 0 fi install -m 0755 "${src}" /usr/bin/update } main() { need_cmd curl ensure_dependencies git jq openssl sudo install_postgres_timescaledb provision_database install_dotnet_sdk build_metervault write_env write_keyring_dir write_systemd systemctl daemon-reload 2>/dev/null || true systemctl_enable_now_best_effort metervault # Write the important post-install artifact first, then run cosmetic framework steps best-effort. # motd_ssh's `chmod -x /etc/update-motd.d/*` returns "Operation not permitted" in some unprivileged # LXCs; under the framework's ERR trap that would otherwise abort an already-successful install. write_update_command motd_ssh 2>/dev/null || true customize 2>/dev/null || true cleanup_lxc 2>/dev/null || true local ip ip="$(get_ipv4)" msg_ok "MeterVault is up at http://${ip:-127.0.0.1}:${METERVAULT_PORT}" } main "$@"