Migrate LMS/Squeezelite addon; fix is_service_enabled dead pre-check; bump to v2.9.0

New menus/addon_lms_squeezelite.sh: install/reconfigure/uninstall for an
LMS (Lyrion/Logitech Media Server) server and a Squeezelite player,
wired into install.sh's Addons menu. Squeezelite's own start script and
systemd unit now go through $BIN_DIR/$SYSTEMD_DIR like every other
addon instead of hardcoded /usr/local/bin and /etc/systemd/system; LMS's
own apt repo/GPG key/ufw rules stay at their real fixed system paths,
same approach as CUPS.

Fixed a real unguarded-pipeline bug from the legacy install_lms():
`sudo systemctl enable "$service_name" 2>&1 | tee ...` made the exit
status depend on tee (always 0) instead of systemctl enable, silently
swallowing real enable/start failures. Now uses enable_and_start_units().

Fixed is_service_enabled() (shared helper, backported into the legacy
script too): its list-unit-files pre-check never matched a bare service
name, so it always fell through to "not enabled" regardless of the real
state. Dropped the dead pre-check.

Full command-level stubbed test suite covering install/reconfigure/
uninstall for both LMS and Squeezelite, including the repo-vs-fallback-
download path, undetectable-service-name path, and enable/start-failure
path. Full 12-suite regression + real end-to-end menu navigation via
install.sh all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
Claude
2026-08-19 02:11:05 +00:00
parent 0454a259fa
commit 2bf4efe2b1
5 changed files with 437 additions and 22 deletions
+34 -11
View File
@@ -1,6 +1,6 @@
# Ubuntu Based Kiosk
**Current Version:** 2.8.0 (check script header for latest version)
**Current Version:** 2.9.0 (check script header for latest version)
**Built with Claude Sonnet 4.6 AI assistance**
**License:** GPL v3 - Keep derivatives open source
**Repository:** https://github.com/outis1one/ubuntu-based-kiosk/
@@ -1214,6 +1214,13 @@ terminal menu and the web UI, so they can't drift apart).
Prompted the `save_config` merge fix above.
- `menus/addon_remote_access.sh`**Remote Access** (Addons): VNC,
WireGuard, Tailscale, Netbird. The biggest Addon so far.
- `menus/addon_lms_squeezelite.sh`**LMS Server / Squeezelite Player**
(Addons): install/reconfigure/uninstall for an LMS (Lyrion/Logitech
Media Server) server the kiosk can host, and a Squeezelite player the
kiosk can run against any LMS server on the LAN. Squeezelite's own
start script and systemd unit go through `$BIN_DIR`/`$SYSTEMD_DIR`
like every other addon; LMS's own apt repo/GPG key/ufw rules stay at
their real fixed system paths, same as CUPS.
- `install.sh` — entry point for the modular tool, now grouped **Core
Settings / Addons / Advanced** like the legacy menu. Run it against an
*already-installed* kiosk:
@@ -1226,14 +1233,25 @@ terminal menu and the web UI, so they can't drift apart).
**Honest status:** this does not yet replace first-time installation, or
most of the old installer. `ubuntu-based-kiosk.sh` is still ~12,000
lines and still contains its own unremoved, unmodified copies of every
menu above (plus Upgrade, Reinstall, Uninstall, 2 more Addons, and the
other 8 Advanced items — none of that has moved yet). Both copies
coexist deliberately: the old ones stay until enough
of Core Settings/Addons/Advanced is migrated to
retire them in one pass, rather than leaving the legacy menu half-wired.
Migration continues one `menus/*.sh` file at a time; first-time
installation itself is the last and largest piece to move, if it moves
at all.
menu above (plus Upgrade, Reinstall, Uninstall, 1 more Addon — Easy
Asterisk Intercom — and the other 8 Advanced items — none of that has
moved yet). Both copies coexist deliberately: the old ones stay until
enough of Core Settings/Addons/Advanced is migrated to retire them in
one pass, rather than leaving the legacy menu half-wired. Migration
continues one `menus/*.sh` file at a time; first-time installation
itself is the last and largest piece to move, if it moves at all.
**Resolved (v2.9.0):** `is_service_enabled()` — shared by both scripts
— had a pre-check (`systemctl list-unit-files | grep -q "^${service}\s"`)
that never actually matched, since every call site passes a bare
service name while `list-unit-files` lines start with
`"$service.service"`. The function always fell through to `return 1`
regardless of the real enabled state — under-reporting "enabled but not
currently running" as "not installed" everywhere it's used, including
LMS/Squeezelite's own status detection. Fixed in both `lib/config.sh`
and `ubuntu-based-kiosk.sh` by dropping the dead pre-check —
`systemctl is-enabled` already reports "not found" as a failure on its
own.
**Resolved (v2.7.0):** the config-clobbering bug fixed in `lib/config.sh`
(v2.6.0 — `save_config` silently deleting fields it doesn't know about,
@@ -1248,9 +1266,14 @@ full migration pass.
## Project Status & Future Plans
**Current Version:** 2.8.0
**Current Version:** 2.9.0
**Recent Updates (v2.8.0):**
**Recent Updates (v2.9.0):**
- **LMS Server / Squeezelite Player migrated** — install/reconfigure/uninstall for both, in `./install.sh`. Squeezelite's own start script and systemd unit now go through `$BIN_DIR`/`$SYSTEMD_DIR` like every other addon instead of hardcoded `/usr/local/bin`/`/etc/systemd/system`; LMS's own apt repo/GPG key/ufw rules stay at their real fixed system paths, same approach as CUPS.
- **Bug fix:** the legacy `install_lms()` enabled/started the detected service via `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log` — piped through `tee`, the statement's exit status reflected `tee` (always 0), not `systemctl enable`, so a real enable/start failure was silently swallowed instead of falling through to a warning. Now uses the shared `enable_and_start_units()` helper.
- **Bug fix (shared, backported to the legacy script too):** `is_service_enabled()`'s pre-check never matched a bare service name against `list-unit-files`' `"$service.service"` lines, so it always reported "not enabled" regardless of the real state. Dropped the dead pre-check — see "Modular Management" below.
**Previous (v2.8.0):**
- **Remote Access migrated** — VNC, WireGuard, Tailscale, and Netbird, each with its own install/connect/status/uninstall flow. The biggest Addon so far. Tailscale/Netbird install via the vendors' own `curl | sh` method, preserved as-is.
- **Important framework-level bug found and fixed:** `run_menu()`'s *handler* call has been crash-guarded since v2.1.0, but its *status function* call was still completely bare. A status function is meant to be read-only display, but a pipeline whose `grep` matches nothing (which `pipefail` turns into a failure even though the actual last command succeeds) would crash the **entire session**, not just fail to show status. Found while building `wireguard_status()` and verifying its exact failure mode rather than assuming it was covered. Fixed once, in the framework, protecting every status function across every menu — present and future. Also audited every existing status function for the same shape and fixed one real instance in `power_schedule_status()`.
- Deduplicated: promoted `power_schedule.sh`'s `enable_and_start_timers()` to a shared `enable_and_start_units()` in `lib/menu.sh` (works for services now, not just timers) rather than writing the same helper a second time for VNC/WireGuard.
+6 -3
View File
@@ -18,7 +18,8 @@
# Power/Display/Quiet Hours.
# Addons: CUPS Printing (menus/addon_cups.sh), Authelia Auto-Login
# (menus/addon_authelia.sh), Remote Access - VNC/WireGuard/
# Tailscale/Netbird (menus/addon_remote_access.sh).
# Tailscale/Netbird (menus/addon_remote_access.sh), LMS Server /
# Squeezelite Player (menus/addon_lms_squeezelite.sh).
# Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/
# audio/network).
#
@@ -58,6 +59,8 @@ source "$SCRIPT_DIR/menus/addon_cups.sh"
source "$SCRIPT_DIR/menus/addon_authelia.sh"
# shellcheck source=menus/addon_remote_access.sh
source "$SCRIPT_DIR/menus/addon_remote_access.sh"
# shellcheck source=menus/addon_lms_squeezelite.sh
source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh"
################################################################################
# Preflight
@@ -121,8 +124,8 @@ core_settings_menu() {
}
addons_menu_builder() {
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu)
MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player")
MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu)
}
addons_menu() {
+14
View File
@@ -78,6 +78,20 @@ is_service_active() {
systemctl is-active --quiet "$service" 2>/dev/null
}
# Whether a service is enabled (would start on boot), regardless of
# whether it's currently running. The legacy script's version of this
# pre-checked `systemctl list-unit-files | grep -q "^${service}\s"`
# before calling is-enabled - but every call site passes a bare service
# name (e.g. "squeezelite"), while list-unit-files lines start with
# "squeezelite.service", so that regex never matched and the legacy
# function always fell through to `return 1` no matter the real state.
# `systemctl is-enabled` already reports "not found" as a failure on its
# own, so the pre-check was both broken and unnecessary - dropped here.
is_service_enabled() {
local service="$1"
systemctl is-enabled --quiet "$service" 2>/dev/null
}
# Load every setting config.json has into the bash globals above.
# Safe to call with no existing config file - leaves script defaults in place.
load_existing_config() {
+344
View File
@@ -0,0 +1,344 @@
#!/bin/bash
################################################################################
# menus/addon_lms_squeezelite.sh - "LMS Server / Squeezelite Player" addon.
#
# Two independent pieces sharing one menu, same as the legacy code: an LMS
# (Lyrion/Logitech Media Server) server the kiosk can host, and a
# Squeezelite player the kiosk can run to play music from any LMS server
# (this one or another one on the LAN). LMS itself is a real apt-managed
# subsystem with its own fixed paths (repo file, GPG keyring, ufw rules,
# /etc/squeezeboxserver) - like CUPS, those get full command-level `sudo`/
# `wget`/`apt` stubbing in tests rather than relocation. Squeezelite's own
# start script and systemd unit are ours to place, so - like power_schedule
# and the other addons - they go through $BIN_DIR/$SYSTEMD_DIR (lib/
# config.sh) instead of hardcoded /usr/local/bin and /etc/systemd/system,
# so tests can point them at a scratch directory.
#
# LMS ships under two package/service names depending on version -
# "logitechmediaserver" (older) and "lyrionmusicserver" (the project's
# current name after its rename) - so detection and every service call
# has to check both.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
lms_service_name() {
if systemctl list-unit-files 2>/dev/null | grep -q "lyrionmusicserver.service"; then
echo "lyrionmusicserver"
elif systemctl list-unit-files 2>/dev/null | grep -q "logitechmediaserver.service"; then
echo "logitechmediaserver"
fi
}
lms_is_installed() {
is_service_active logitechmediaserver || is_service_enabled logitechmediaserver || \
is_service_active lyrionmusicserver || is_service_enabled lyrionmusicserver
}
lms_is_running() {
is_service_active logitechmediaserver || is_service_active lyrionmusicserver
}
squeezelite_is_installed() {
is_service_active squeezelite || is_service_enabled squeezelite
}
addon_lms_squeezelite_status() {
if lms_is_installed; then
echo "LMS Server: Installed"
if lms_is_running; then
echo " Status: Running"
else
echo " Status: Stopped"
fi
echo " Web: http://$(get_ip_address):9000"
echo
fi
if squeezelite_is_installed; then
local player_name="Unknown"
if [[ -f "$BIN_DIR/squeezelite-start.sh" ]]; then
player_name=$(grep '^PLAYER_NAME=' "$BIN_DIR/squeezelite-start.sh" 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "Unknown")
fi
echo "Squeezelite Player: Installed"
if is_service_active squeezelite; then
echo " Status: Running"
else
echo " Status: Stopped"
fi
echo " Name: $player_name"
echo
fi
echo " A server is needed to stream music. The kiosk can run the"
echo " server (if sufficient resources) or connect to another server."
}
addon_lms_squeezelite_menu_builder() {
MENU_LABELS=("Install/Configure LMS Server" "Install/Configure Squeezelite Player")
MENU_HANDLERS=(action_install_lms action_install_squeezelite)
if lms_is_installed; then
MENU_LABELS+=("Uninstall LMS Server")
MENU_HANDLERS+=(action_uninstall_lms)
fi
if squeezelite_is_installed; then
MENU_LABELS+=("Uninstall Squeezelite Player")
MENU_HANDLERS+=(action_uninstall_squeezelite)
fi
}
addon_lms_squeezelite_menu() {
run_menu "LMS SERVER / SQUEEZELITE PLAYER" addon_lms_squeezelite_menu_builder addon_lms_squeezelite_status
}
################################################################################
# Actions - LMS Server
################################################################################
action_install_lms() {
echo
if lms_is_installed; then
echo "LMS is already installed."
if ask_yes_no "Reconfigure port?" "n"; then
local new_port
new_port=$(ask_integer "New HTTP port" 9000 1 65535)
sudo sed -i "s/httpport:.*/httpport: $new_port/" /etc/squeezeboxserver/prefs/server.prefs 2>/dev/null || true
sudo systemctl restart lyrionmusicserver 2>/dev/null || sudo systemctl restart logitechmediaserver 2>/dev/null || true
log_success "LMS reconfigured on port $new_port"
fi
pause
return
fi
echo "Installing Lyrion Music Server..."
# Try the repository method first.
if wget -qO - https://debian.slimdevices.com/debian/squeezebox-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/lms-keyring.gpg 2>/dev/null; then
echo "deb [signed-by=/usr/share/keyrings/lms-keyring.gpg] http://debian.slimdevices.com/debian stable main" | sudo tee /etc/apt/sources.list.d/lms.list
# `|| true`: a bare, unguarded `apt update` failing here (bad
# mirror, no network) would otherwise crash the whole session
# under set -e instead of falling through to the direct-download
# fallback below, which is exactly the degrade path this is
# supposed to hit when the repository route doesn't work.
sudo apt update 2>/dev/null || true
if sudo apt install -y logitechmediaserver 2>/dev/null; then
log_success "LMS installed via repository"
else
log_warning "Repository install failed, trying direct download..."
fi
fi
# Fall back to a direct .deb download if the repository didn't produce
# either possible package.
if ! command -v logitechmediaserver &>/dev/null && ! command -v lyrionmusicserver &>/dev/null; then
local lms_deb="/tmp/lms.deb"
echo "Downloading LMS v9.0.3..."
if wget -q https://downloads.lms-community.org/LyrionMusicServer_v9.0.3/lyrionmusicserver_9.0.3_amd64.deb -O "$lms_deb"; then
echo "Installing LMS package..."
if sudo apt install -y "$lms_deb"; then
log_success "LMS installed via direct download"
else
log_error "Failed to install LMS package"
rm -f "$lms_deb"
pause
return 1
fi
rm -f "$lms_deb"
else
log_error "Failed to download LMS from lms-community.org"
pause
return 1
fi
fi
local service_name
service_name=$(lms_service_name)
if [[ -z "$service_name" ]]; then
log_warning "Service file not found, checking installed files..."
service_name=$(dpkg -L lyrionmusicserver logitechmediaserver 2>/dev/null | grep -m1 '\.service$' | xargs -r basename | sed 's/\.service$//' || echo "")
fi
if [[ -z "$service_name" ]]; then
log_error "Could not detect LMS service name"
echo "Manual steps:"
echo " 1. Find service: systemctl list-unit-files | grep -i lms"
echo " 2. Enable: sudo systemctl enable SERVICE_NAME"
echo " 3. Start: sudo systemctl start SERVICE_NAME"
pause
return 1
fi
log_info "Using service: $service_name"
# enable_and_start_units, not a bare `sudo systemctl enable ... | tee`
# pipe: the legacy version's `2>&1 | tee /tmp/lms-enable.log` made the
# whole statement's exit status depend on `tee` (always 0) rather than
# `systemctl enable`, so a real enable/start failure was silently
# swallowed instead of falling through to the warning below.
if enable_and_start_units "$service_name"; then
sudo ufw allow 9000/tcp comment 'LMS-HTTP' 2>/dev/null || true
sudo ufw allow 3483/tcp comment 'LMS-SlimProto' 2>/dev/null || true
sudo ufw allow 3483/udp comment 'LMS-Discovery' 2>/dev/null || true
log_success "LMS installed"
echo " Web interface: http://$(get_ip_address):9000"
else
log_warning "LMS installed, but systemctl enable/start failed - check 'systemctl status $service_name'"
fi
pause
}
action_uninstall_lms() {
echo
ask_yes_no "Remove LMS Server?" "n" || { echo "Cancelled"; pause; return; }
local service_name
service_name=$(lms_service_name)
if [[ -n "$service_name" ]]; then
echo "Stopping $service_name..."
sudo systemctl stop "$service_name" 2>/dev/null || true
sudo systemctl disable "$service_name" 2>/dev/null || true
fi
# Try to remove both possible package names - only one will actually
# be installed, the other is a harmless no-op.
sudo apt remove -y lyrionmusicserver 2>/dev/null || true
sudo apt remove -y logitechmediaserver 2>/dev/null || true
sudo rm -f /etc/apt/sources.list.d/lms.list
sudo rm -f /usr/share/keyrings/lms-keyring.gpg
if ask_yes_no "Remove LMS data and configuration?" "n"; then
sudo rm -rf /var/lib/squeezeboxserver
sudo rm -rf /etc/squeezeboxserver
log_success "LMS and data removed"
else
log_success "LMS removed (data preserved)"
fi
pause
}
################################################################################
# Actions - Squeezelite Player
################################################################################
action_install_squeezelite() {
echo
if squeezelite_is_installed; then
echo "Squeezelite is already installed."
ask_yes_no "Reconfigure?" "n" || { pause; return; }
fi
if ! command -v squeezelite &>/dev/null; then
if ! sudo apt install -y squeezelite; then
log_error "squeezelite package installation failed"
pause
return 1
fi
fi
local player_name
player_name=$(ask_text "Player name" "Kiosk")
echo
echo "LMS Server Configuration:"
echo " Enter IP:PORT of your LMS server"
echo " Leave blank for auto-discovery on LAN"
echo
local lms_server
lms_server=$(ask_text "LMS Server (e.g., 192.168.1.100:3483)" "")
sudo tee "$BIN_DIR/squeezelite-start.sh" > /dev/null <<SQSTART
#!/bin/bash
PLAYER_NAME="$player_name"
LMS_SERVER="$lms_server"
for i in {1..20}; do
pactl info >/dev/null 2>&1 && break
sleep 1
done
if ! pactl info >/dev/null 2>&1; then
logger "ERROR: Squeezelite - PipeWire not available"
exit 1
fi
if [[ -n "\$LMS_SERVER" ]]; then
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -s "\$LMS_SERVER" -o pulse -a 80:4:: -b 512:1024 -C 5
else
exec /usr/bin/squeezelite -n "\$PLAYER_NAME" -o pulse -a 80:4:: -b 512:1024 -C 5
fi
SQSTART
sudo chmod +x "$BIN_DIR/squeezelite-start.sh"
local kiosk_uid
kiosk_uid=$(id -u "$KIOSK_USER")
sudo tee "$SYSTEMD_DIR/squeezelite.service" > /dev/null <<EOF
[Unit]
Description=Squeezelite
After=sound.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$KIOSK_USER
Environment="XDG_RUNTIME_DIR=/run/user/$kiosk_uid"
ExecStartPre=/bin/sleep 10
ExecStart=${BIN_DIR}/squeezelite-start.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload 2>/dev/null || true
# Enable only, not start: squeezelite needs the kiosk user's real
# session (PipeWire, XDG_RUNTIME_DIR) up first, which is why a reboot
# is required below rather than starting it immediately.
if ! sudo systemctl enable squeezelite 2>/dev/null; then
log_warning "Squeezelite files written, but 'systemctl enable' failed - check 'systemctl status squeezelite'"
fi
log_success "Squeezelite installed: $player_name"
if [[ -n "$lms_server" ]]; then
echo " Server: $lms_server"
else
echo " Server: Auto-discovery"
fi
echo
echo "⚠️ IMPORTANT: Squeezelite requires a reboot to work properly"
echo
if ask_yes_no "Reboot now?" "n"; then
echo "Rebooting in 5 seconds..."
sleep 5
sudo reboot
else
echo "⚠️ Remember to reboot before using Squeezelite"
echo " Command: sudo reboot"
fi
pause
}
action_uninstall_squeezelite() {
echo
ask_yes_no "Remove Squeezelite Player?" "n" || { echo "Cancelled"; pause; return; }
sudo systemctl stop squeezelite 2>/dev/null || true
sudo systemctl disable squeezelite 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/squeezelite.service"
sudo rm -f "$BIN_DIR/squeezelite-start.sh"
sudo apt remove -y squeezelite 2>/dev/null || true
log_success "Squeezelite removed"
pause
}
+39 -8
View File
@@ -1,8 +1,37 @@
#!/bin/bash
################################################################################
### Ubuntu Based Kiosk v2.8.0 ###
### Ubuntu Based Kiosk v2.9.0 ###
################################################################################
#
# RELEASE v2.9.0 - LMS Server / Squeezelite Player Migrated;
# is_service_enabled() Dead Pre-Check Fixed
# - New in ./install.sh: LMS Server / Squeezelite Player
# (menus/addon_lms_squeezelite.sh) - install/reconfigure/uninstall for
# an LMS (Lyrion/Logitech Media Server) server the kiosk can host, and
# a Squeezelite player the kiosk can run against any LMS server on the
# LAN. Squeezelite's own start script and systemd unit now go through
# $BIN_DIR/$SYSTEMD_DIR (lib/config.sh) instead of hardcoded
# /usr/local/bin and /etc/systemd/system, matching every other addon;
# LMS's own apt repo/GPG key/ufw rules stay at their real fixed system
# paths, same as CUPS.
# - Fixed a real unguarded-pipeline bug from the legacy install_lms():
# `sudo systemctl enable "$service_name" 2>&1 | tee /tmp/lms-enable.log`
# made the whole statement's exit status depend on `tee` (always 0)
# instead of `systemctl enable`, so a real enable/start failure was
# silently swallowed rather than falling through to a warning. Now
# uses the shared enable_and_start_units() helper instead.
# - Fixed is_service_enabled() (shared by both scripts): its pre-check
# `systemctl list-unit-files | grep -q "^${service}\s"` never matched,
# since every call site passes a bare service name (e.g.
# "squeezelite") while list-unit-files lines start with
# "squeezelite.service" - so the function always fell through to
# `return 1` regardless of the real enabled state. `systemctl
# is-enabled` already reports "not found" as a failure on its own, so
# the dead pre-check is simply dropped. Backported here since it's the
# same shared function in both scripts and the fix is low-risk
# (behavior-preserving for every state except the one it was silently
# getting wrong).
#
# RELEASE v2.8.0 - Remote Access Migrated (VNC/WireGuard/Tailscale/
# Netbird); Framework-Level Status-Function Crash Fixed
# - New in ./install.sh: Remote Access (menus/addon_remote_access.sh) -
@@ -320,7 +349,7 @@ set -euo pipefail
### SECTION 1: CONSTANTS & GLOBALS
################################################################################
SCRIPT_VERSION="2.8.0"
SCRIPT_VERSION="2.9.0"
# Resolve the real path to this script file.
# When piped (curl|bash or wget|bash), BASH_SOURCE[0] is a pipe descriptor,
@@ -578,12 +607,14 @@ is_service_active() {
is_service_enabled() {
local service="$1"
# Check if service file exists first
if systemctl list-unit-files 2>/dev/null | grep -q "^${service}\s"; then
systemctl is-enabled --quiet "$service" 2>/dev/null
else
return 1
fi
# `systemctl is-enabled` already reports "not found" as a failure on
# its own - no need for (and no correct way to write, given every
# call site here passes a bare service name while list-unit-files
# lines start with "$service.service") a pre-check via
# list-unit-files. The previous "^${service}\s" pre-check never
# matched, so this function always fell through to `return 1`
# regardless of the real enabled state.
systemctl is-enabled --quiet "$service" 2>/dev/null
}
get_ip_address() {