node-install.sh: any systemd from 219, remove by name, list, uninstall

- Portability: "systemctl --now" (systemd 220) and "show --value" (230)
  are gone, "ss -H" too (older iproute2); apply creates the channel's
  state directory and the unit allows it with ReadWritePaths=-, so a
  channel runs where StateDirectory= is unknown (before 235). The updater
  (restart, liveness by MainPID, rollback) works on those systems too.
- remove CHANNEL removes one channel by name from the server's shell (the
  apps keep passing a config file); list prints the channels with their
  state and port; uninstall removes every channel and everything the
  script installed (updater, unit, cores, folders, node user, firewall
  ports), stopping channel instances whose config is already gone.
- mobile: OfferExitCookies (a sign-in handed to the exit) is removed; the
  account feature it served is not coming.
- CI: .github/workflows/node-install.yml runs TestInstallOnVDS on Ubuntu
  24.04/22.04/20.04, Debian 12/11, Rocky 9, Alma 8, Fedora 42, Arch and
  openSUSE Leap 15.6 in systemd containers (deploy/test/vds-dockerfile.sh);
  the test now also covers list, remove by name and uninstall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
p1neappleXpress
2026-09-29 21:31:54 +03:00
co-authored by Claude Opus 5.5
parent 96a0f95205
commit 768745dd7e
8 changed files with 274 additions and 99 deletions
+87
View File
@@ -0,0 +1,87 @@
name: Node install
# deploy/node-install.sh on the distributions a VDS runs: the wizard's whole
# path over SSH (provision.TestInstallOnVDS) in a systemd container per
# image - plan, apply with a sudo password, the updater (update, rollback
# from a crashing core, skip), remove, list and uninstall.
on:
push:
branches: [main]
paths:
- deploy/**
- provision/**
- .github/workflows/node-install.yml
pull_request:
paths:
- deploy/**
- provision/**
- .github/workflows/node-install.yml
workflow_dispatch:
jobs:
vds:
name: ${{ matrix.image }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
image:
- ubuntu:24.04
- ubuntu:22.04
- ubuntu:20.04
- debian:12
- debian:11
- rockylinux/rockylinux:9
- almalinux:8
- fedora:42
- archlinux:latest
- opensuse/leap:15.6
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build the node core
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -tags exitnode \
-ldflags="-s -w -buildid=" -o "$RUNNER_TEMP/core" .
echo "CORE_SHA=$(sha256sum "$RUNNER_TEMP/core" | cut -d' ' -f1)" >> "$GITHUB_ENV"
- name: Start the VDS
run: |
sh deploy/test/vds-dockerfile.sh "${{ matrix.image }}" > "$RUNNER_TEMP/Dockerfile"
docker build -q -t vds -f "$RUNNER_TEMP/Dockerfile" "$RUNNER_TEMP"
docker run -d --name vds --privileged --cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /run/lock vds
for i in $(seq 1 60); do
state=$(docker exec vds systemctl is-system-running 2>/dev/null || true)
case "$state" in running|degraded) break ;; esac
sleep 1
done
echo "systemd: $state"
docker exec vds sh -c 'systemctl --version | head -n 1; cat /etc/os-release | grep PRETTY'
docker exec vds sh -c 'systemctl is-active sshd 2>/dev/null || systemctl is-active ssh'
# The core the test expects already installed (see TestInstallOnVDS).
docker exec vds mkdir -p /opt/openflux-node/bin
docker cp "$RUNNER_TEMP/core" vds:/opt/openflux-node/bin/openflux-node-v1.0.1
docker exec vds sh -c 'chmod 0755 /opt/openflux-node /opt/openflux-node/bin /opt/openflux-node/bin/openflux-node-v1.0.1 && ln -sfn openflux-node-v1.0.1 /opt/openflux-node/bin/openflux'
cp "$RUNNER_TEMP/core" core.bin
- name: TestInstallOnVDS
run: |
go_version=$(sed -n 's/^go \([0-9]*\.[0-9]*\).*/\1/p' go.mod)
docker run --rm --network container:vds -v "$PWD:/src" -w /src \
-e OPENFLUX_TEST_SSH=127.0.0.1:22 \
-e OPENFLUX_TEST_ROOT_PASSWORD=rootpass \
-e OPENFLUX_TEST_USER=deploy:deploypass \
-e OPENFLUX_TEST_CORE_SHA="$CORE_SHA" \
-e OPENFLUX_TEST_CORE_FILE=/src/core.bin \
-e OPENFLUX_TEST_DOC=https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrSt \
"golang:$go_version" go test ./provision -run TestInstallOnVDS -count=1 -v -timeout 20m
- name: VDS logs
if: failure()
run: |
docker exec vds sh -c 'journalctl --no-pager -n 200 -u "openflux-node*" ; systemctl list-units --all "openflux*" --no-pager' || true
+1
View File
@@ -60,3 +60,4 @@ watch_stats.sh
*.log
openflux
*.bak
core.bin
+101 -25
View File
@@ -23,6 +23,12 @@
# Usage: node-install.sh probe
# node-install.sh plan|status (config on stdin)
# node-install.sh apply|remove CONFIG_FILE (run as root)
# node-install.sh remove CHANNEL (run as root: remove one
# channel by name)
# node-install.sh list (the channels and their state)
# node-install.sh uninstall (run as root: remove every
# channel and everything this
# script installed)
# node-install.sh upgrade (run as root: move every
# channel to this core)
# node-install.sh set-cookies CONFIG_FILE (run as root: replace a
@@ -42,6 +48,9 @@
# sudo read the config as further password attempts). Secrets never appear
# in arguments, so they stay out of ps and shell history.
# Output is one JSON object on stdout.
#
# Works with any systemd from 219 (CentOS 7) on: no "systemctl --now" or
# "show --value", and the unit runs without StateDirectory= too.
set -u
umask 077
@@ -98,6 +107,18 @@ json_list() {
have() { command -v "$1" >/dev/null 2>&1; }
# unit_start / unit_stop UNIT: enable and start, stop and disable, in two
# calls each: "systemctl --now" needs systemd 220.
unit_start() { systemctl enable "$1" >/dev/null 2>&1 && systemctl start "$1" >/dev/null 2>&1; }
unit_stop() {
systemctl stop "$1" >/dev/null 2>&1
systemctl disable "$1" >/dev/null 2>&1
return 0
}
# main_pid UNIT: its main process id, 0 when none ("show --value" needs 230).
main_pid() { systemctl show -p MainPID "$1" 2>/dev/null | sed -n 's/^MainPID=//p'; }
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
@@ -213,7 +234,8 @@ release_repo() {
port_busy() { # PORT
if have ss; then
[ -n "$(ss -Hltn "sport = :$1" 2>/dev/null)" ]
# No -H: older iproute2 lacks it; the first line is the header.
[ -n "$(ss -ltn "sport = :$1" 2>/dev/null | tail -n +2)" ]
elif have netstat; then
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$1\$"
else
@@ -410,7 +432,7 @@ cmd_plan() {
CREATED_USER=0; CREATED_UNIT=0; CREATED_BIN=0; CREATED_CONF=0; CREATED_FW=""; STARTED=0
rollback() {
[ "$STARTED" = 1 ] && systemctl disable --now "openflux-node@$CHANNEL" >/dev/null 2>&1
[ "$STARTED" = 1 ] && unit_stop "openflux-node@$CHANNEL"
case "$CREATED_FW" in
ufw) ufw delete allow "$PORT/tcp" >/dev/null 2>&1 ;;
firewalld) firewall-cmd --permanent --remove-port="$PORT/tcp" >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1 ;;
@@ -468,6 +490,8 @@ Type=simple
User=$NODE_USER
Group=$NODE_USER
StateDirectory=openflux-node/%i
# apply creates the directory too: systemd before 235 ignores StateDirectory=.
ReadWritePaths=-$STATE_ROOT/%i
WorkingDirectory=$STATE_ROOT/%i
ExecStart=$BIN_DIR/openflux --config $CONF_ROOT/%i/node.conf
Restart=always
@@ -538,11 +562,11 @@ RandomizedDelaySec=30min
WantedBy=timers.target
EOF
chmod 0644 "$UPDATE_SERVICE" "$UPDATE_TIMER"
systemctl daemon-reload >/dev/null 2>&1 && systemctl enable --now openflux-node-update.timer >/dev/null 2>&1
systemctl daemon-reload >/dev/null 2>&1 && unit_start openflux-node-update.timer
}
disable_updater() {
systemctl disable --now openflux-node-update.timer >/dev/null 2>&1
unit_stop openflux-node-update.timer
for f in "$UPDATE_TIMER" "$UPDATE_SERVICE"; do
[ -f "$f" ] && grep -qF "$MARKER" "$f" && rm -f "$f"
done
@@ -622,6 +646,9 @@ cmd_apply() {
chmod 0751 "$dir"
chmod 0640 "$dir/encryption-key" "$dir/node.conf"
chmod 0644 "$dir/port"
# The node's state directory (systemd before 235 does not make it).
mkdir -p "$STATE_ROOT/$CHANNEL" && chmod 0755 "$STATE_ROOT" && chown "$NODE_USER:$NODE_USER" "$STATE_ROOT/$CHANNEL" \
&& chmod 0750 "$STATE_ROOT/$CHANNEL" || apply_fail config "не удалось создать $STATE_ROOT/$CHANNEL"
if [ -n "$COOKIES" ]; then
write_cookies || apply_fail cookies "не удалось сохранить вход в Яндекс на сервере"
fi
@@ -644,7 +671,7 @@ cmd_apply() {
esac
[ -n "$CREATED_FW" ] && printf '%s %s\n' "$CREATED_FW" "$PORT" > "$dir/firewall"
systemctl enable --now "openflux-node@$CHANNEL" >/dev/null 2>&1 \
unit_start "openflux-node@$CHANNEL" \
|| apply_fail start "не удалось запустить openflux-node@$CHANNEL"
STARTED=1
sleep 4
@@ -659,32 +686,79 @@ cmd_apply() {
printf '{"ok":true,"channel":"%s","port":%s,"core":"%s","autoupdate":%s}\n' "$CHANNEL" "$PORT" "$(core_to_use)" "$autoupdate"
}
cmd_remove() {
[ "$(id -u)" = 0 ] || fail remove "нужны права root (sudo)"
read_config "$@"
check_channel
dir="$CONF_ROOT/$CHANNEL"
[ -d "$dir" ] || fail remove "канала $CHANNEL нет на сервере"
systemctl disable --now "openflux-node@$CHANNEL" >/dev/null 2>&1
if [ -f "$dir/firewall" ]; then
read -r kind port < "$dir/firewall"
# remove_channel CHANNEL: stops the channel, closes its firewall port and
# deletes its config and state.
remove_channel() {
unit_stop "openflux-node@$1"
if [ -f "$CONF_ROOT/$1/firewall" ]; then
read -r kind port < "$CONF_ROOT/$1/firewall"
case "$kind" in
ufw) ufw delete allow "$port/tcp" >/dev/null 2>&1 ;;
firewalld) firewall-cmd --permanent --remove-port="$port/tcp" >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1 ;;
esac
fi
rm -rf "${CONF_ROOT:?}/$CHANNEL" "${STATE_ROOT:?}/$CHANNEL"
if [ -z "$(list_channels)" ]; then
# The last channel is gone: remove everything this script installed.
disable_updater
rm -f "$UNIT_FILE"
systemctl daemon-reload >/dev/null 2>&1
rm -rf /opt/openflux-node "$CONF_ROOT" "$STATE_ROOT"
userdel "$NODE_USER" >/dev/null 2>&1
rm -rf "${CONF_ROOT:?}/$1" "${STATE_ROOT:?}/$1"
}
# remove_everything: what this script installed besides the channels: the
# updater, the unit template, the cores, the folders and the node user.
# Files that do not carry this script's marker are left alone.
remove_everything() {
disable_updater
[ -f "$UNIT_FILE" ] && grep -qF "$MARKER" "$UNIT_FILE" && rm -f "$UNIT_FILE"
systemctl daemon-reload >/dev/null 2>&1
systemctl reset-failed 'openflux-node@*' >/dev/null 2>&1
rm -rf /opt/openflux-node "$CONF_ROOT" "$STATE_ROOT"
id "$NODE_USER" >/dev/null 2>&1 && userdel "$NODE_USER" >/dev/null 2>&1
return 0
}
# remove CONFIG_FILE (the apps: channel= in a temp file) or remove CHANNEL
# (by hand on the server).
cmd_remove() {
[ "$(id -u)" = 0 ] || fail remove "нужны права root (sudo)"
if [ $# -gt 0 ] && [ ! -f "$1" ] && valid_channel "$1"; then
CHANNEL=$1
else
read_config "$@"
fi
check_channel
[ -d "$CONF_ROOT/$CHANNEL" ] || fail remove "канала $CHANNEL нет на сервере"
remove_channel "$CHANNEL"
# The last channel is gone: remove everything this script installed.
[ -z "$(list_channels)" ] && remove_everything
printf '{"ok":true,"channel":"%s"}\n' "$CHANNEL"
}
# uninstall: every channel, then everything this script installed. Also
# stops channel instances whose config is already gone.
cmd_uninstall() {
[ "$(id -u)" = 0 ] || fail uninstall "нужны права root (sudo)"
set --
for ch in $(list_channels); do
remove_channel "$ch"
set -- "$@" "$ch"
done
for unit in $(systemctl list-units --all --plain --no-legend 'openflux-node@*' 2>/dev/null | awk '{print $1}'); do
unit_stop "$unit"
done
remove_everything
printf '{"ok":true,"removed":%s}\n' "$(json_list "$@")"
}
# list: the channels, their state and port; readable without root.
cmd_list() {
printf '{"ok":true,"core":"%s","autoupdate":%s,"channels":[' "$(installed_core)" "$(autoupdate_on && echo true || echo false)"
first=1
for ch in $(list_channels); do
[ "$first" = 1 ] || printf ','
first=0
port=$(cat "$CONF_ROOT/$ch/port" 2>/dev/null)
printf '{"channel":"%s","state":"%s","port":%s}' "$ch" "$(json_escape "$(systemctl is-active "openflux-node@$ch" 2>/dev/null)")" "${port:-0}"
done
printf ']}\n'
}
# upgrade: switches every channel to this script's core and restarts the
# running ones. Configs, keys and ports stay as they are.
cmd_upgrade() {
@@ -746,12 +820,12 @@ restart_channels() {
channels_stay_up() {
sleep 3
pids=""
for ch in "$@"; do pids="$pids $ch=$(systemctl show -p MainPID --value "openflux-node@$ch" 2>/dev/null)"; done
for ch in "$@"; do pids="$pids $ch=$(main_pid "openflux-node@$ch")"; done
sleep 20
for pair in $pids; do
ch=${pair%%=*}; pid=${pair#*=}
systemctl is-active --quiet "openflux-node@$ch" || return 1
[ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" = "$(systemctl show -p MainPID --value "openflux-node@$ch" 2>/dev/null)" ] || return 1
[ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" = "$(main_pid "openflux-node@$ch")" ] || return 1
done
}
@@ -869,10 +943,12 @@ case "${1:-}" in
plan) cmd_plan ;;
apply) shift; cmd_apply "$@" ;;
remove) shift; cmd_remove "$@" ;;
uninstall) cmd_uninstall ;;
list) cmd_list ;;
status) cmd_status ;;
upgrade) cmd_upgrade ;;
set-cookies) shift; cmd_set_cookies "$@" ;;
update) cmd_update ;;
autoupdate) shift; cmd_autoupdate "$@" ;;
*) fail usage "usage: node-install.sh probe|plan|apply|remove|status|upgrade|set-cookies|update|autoupdate" ;;
*) fail usage "usage: node-install.sh probe|plan|apply|remove|list|uninstall|status|upgrade|set-cookies|update|autoupdate" ;;
esac
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# vds-dockerfile.sh BASE_IMAGE: a Dockerfile (on stdout) for a test "VDS"
# from a stock distribution image: systemd as PID 1, sshd with root and
# password logins, and a sudoer "deploy" whose sudo needs its password -
# what provision.TestInstallOnVDS drives node-install.sh on.
# Passwords: root "rootpass", deploy "deploypass".
set -eu
base=$1
cat <<DOCKERFILE
FROM $base
ENV container=docker
RUN set -eu; \\
if command -v apt-get >/dev/null; then \\
export DEBIAN_FRONTEND=noninteractive; apt-get update; \\
apt-get install -y --no-install-recommends systemd systemd-sysv dbus openssh-server sudo curl ca-certificates iproute2 procps passwd; \\
admin=sudo; \\
elif command -v dnf >/dev/null; then \\
dnf install -y systemd openssh-server sudo iproute procps-ng passwd shadow-utils findutils which; \\
command -v curl >/dev/null || dnf install -y curl; \\
admin=wheel; \\
elif command -v yum >/dev/null; then \\
yum install -y systemd openssh-server sudo curl iproute procps-ng passwd shadow-utils which; \\
admin=wheel; \\
elif command -v pacman >/dev/null; then \\
pacman -Syu --noconfirm --needed systemd openssh sudo curl iproute2 procps-ng shadow; \\
admin=wheel; \\
elif command -v zypper >/dev/null; then \\
zypper --non-interactive install systemd openssh sudo curl iproute2 procps shadow; \\
admin=wheel; groupadd -f wheel; \\
sed -i -e 's/^Defaults targetpw/# &/' -e 's/^ALL[[:space:]]*ALL=(ALL) ALL/# &/' /etc/sudoers; \\
else echo "unknown package manager" >&2; exit 1; fi; \\
echo "%\$admin ALL=(ALL) ALL" > /etc/sudoers.d/90-test; chmod 0440 /etc/sudoers.d/90-test; \\
echo root:rootpass | chpasswd; \\
useradd -m -s /bin/sh deploy; usermod -aG "\$admin" deploy; echo deploy:deploypass | chpasswd; \\
ssh-keygen -A; \\
sed -i '1i PermitRootLogin yes\\nPasswordAuthentication yes\\nUsePAM yes' /etc/ssh/sshd_config; \\
rm -f /etc/ssh/sshd_config.d/*.conf 2>/dev/null || true; \\
rm -f /run/nologin /etc/nologin; \\
systemctl enable sshd 2>/dev/null || systemctl enable ssh; \\
systemctl mask getty.target console-getty.service systemd-firstboot.service 2>/dev/null || true; \\
ln -sf "\$(ls /lib/systemd/systemd /usr/lib/systemd/systemd 2>/dev/null | head -n 1)" /usr/local/sbin/test-init
STOPSIGNAL SIGRTMIN+3
CMD ["/usr/local/sbin/test-init"]
DOCKERFILE
-59
View File
@@ -1,59 +0,0 @@
package mobile
import (
"fmt"
"strings"
"sync"
"github.com/p1neappleXpress/OpenFlux/transport/manager"
)
// clientSession is the running client Session's manager and its
// transports' types by name, so the app can hand the exit a sign-in.
var clientSession struct {
mu sync.Mutex
m *manager.Manager
types map[string]string
}
func setClientSession(m *manager.Manager, types map[string]string) {
clientSession.mu.Lock()
clientSession.m, clientSession.types = m, types
clientSession.mu.Unlock()
}
// OfferExitCookies sends a sign-in (a Cookie header, "a=1; b=2") to the
// exit for every transport of the client Session whose type is in
// transportTypes (comma-separated, e.g. "vyandex,yandex,boards"). The exit
// applies and keeps it. Returns how many transports it went to, or an
// error when no Session is connected.
func OfferExitCookies(transportTypes, cookieHeader string) (int, error) {
jar := parseCookieHeader(cookieHeader)
if len(jar) == 0 {
return 0, fmt.Errorf("нет cookies")
}
wanted := map[string]bool{}
for _, t := range strings.Split(transportTypes, ",") {
if t = strings.TrimSpace(t); t != "" {
wanted[t] = true
}
}
clientSession.mu.Lock()
m, types := clientSession.m, clientSession.types
clientSession.mu.Unlock()
if m == nil || !m.IsConnected() {
return 0, fmt.Errorf("нет подключения к ноде")
}
sent := 0
for name, typ := range types {
if !wanted[typ] {
continue
}
if err := m.OfferCookies(name, jar); err != nil {
return sent, fmt.Errorf("%s: %w", name, err)
}
sent++
}
appendLog(fmt.Sprintf("[ANDROID] Вход передан ноде: %d транспорт(ов)", sent))
return sent, nil
}
-13
View File
@@ -1,13 +0,0 @@
package mobile
import "testing"
func TestOfferExitCookiesWithoutSession(t *testing.T) {
setClientSession(nil, nil)
if _, err := OfferExitCookies("vyandex", "Session_id=s"); err == nil {
t.Fatal("want an error without a connected Session")
}
if _, err := OfferExitCookies("vyandex", ""); err == nil {
t.Fatal("want an error without cookies")
}
}
-1
View File
@@ -180,7 +180,6 @@ func buildSessionWith(specsJSON, secret string, exit bool, opt sessionOptions) (
proxy := &authProxy{demux: demux}
setAuthProxy(proxy)
attachSessionCaptcha(m, keys, proxy)
setClientSession(m, types)
appendLog("[ANDROID] Session: шифрование AES-256-GCM, согласование с нодой")
return demux, sess, nil
}
+41 -1
View File
@@ -74,7 +74,7 @@ func TestInstallOnVDS(t *testing.T) {
good = Pinned()
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
root := Target{Host: host, Port: port, User: "root", Password: os.Getenv("OPENFLUX_TEST_ROOT_PASSWORD")}
@@ -290,6 +290,46 @@ func TestInstallOnVDS(t *testing.T) {
if out, _, _ := c.run("test -e /etc/systemd/system/openflux-node-update.timer && echo left", nil); strings.TrimSpace(string(out)) != "" {
t.Fatal("updater left after the last channel was removed")
}
// By hand on the server: list, remove one channel by name, uninstall.
if err := c.FetchScript(good); err != nil {
t.Fatal(err)
}
for _, name := range []string{"first", "second"} {
p, err := c.Plan(Channel{ID: name, AutoUpdate: true}, false)
if err != nil {
t.Fatal(err)
}
k, _ := NewKey()
if err := c.Apply(Channel{ID: name, Key: k, Port: p.Port, AutoUpdate: true}, userPass); err != nil {
t.Fatal(err)
}
}
if out := sudo("stat -c '%a %U' /var/lib/openflux-node/first"); out != "750 openflux-node" {
t.Fatalf("state directory: %q", out)
}
if out := sudo("sh /opt/openflux-node/node-install.sh list"); !strings.Contains(out, `"channel":"first","state":"active"`) ||
!strings.Contains(out, `"channel":"second","state":"active"`) || !strings.Contains(out, `"autoupdate":true`) {
t.Fatalf("list: %s", out)
}
if out := sudo("sh /opt/openflux-node/node-install.sh remove first"); !strings.Contains(out, `"ok":true`) {
t.Fatalf("remove by name: %s", out)
}
if out := sudo("test -e /etc/openflux-node/first && echo left; systemctl is-active openflux-node@first; systemctl is-active openflux-node@second"); out != "inactive\nactive" {
t.Fatalf("after remove first: %q", out)
}
if out := sudo("sh /opt/openflux-node/node-install.sh remove nosuch"); !strings.Contains(out, `"ok":false`) {
t.Fatalf("remove of a missing channel: %s", out)
}
if out := sudo("sh /opt/openflux-node/node-install.sh uninstall"); !strings.Contains(out, `"removed":["second"]`) {
t.Fatalf("uninstall: %s", out)
}
left := sudo("for p in /opt/openflux-node /etc/openflux-node /var/lib/openflux-node /etc/systemd/system/openflux-node@.service " +
"/etc/systemd/system/openflux-node-update.timer /etc/systemd/system/openflux-node-update.service; do test -e $p && echo $p; done; " +
"id openflux-node >/dev/null 2>&1 && echo user; systemctl is-active openflux-node@second; ps -eo args | grep -c '[o]penflux --config'")
if left != "inactive\n0" {
t.Fatalf("left after uninstall: %q", left)
}
}
// fakeReleases is GitHub for the updater: the releases listing, each