Read a backup repository from the WebUI, and stop adoption breaking things
restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.
restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.
A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.
Three things found by using it:
- locationRemove never worked. It unlinked as the container user, but
configs/ is manager-owned, so it was always denied — and the result was
never checked, so isSuccessful printed anyway and a "removed" location came
back on the next listing. Now runInstallOp, and the directory is checked.
- webuiSecretSweep had no callers. An abandoned flow left its repository
password on disk forever. The sweep now runs in /api/setup/secret before
each write, tied to the one event guaranteed to happen.
- Adoption took the WebUI down. config-adopt chowned every adopted file to
manager:manager 0640, and webui_logins is bind-mounted into the container,
which then could not read its own credentials: exit 137 with no log line.
It also clamped every parent directory it passed through, closing
configs/webui and configs/backup to the container user.
The fix is a principle, not a special case: a restore replaces the CONTENT
of a config file and nothing else. The live install already knows who may
read each one. Adoption preserves the destination's ownership and mode,
defaults closed only for a file that did not exist, and never
re-permissions a directory it passes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3fbc997a2d
commit
5dd763713d
@ -189,6 +189,21 @@ router.post('/secret', requireAuth, async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
|
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sweep before writing. A reference is redeemed once by the applier, but a
|
||||||
|
// flow the user abandons — closed the tab, hit a validation error, never
|
||||||
|
// pressed Save — leaves its secret behind, and these are repository
|
||||||
|
// passwords. webuiSecretSweep exists for this and had no callers at all,
|
||||||
|
// so nothing ever ran it; doing it here ties the cleanup to the one event
|
||||||
|
// that is guaranteed to happen whenever secrets are being created.
|
||||||
|
try {
|
||||||
|
const cutoff = Date.now() - 15 * 60 * 1000;
|
||||||
|
for (const name of await fsp.readdir(SECRET_DIR)) {
|
||||||
|
const f = path.join(SECRET_DIR, name);
|
||||||
|
const st = await fsp.stat(f).catch(() => null);
|
||||||
|
if (st && st.isFile() && st.mtimeMs < cutoff) await fsp.unlink(f).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* a sweep that fails must never block storing the new value */ }
|
||||||
|
|
||||||
const id = require('crypto').randomBytes(16).toString('hex');
|
const id = require('crypto').randomBytes(16).toString('hex');
|
||||||
const file = path.join(SECRET_DIR, id);
|
const file = path.join(SECRET_DIR, id);
|
||||||
// 0640 explicitly rather than relying on the process umask: owner writes,
|
// 0640 explicitly rather than relying on the process umask: owner writes,
|
||||||
|
|||||||
@ -410,6 +410,61 @@ an essay attached and no lookup could match. And `updateConfigOption` writes an
|
|||||||
empty value as a literal `""`, so nine cleared slots read back as nine
|
empty value as a literal `""`, so nine cleared slots read back as nine
|
||||||
two-character domains and were reported as nine failures.
|
two-character domains and were reported as nine failures.
|
||||||
|
|
||||||
|
### 3.10 — Reading a repository without restoring it
|
||||||
|
|
||||||
|
`restore inspect <idx> [host]` answers "what would a restore from here bring?"
|
||||||
|
without writing anything: which machines' backups are in the repository, which
|
||||||
|
apps and how big, and — the part that is least obvious to get at — **which
|
||||||
|
domains**. Those live in the system-config snapshot, so `engineDumpFile` pulls
|
||||||
|
`network/network_domains` straight out of it, the same way the preflight pulls
|
||||||
|
an app's manifest. Knowing "this backup hands you six domains, four of which
|
||||||
|
point somewhere else" before committing is the difference between a rebuild and
|
||||||
|
a surprise.
|
||||||
|
|
||||||
|
`restore connect <base64-json>` is the WebUI's entry point: it creates the
|
||||||
|
location from a payload, redeems the repository password from the single-use
|
||||||
|
secret channel, and inspects. It deliberately does **not** call
|
||||||
|
`engineInitLocation` — every other path that creates a location initialises it
|
||||||
|
because it is about to write there; this one is pointed at a repository that
|
||||||
|
already exists and is only going to be read.
|
||||||
|
|
||||||
|
This is what unblocks §4. `app_portable.sh` records that a `.lpapp` can live in
|
||||||
|
the WebUI *because* it is unencrypted and no password has to cross from the
|
||||||
|
browser to the host — and that the repository restore therefore could not. The
|
||||||
|
`secret:<ref>` channel is that missing piece, so the constraint no longer holds.
|
||||||
|
|
||||||
|
A wrong password is the ordinary case here and the user simply tries again, so
|
||||||
|
a failed connect removes the location it just created. Without that, every
|
||||||
|
retry left another half-configured destination behind and the Backup page grew
|
||||||
|
a column of identical dead entries.
|
||||||
|
|
||||||
|
### 3.11 — Three more found by using it
|
||||||
|
|
||||||
|
**`locationRemove` never worked.** It removed with `runFileOp` — the container
|
||||||
|
user — but `configs/` is manager-owned, so the unlink was always denied. The
|
||||||
|
result was never checked, and `isSuccessful` printed regardless, so a location
|
||||||
|
"removed" from the WebUI came straight back on the next listing. Now
|
||||||
|
`runInstallOp`, and the directory is checked before claiming anything.
|
||||||
|
|
||||||
|
**`webuiSecretSweep` had no callers.** Written for exactly this and never
|
||||||
|
wired in, so a flow the user abandoned — closed the tab, hit a validation
|
||||||
|
error, never pressed Save — left its repository password on disk indefinitely.
|
||||||
|
The sweep now runs in the `/api/setup/secret` route before each write, which
|
||||||
|
ties it to the one event guaranteed to happen whenever secrets are being made.
|
||||||
|
|
||||||
|
**Adoption took the WebUI down.** `config-adopt` chowned every adopted file to
|
||||||
|
`manager:manager 0640`. `webui_logins` is bind-mounted into the WebUI
|
||||||
|
container, which then could not read its own credentials file: the container
|
||||||
|
died with exit 137 and *no log line at all*, which is a genuinely hard failure
|
||||||
|
to read. It also clamped every parent directory it passed through, closing
|
||||||
|
`configs/webui` and `configs/backup` to the container user.
|
||||||
|
|
||||||
|
The fix is a principle rather than a special case: **a restore replaces the
|
||||||
|
content of a config file and nothing else.** The live install already knows who
|
||||||
|
is allowed to read each one. Adoption now preserves the destination's existing
|
||||||
|
ownership and mode, defaults closed only for a file that did not exist, and
|
||||||
|
never re-permissions a directory it merely passes through.
|
||||||
|
|
||||||
## 4. The password problem, stated plainly
|
## 4. The password problem, stated plainly
|
||||||
|
|
||||||
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
||||||
|
|||||||
@ -20,7 +20,18 @@ locationRemove()
|
|||||||
local name_var="CFG_BACKUP_LOC_${idx}_NAME"
|
local name_var="CFG_BACKUP_LOC_${idx}_NAME"
|
||||||
local label="${!name_var:-Location $idx}"
|
local label="${!name_var:-Location $idx}"
|
||||||
|
|
||||||
runFileOp rm -rf "$dir"
|
# runInstallOp, not runFileOp: configs/ is manager-owned and the container
|
||||||
|
# user this used to run as cannot unlink inside it. The removal failed
|
||||||
|
# every time while the success message below printed anyway, so a location
|
||||||
|
# "removed" from the WebUI came straight back on the next listing.
|
||||||
|
runInstallOp rm -rf "$dir"
|
||||||
|
|
||||||
|
# And check. An unconditional isSuccessful after an unchecked command is
|
||||||
|
# what hid this: the only evidence a person ever saw was the message.
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
isError "Location $idx '$label' could not be removed — $dir is still there."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Best-effort unset of the now-orphaned env vars so other code in this
|
# Best-effort unset of the now-orphaned env vars so other code in this
|
||||||
# process doesn't see stale values.
|
# process doesn't see stale values.
|
||||||
|
|||||||
@ -41,6 +41,18 @@ cliHandleRestoreCommands()
|
|||||||
# staging dir; never overwrites live config. Optional location idx.
|
# staging dir; never overwrites live config. Optional location idx.
|
||||||
backupRestoreSystemConfig "$action"
|
backupRestoreSystemConfig "$action"
|
||||||
;;
|
;;
|
||||||
|
connect)
|
||||||
|
# Connect a repository from a base64 JSON payload and report what
|
||||||
|
# it holds. The WebUI's restore branch; see restore_inspect.sh.
|
||||||
|
# restore connect <base64-json>
|
||||||
|
restoreConnectInspect "$action"
|
||||||
|
;;
|
||||||
|
inspect)
|
||||||
|
# Report what a restore from this repository would bring, without
|
||||||
|
# writing anything.
|
||||||
|
# restore inspect <location-idx> [host]
|
||||||
|
restoreInspect "$action" "$name"
|
||||||
|
;;
|
||||||
adopt)
|
adopt)
|
||||||
# Copy a staged system config into the live one. Only on a machine
|
# Copy a staged system config into the live one. Only on a machine
|
||||||
# with nothing on it yet, unless --force. This is the step that
|
# with nothing on it yet, unless --force. This is the step that
|
||||||
|
|||||||
@ -125,6 +125,53 @@ else
|
|||||||
echo " SKIP $H not installed"
|
echo " SKIP $H not installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "adoption preserves what the destination already was"
|
||||||
|
# The whole reason this is checked rather than assumed: config-adopt used to
|
||||||
|
# chown every adopted file to manager:manager 0640. webui_logins is
|
||||||
|
# bind-mounted into the WebUI container, which then could not read its own
|
||||||
|
# credentials file — the container died with exit 137 and no log line at all.
|
||||||
|
# A restore replaces the CONTENT of a config file; the live install already
|
||||||
|
# knows who is allowed to read it.
|
||||||
|
H=/usr/local/lib/libreportal/libreportal-ownership
|
||||||
|
if [[ -x "$H" ]]; then
|
||||||
|
stage=$(mktemp -d); mkdir -p "$stage/webui"; echo "adopted=yes" > "$stage/webui/webui_logins"
|
||||||
|
target="$CONFIGS/webui/webui_logins"
|
||||||
|
if [[ -f "$target" ]]; then
|
||||||
|
want="$(stat -c '%a %U:%G' "$target")"
|
||||||
|
keep=$(mktemp); cp -a "$target" "$keep"
|
||||||
|
"$H" config-adopt "$stage" "webui/webui_logins" >/dev/null 2>&1
|
||||||
|
got="$(stat -c '%a %U:%G' "$target")"
|
||||||
|
chk "ownership and mode unchanged" "$got" "$want"
|
||||||
|
grep -q '^adopted=yes$' "$target" && echo " ok the content was replaced" \
|
||||||
|
|| { echo " FAIL the content was not replaced"; fail=1; }
|
||||||
|
cat "$keep" > "$target"; chmod "${want%% *}" "$target"; chown "${want##* }" "$target"; rm -f "$keep"
|
||||||
|
else
|
||||||
|
echo " SKIP no webui_logins to test against"
|
||||||
|
fi
|
||||||
|
# A file that does not exist yet gets the closed default.
|
||||||
|
mkdir -p "$stage/general"; echo "n=1" > "$stage/general/lp_test_new_file"
|
||||||
|
"$H" config-adopt "$stage" "general/lp_test_new_file" >/dev/null 2>&1
|
||||||
|
if [[ -f "$CONFIGS/general/lp_test_new_file" ]]; then
|
||||||
|
chk "a new file defaults closed" "$(stat -c '%a' "$CONFIGS/general/lp_test_new_file")" "640"
|
||||||
|
rm -f "$CONFIGS/general/lp_test_new_file"
|
||||||
|
else
|
||||||
|
echo " FAIL a new file was not created"; fail=1
|
||||||
|
fi
|
||||||
|
rm -rf "$stage"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "adoption does not re-permission directories it passes through"
|
||||||
|
# config-adopt clamped every parent to manager:manager 0750, including ones
|
||||||
|
# that already existed. That closed configs/backup to the container user and
|
||||||
|
# broke the credential read, and closed configs/webui, which is what actually
|
||||||
|
# took the WebUI down.
|
||||||
|
for d in general network security webui backup; do
|
||||||
|
[[ -d "$CONFIGS/$d" ]] || continue
|
||||||
|
m=$(stat -c '%a' "$CONFIGS/$d")
|
||||||
|
if [[ "$m" == "755" ]]; then echo " ok configs/$d is traversable ($m)"
|
||||||
|
else echo " FAIL configs/$d is $m — the container user cannot traverse it"; fail=1; fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "the domain reader"
|
echo "the domain reader"
|
||||||
# Config values carry a trailing comment column, and updateConfigOption writes
|
# Config values carry a trailing comment column, and updateConfigOption writes
|
||||||
# an empty value as a literal "". Both had to be stripped: without the first
|
# an empty value as a literal "". Both had to be stripped: without the first
|
||||||
|
|||||||
255
scripts/restore/restore_inspect.sh
Normal file
255
scripts/restore/restore_inspect.sh
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Read a backup repository and report what a restore from it would bring —
|
||||||
|
# without writing anything.
|
||||||
|
#
|
||||||
|
# This is what lets a person decide before committing, in the WebUI as well as
|
||||||
|
# the installer: which machine's backups are in there, which apps, how big, how
|
||||||
|
# recent, and which domains would come across. Every answer is read out of
|
||||||
|
# snapshots; nothing is restored, nothing on this machine is touched.
|
||||||
|
#
|
||||||
|
# The domains matter most and are the least obvious to get at. They live in the
|
||||||
|
# system-config snapshot, in network/network_domains — so they can be pulled
|
||||||
|
# with engineDumpFile the same way the preflight pulls an app's manifest, long
|
||||||
|
# before anything is adopted. Knowing "this backup will hand you six domains,
|
||||||
|
# four of which point somewhere else" is the difference between a rebuild and a
|
||||||
|
# surprise.
|
||||||
|
#
|
||||||
|
# Emits one JSON object, for the WebUI to render and for a person to read.
|
||||||
|
|
||||||
|
# The domains recorded in the repository's system-config snapshot.
|
||||||
|
# Silent on failure: an older backup may not carry the file, and that is not a
|
||||||
|
# reason to refuse an inspection.
|
||||||
|
restoreInspectDomains()
|
||||||
|
{
|
||||||
|
local idx="$1" host="$2"
|
||||||
|
|
||||||
|
# There is no engineSystemSnapshotLatestId — only the JSON listing — so
|
||||||
|
# take the last entry, which is the newest: restic returns snapshots
|
||||||
|
# oldest-first.
|
||||||
|
local snap
|
||||||
|
snap=$(engineSystemSnapshotsJson "$idx" "$host" 2>/dev/null \
|
||||||
|
| grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4)
|
||||||
|
[[ -n "$snap" ]] || return 0
|
||||||
|
|
||||||
|
# The snapshot is the config tree, taken from this install's configs dir, so
|
||||||
|
# the file sits at that absolute path inside it. Ask for a few plausible
|
||||||
|
# roots rather than assuming one: the source machine's config root is
|
||||||
|
# exactly the thing that differs between installs.
|
||||||
|
local base out=""
|
||||||
|
for base in "${configs_dir%/}" "/libreportal-system/configs" "/docker/configs"; do
|
||||||
|
out=$(engineDumpFile "$idx" "$snap" "$base/network/network_domains" 2>/dev/null)
|
||||||
|
[[ -n "$out" ]] && break
|
||||||
|
done
|
||||||
|
[[ -n "$out" ]] || return 0
|
||||||
|
|
||||||
|
local line v
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^CFG_DOMAIN_[0-9]+= ]] || continue
|
||||||
|
v="${line#*=}"; v="${v%%#*}"
|
||||||
|
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
||||||
|
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||||
|
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
||||||
|
[[ -n "$v" ]] && printf '%s\n' "$v"
|
||||||
|
done <<< "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inspect a repository. Prints JSON on stdout.
|
||||||
|
#
|
||||||
|
# restore inspect <location-idx> [host]
|
||||||
|
#
|
||||||
|
# With no host it reports every host it found and picks the one with the most
|
||||||
|
# apps as the suggestion — a repository that has been pointed at two machines
|
||||||
|
# is a normal thing to end up with, and guessing silently is not.
|
||||||
|
restoreInspect()
|
||||||
|
{
|
||||||
|
local idx="${1:-}" want_host="${2:-}"
|
||||||
|
if [[ -z "$idx" ]]; then
|
||||||
|
echo '{"error":"a backup location is required"}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local snaps
|
||||||
|
snaps=$(restoreFirstRunDiscover "$idx" 2>/dev/null)
|
||||||
|
if [[ -z "$snaps" || "$snaps" == "null" ]]; then
|
||||||
|
# The single most common cause by a distance, and worth saying plainly
|
||||||
|
# rather than as "discovery failed".
|
||||||
|
echo '{"error":"Could not read that repository — wrong password, or not a LibrePortal backup."}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a hosts=()
|
||||||
|
local h
|
||||||
|
while IFS= read -r h; do [[ -n "$h" ]] && hosts+=("$h"); done \
|
||||||
|
< <(printf '%s' "$snaps" | grep -o '"hostname":"[^"]*"' | cut -d'"' -f4 | sort -u)
|
||||||
|
|
||||||
|
if (( ${#hosts[@]} == 0 )); then
|
||||||
|
echo '{"error":"No LibrePortal backups found in that repository."}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local host="$want_host"
|
||||||
|
if [[ -z "$host" ]]; then
|
||||||
|
# The one with the most apps, not simply the first: a repository often
|
||||||
|
# carries a stray snapshot from a machine that was only ever tested.
|
||||||
|
local best="" best_n=-1 n
|
||||||
|
for h in "${hosts[@]}"; do
|
||||||
|
n=$(migrateDiscoverApps "$h" "$idx" 2>/dev/null | grep -c .)
|
||||||
|
if (( n > best_n )); then best_n=$n; best="$h"; fi
|
||||||
|
done
|
||||||
|
host="$best"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a apps=()
|
||||||
|
local a
|
||||||
|
while IFS= read -r a; do [[ -n "$a" ]] && apps+=("$a"); done \
|
||||||
|
< <(migrateDiscoverApps "$host" "$idx" 2>/dev/null)
|
||||||
|
|
||||||
|
# --- assemble ---
|
||||||
|
local out='{'
|
||||||
|
out+='"host":"'$(_lpJsonStr "$host")'",'
|
||||||
|
|
||||||
|
out+='"hosts":['
|
||||||
|
local first=1
|
||||||
|
for h in "${hosts[@]}"; do
|
||||||
|
[[ $first -eq 0 ]] && out+=','
|
||||||
|
out+='"'$(_lpJsonStr "$h")'"'; first=0
|
||||||
|
done
|
||||||
|
out+='],'
|
||||||
|
|
||||||
|
out+='"apps":['
|
||||||
|
first=1
|
||||||
|
local size_b size_h
|
||||||
|
for a in "${apps[@]}"; do
|
||||||
|
[[ $first -eq 0 ]] && out+=','
|
||||||
|
# Size and date come from the app's own manifest where there is one.
|
||||||
|
# An older backup without a manifest still lists — it just has less to
|
||||||
|
# say about itself, which is better than being left out of the list.
|
||||||
|
size_b=$(restorePreflightManifest "$idx" "$a" "$host" 2>/dev/null \
|
||||||
|
| tr -d ' \n\t' | grep -o '"size_bytes":[0-9]*' | cut -d: -f2)
|
||||||
|
size_h=""
|
||||||
|
[[ -n "$size_b" ]] && size_h=$(_restorePfSize "$size_b")
|
||||||
|
out+='{"name":"'$(_lpJsonStr "$a")'","size":"'$(_lpJsonStr "$size_h")'"}'
|
||||||
|
first=0
|
||||||
|
done
|
||||||
|
out+='],'
|
||||||
|
|
||||||
|
out+='"domains":['
|
||||||
|
first=1
|
||||||
|
local d
|
||||||
|
while IFS= read -r d; do
|
||||||
|
[[ -z "$d" ]] && continue
|
||||||
|
[[ $first -eq 0 ]] && out+=','
|
||||||
|
out+='"'$(_lpJsonStr "$d")'"'; first=0
|
||||||
|
done < <(restoreInspectDomains "$idx" "$host")
|
||||||
|
out+=']}'
|
||||||
|
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Connect a repository described by a base64 JSON payload, then inspect it.
|
||||||
|
#
|
||||||
|
# restoreConnectInspect <base64-json>
|
||||||
|
#
|
||||||
|
# Payload: {"location":{"name","type","path","uri","ssh_*","password_ref",…},
|
||||||
|
# "host":"optional"}
|
||||||
|
#
|
||||||
|
# The password arrives as a REFERENCE, never a value. This payload reaches a
|
||||||
|
# task command line and tasks are recorded world-readable, so a secret
|
||||||
|
# travelling as itself would be readable by any local account —
|
||||||
|
# webuiSecretResolve redeems it here, once, at the moment of the write. Same
|
||||||
|
# contract as the wizard's backup destinations.
|
||||||
|
#
|
||||||
|
# Deliberately does NOT call engineInitLocation. Every other path that creates
|
||||||
|
# a location initialises it, because it is about to write backups there. This
|
||||||
|
# one is pointed at a repository that already exists and is only going to be
|
||||||
|
# read; initialising is at best a no-op and at worst the wrong answer to
|
||||||
|
# "that path is empty".
|
||||||
|
restoreConnectInspect()
|
||||||
|
{
|
||||||
|
local b64="${1:-}"
|
||||||
|
if [[ -z "$b64" ]]; then
|
||||||
|
echo '{"error":"no payload"}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(printf '%s' "$b64" | base64 -d 2>/dev/null)
|
||||||
|
if [[ -z "$payload" ]]; then
|
||||||
|
echo '{"error":"payload could not be decoded"}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local loc; loc=$(jq -c '.location // {}' <<< "$payload" 2>/dev/null)
|
||||||
|
[[ -n "$loc" && "$loc" != "{}" ]] || { echo '{"error":"no backup location in the payload"}'; return 1; }
|
||||||
|
|
||||||
|
local name type idx
|
||||||
|
name=$(jq -r '.name // "restore-source"' <<< "$loc")
|
||||||
|
type=$(jq -r '.type // "local"' <<< "$loc")
|
||||||
|
|
||||||
|
idx=$(locationAdd "$name" "$type" 2>/dev/null | tail -1)
|
||||||
|
if [[ ! "$idx" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo '{"error":"Could not create a backup location to read from."}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cfg; cfg=$(backupLocationConfig "$idx")
|
||||||
|
if [[ ! -f "$cfg" ]]; then
|
||||||
|
echo '{"error":"The backup location was created but has no config file."}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local v
|
||||||
|
v=$(jq -r '.path // ""' <<< "$loc")
|
||||||
|
if [[ -n "$v" ]]; then
|
||||||
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH_MODE" "custom" "$cfg" >/dev/null
|
||||||
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH" "$v" "$cfg" >/dev/null
|
||||||
|
fi
|
||||||
|
v=$(jq -r '.uri // ""' <<< "$loc")
|
||||||
|
[[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_URI" "$v" "$cfg" >/dev/null
|
||||||
|
|
||||||
|
local k
|
||||||
|
for k in ssh_user ssh_host ssh_port ssh_path s3_bucket s3_key_id b2_account_id; do
|
||||||
|
v=$(jq -r --arg k "$k" '.[$k] // ""' <<< "$loc")
|
||||||
|
[[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$v" "$cfg" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Every secret in the payload, redeemed at the point of use.
|
||||||
|
local ref pw
|
||||||
|
for k in password ssh_pass s3_key b2_key connect_token; do
|
||||||
|
ref=$(jq -r --arg k "${k}_ref" '.[$k] // ""' <<< "$loc")
|
||||||
|
[[ -n "$ref" ]] || continue
|
||||||
|
if pw=$(webuiSecretResolve "$ref" 2>/dev/null) && [[ -n "$pw" ]]; then
|
||||||
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$pw" "$cfg" >/dev/null
|
||||||
|
pw=""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_ENABLED" "true" "$cfg" >/dev/null
|
||||||
|
# The credentials just landed, so close the directory to everything that is
|
||||||
|
# not LibrePortal before anything else runs.
|
||||||
|
runOwnership config-secure >/dev/null 2>&1 || true
|
||||||
|
source "$cfg" 2>/dev/null
|
||||||
|
|
||||||
|
local host; host=$(jq -r '.host // ""' <<< "$payload")
|
||||||
|
local out; out=$(restoreInspect "$idx" "$host")
|
||||||
|
|
||||||
|
# The caller needs the index to restore from later, and it is not otherwise
|
||||||
|
# discoverable from the WebUI without guessing.
|
||||||
|
if grep -q '"error"' <<< "$out"; then
|
||||||
|
# Remove the location we just made. A wrong password is the ordinary
|
||||||
|
# case here and the user will simply try again — without this, every
|
||||||
|
# retry leaves another half-configured destination behind, and after a
|
||||||
|
# few attempts the Backup page lists a column of identical dead
|
||||||
|
# entries the user then has to clean up by hand.
|
||||||
|
locationRemove "$idx" >/dev/null 2>&1 || true
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Spliced with jq rather than by trimming a closing brace: string surgery
|
||||||
|
# on JSON is how you ship something that parses on your machine and not on
|
||||||
|
# the next one.
|
||||||
|
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@ -11,5 +11,6 @@ restore_scripts=(
|
|||||||
"restore/restore_preflight.sh"
|
"restore/restore_preflight.sh"
|
||||||
"restore/restore_system_adopt.sh"
|
"restore/restore_system_adopt.sh"
|
||||||
"restore/restore_domains.sh"
|
"restore/restore_domains.sh"
|
||||||
|
"restore/restore_inspect.sh"
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -905,10 +905,12 @@ declare -gA LP_FN_MAP=(
|
|||||||
[resticSnapshotPaths]="backup/engine/restic_snapshots.sh"
|
[resticSnapshotPaths]="backup/engine/restic_snapshots.sh"
|
||||||
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||||
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||||
|
[_restoreAdoptAllowDirs]="restore/restore_system_adopt.sh"
|
||||||
[_restoreAdoptAllowList]="restore/restore_system_adopt.sh"
|
[_restoreAdoptAllowList]="restore/restore_system_adopt.sh"
|
||||||
[restoreAdoptIsFirstRun]="restore/restore_system_adopt.sh"
|
[restoreAdoptIsFirstRun]="restore/restore_system_adopt.sh"
|
||||||
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
||||||
[restoreAppStart]="restore/restore_app_start.sh"
|
[restoreAppStart]="restore/restore_app_start.sh"
|
||||||
|
[restoreConnectInspect]="restore/restore_inspect.sh"
|
||||||
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
||||||
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
||||||
[restoreDomainCheck]="restore/restore_domains.sh"
|
[restoreDomainCheck]="restore/restore_domains.sh"
|
||||||
@ -918,11 +920,15 @@ declare -gA LP_FN_MAP=(
|
|||||||
[restoreFilesRehydratePreStart]="backup/files/backup_files.sh"
|
[restoreFilesRehydratePreStart]="backup/files/backup_files.sh"
|
||||||
[restoreFirstRunBulk]="restore/restore_first_run.sh"
|
[restoreFirstRunBulk]="restore/restore_first_run.sh"
|
||||||
[restoreFirstRunDiscover]="restore/restore_first_run.sh"
|
[restoreFirstRunDiscover]="restore/restore_first_run.sh"
|
||||||
|
[restoreInspect]="restore/restore_inspect.sh"
|
||||||
|
[restoreInspectDomains]="restore/restore_inspect.sh"
|
||||||
|
[_restoreIsPrivateIp]="restore/restore_domains.sh"
|
||||||
[_restorePfSize]="restore/restore_preflight.sh"
|
[_restorePfSize]="restore/restore_preflight.sh"
|
||||||
[restorePickSnapshot]="restore/restore_app_pick.sh"
|
[restorePickSnapshot]="restore/restore_app_pick.sh"
|
||||||
[restorePreflightApp]="restore/restore_preflight.sh"
|
[restorePreflightApp]="restore/restore_preflight.sh"
|
||||||
[restorePreflightManifest]="restore/restore_preflight.sh"
|
[restorePreflightManifest]="restore/restore_preflight.sh"
|
||||||
[restorePreflightReport]="restore/restore_preflight.sh"
|
[restorePreflightReport]="restore/restore_preflight.sh"
|
||||||
|
[restoreServerPublicIp]="restore/restore_domains.sh"
|
||||||
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
||||||
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
||||||
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
||||||
@ -2163,10 +2169,12 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[resticSnapshotPaths]="scripts"
|
[resticSnapshotPaths]="scripts"
|
||||||
[resticSnapshotsJson]="scripts"
|
[resticSnapshotsJson]="scripts"
|
||||||
[resticSystemSnapshotsJson]="scripts"
|
[resticSystemSnapshotsJson]="scripts"
|
||||||
|
[_restoreAdoptAllowDirs]="scripts"
|
||||||
[_restoreAdoptAllowList]="scripts"
|
[_restoreAdoptAllowList]="scripts"
|
||||||
[restoreAdoptIsFirstRun]="scripts"
|
[restoreAdoptIsFirstRun]="scripts"
|
||||||
[restoreAppRunHook]="scripts"
|
[restoreAppRunHook]="scripts"
|
||||||
[restoreAppStart]="scripts"
|
[restoreAppStart]="scripts"
|
||||||
|
[restoreConnectInspect]="scripts"
|
||||||
[restoreDbRehydratePreStart]="scripts"
|
[restoreDbRehydratePreStart]="scripts"
|
||||||
[restoreDbReplayPostStart]="scripts"
|
[restoreDbReplayPostStart]="scripts"
|
||||||
[restoreDomainCheck]="scripts"
|
[restoreDomainCheck]="scripts"
|
||||||
@ -2176,11 +2184,15 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[restoreFilesRehydratePreStart]="scripts"
|
[restoreFilesRehydratePreStart]="scripts"
|
||||||
[restoreFirstRunBulk]="scripts"
|
[restoreFirstRunBulk]="scripts"
|
||||||
[restoreFirstRunDiscover]="scripts"
|
[restoreFirstRunDiscover]="scripts"
|
||||||
|
[restoreInspect]="scripts"
|
||||||
|
[restoreInspectDomains]="scripts"
|
||||||
|
[_restoreIsPrivateIp]="scripts"
|
||||||
[_restorePfSize]="scripts"
|
[_restorePfSize]="scripts"
|
||||||
[restorePickSnapshot]="scripts"
|
[restorePickSnapshot]="scripts"
|
||||||
[restorePreflightApp]="scripts"
|
[restorePreflightApp]="scripts"
|
||||||
[restorePreflightManifest]="scripts"
|
[restorePreflightManifest]="scripts"
|
||||||
[restorePreflightReport]="scripts"
|
[restorePreflightReport]="scripts"
|
||||||
|
[restoreServerPublicIp]="scripts"
|
||||||
[restoreSystemAdopt]="scripts"
|
[restoreSystemAdopt]="scripts"
|
||||||
[_rocketchatApi]="containers"
|
[_rocketchatApi]="containers"
|
||||||
[_rocketchatBaseUrl]="containers"
|
[_rocketchatBaseUrl]="containers"
|
||||||
@ -3459,10 +3471,12 @@ resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${in
|
|||||||
resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; }
|
resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; }
|
||||||
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
|
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
|
||||||
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
|
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
|
||||||
|
_restoreAdoptAllowDirs() { unset -f _restoreAdoptAllowDirs; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowDirs "$@"; }
|
||||||
_restoreAdoptAllowList() { unset -f _restoreAdoptAllowList; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowList "$@"; }
|
_restoreAdoptAllowList() { unset -f _restoreAdoptAllowList; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowList "$@"; }
|
||||||
restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreAdoptIsFirstRun "$@"; }
|
restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreAdoptIsFirstRun "$@"; }
|
||||||
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
||||||
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
||||||
|
restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; }
|
||||||
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
||||||
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
||||||
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
||||||
@ -3472,11 +3486,15 @@ restoreDomainsDropElsewhere() { unset -f restoreDomainsDropElsewhere; __lpAutolo
|
|||||||
restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/files/backup_files.sh"; restoreFilesRehydratePreStart "$@"; }
|
restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/files/backup_files.sh"; restoreFilesRehydratePreStart "$@"; }
|
||||||
restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; }
|
restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; }
|
||||||
restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; }
|
restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; }
|
||||||
|
restoreInspect() { unset -f restoreInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspect "$@"; }
|
||||||
|
restoreInspectDomains() { unset -f restoreInspectDomains; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspectDomains "$@"; }
|
||||||
|
_restoreIsPrivateIp() { unset -f _restoreIsPrivateIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; _restoreIsPrivateIp "$@"; }
|
||||||
_restorePfSize() { unset -f _restorePfSize; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; _restorePfSize "$@"; }
|
_restorePfSize() { unset -f _restorePfSize; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; _restorePfSize "$@"; }
|
||||||
restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; }
|
restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; }
|
||||||
restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; }
|
restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; }
|
||||||
restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; }
|
restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; }
|
||||||
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
||||||
|
restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; }
|
||||||
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
||||||
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
||||||
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
||||||
|
|||||||
@ -561,12 +561,31 @@ config_adopt() {
|
|||||||
chmod 0750 -- "${real_dst%/*}" 2>/dev/null
|
chmod 0750 -- "${real_dst%/*}" 2>/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Preserve whatever the destination already is. A restore replaces the
|
||||||
|
# CONTENT of a config file; it has no business re-deciding who may read it.
|
||||||
|
# The live install already knows — webui_logins and webui_logs are
|
||||||
|
# bind-mounted into the container and must stay readable by the container
|
||||||
|
# user, and imposing manager:manager 0640 on them took the WebUI down
|
||||||
|
# immediately (exit 137, no log line, because the process could not read
|
||||||
|
# its own credentials file).
|
||||||
|
local had_owner="" had_mode=""
|
||||||
|
if [[ -e "$real_dst" ]]; then
|
||||||
|
had_owner="$(stat -c '%U:%G' -- "$real_dst" 2>/dev/null)"
|
||||||
|
had_mode="$(stat -c '%a' -- "$real_dst" 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
# --dereference: copy what a symlink points at, never the link itself.
|
# --dereference: copy what a symlink points at, never the link itself.
|
||||||
cp -f --dereference -- "$src" "$real_dst" || return 1
|
cp -f --dereference -- "$src" "$real_dst" || return 1
|
||||||
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
|
||||||
# These files carry backup-repository passwords and login hashes, so they
|
if [[ -n "$had_owner" && -n "$had_mode" ]]; then
|
||||||
# are never group- or world-readable.
|
chown "$had_owner" -- "$real_dst" || return 1
|
||||||
chmod 0640 -- "$real_dst" || return 1
|
chmod "$had_mode" -- "$real_dst" || return 1
|
||||||
|
else
|
||||||
|
# New file: these can carry repository passwords and login hashes, so
|
||||||
|
# the default is closed.
|
||||||
|
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
||||||
|
chmod 0640 -- "$real_dst" || return 1
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user