Files
MeterVault/deploy/install/metervault-install.sh
T
schmidt.florian cedd60ab45
ci / build-test (push) Successful in 1m17s
Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
Three defects found reviewing the last few commits.

Deriving consumption on ingest made the batch reading endpoint quadratic. A
recompute rewrites a meter's entire consumption series, and POST
/api/v1/readings ran one per reading -- 500 readings for one meter meant 500
full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint
normalizes each touched meter once after the batch.

Percentage change divided by a possibly negative baseline. A net-export meter
going from -100 to -150 exported half again as much and would have been
reported as "+50%", reading as more consumption. A non-positive baseline now
reports no basis rather than a confident lie.

The data-protection key ring had no persistent home outside Docker Compose. The
LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would
otherwise create it under the default umask, leaving a key ring world-readable
-- and the Unraid template maps it, since without that every UI-entered secret
was lost whenever the container was recreated. README documents the variable
and the trust boundary: keys on disk protect against leaked database content,
not against an attacker who already has the host.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:56 +02:00

277 lines
11 KiB
Bash
Executable File

#!/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 <ctid>') 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 <<EOF >"${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 "$@"