Skill: ccaprani/reolink-remote-backup (version 0.3.0)

--- name: reolink-remote-backup version: 0.3.0 description: Set up reliable off-site Reolink camera backup when cameras are remote (4G/5G/LTE) and home inbound networking is constrained (CGNAT/locked routers). Use for architecture decisions, VPS relay setup, FTP/FTPS ingest, local/NAS pull sync, retention, and troubleshooting upload/auth/path failures. --- # Reolink Remote Backup Implement resilient backup using: 1. Reolink camera FTP/FTPS upload -> VPS relay 2. Local machine pull from VPS -> NAS/local archive 3. Retention + health checks Use this flow when direct camera->home ingest is impractical. ## Inputs to collect - Camera model + hardware version - Whether FTP/FTPS options are visible in app/client - VPS host/IP and SSH access method - Local destination path (NAS mountpoint) - Desired pull interval (default 10 min) - Desired VPS retention window (default 30 days) ## Workflow ### 1) Validate architecture constraints - If home inbound routing is locked/CGNAT, prefer VPS relay. - Keep NAS off public internet. - Use local pull from VPS (never rely on VPS pushing into home NAT). ### 2) Configure VPS ingest Run `scripts/setup_vps_vsftpd.sh` on VPS as root. It: - installs vsftpd + ufw - creates `reolinkftp` ingest user - sets up `/srv/reolink/incoming` - opens SSH + FTP passive ports Then set camera FTP/FTPS target to VPS. ### 3) Configure local pull Run `scripts/setup_local_pull.sh` on local machine. It writes: - `/home/$USER/bin/reolink_pull.sh` - systemd user service + timer with `Persistent=true` This ensures catch-up after downtime. ### 4) Set retention safety Run `scripts/setup_vps_retention.sh` on VPS to prune old files if local sync is offline for prolonged periods. ### 5) Verify - Trigger camera upload or run camera FTP test. - Confirm files appear on VPS: `/srv/reolink/incoming` - Run local pull script once manually. - Confirm files appear on destination path. - Confirm timer active: `systemctl --user list-timers | grep reolink-pull` ## Troubleshooting Read `references/troubleshooting.md` for response-code and log-driven fixes. Fast checks: - VPS service status: `systemctl status vsftpd` - VPS logs: `journalctl -u vsftpd -n 120 --no-pager` - FTP logs: `tail -n 120 /var/log/vsftpd.log` - Mount status (local): `mountpoint -q <mountpath>` ## Security baseline - Use SSH keys for VPS admin access. - Keep FTP user isolated from admin users. - Prefer FTPS after initial debugging. - Enforce retention so VPS disk cannot fill. - Rotate any credential exposed in chat/logs.

path: references/troubleshooting.md
content: # Reolink Remote Backup Troubleshooting

## Symptoms and fixes

## 1) `vsftpd` fails to start (`status=2/INVALIDARGUMENT`)

- Run parser directly:
  - `/usr/sbin/vsftpd /etc/vsftpd.conf`
- Remove unsupported config keys reported by OOPS message.
- Restart: `systemctl restart vsftpd`

## 2) Camera FTP test shows `451 login failed`

- Re-enter username/password in app.
- Reset server password: `passwd reolinkftp`.
- Ensure user in allow list:
  - `echo reolinkftp >/etc/vsftpd.userlist`
- Check logs:
  - `tail -n 120 /var/log/vsftpd.log`
  - `tail -n 120 /var/log/auth.log`

## 3) Camera FTP test shows `455` failure

Usually write/path/passive/TLS mismatch.

- Confirm writable ingest path:
  - `mkdir -p /srv/reolink/incoming`
  - `chown -R reolinkftp:reolinkftp /srv/reolink`
  - `chmod 775 /srv/reolink/incoming`
- Confirm passive ports open (21 + 50000-50100/TCP).
- Temporarily disable forced TLS for debugging only:
  - `force_local_logins_ssl=NO`
  - `force_local_data_ssl=NO`

## 4) Connect appears in logs, but no USER/PASS lines

Likely protocol mismatch or early TLS negotiation failure.

- Enable protocol logging in vsftpd config:
  - `log_ftp_protocol=YES`
  - `dual_log_enable=YES`
- Re-test and inspect `/var/log/vsftpd.log`.

## 5) Local pull script says mount missing

- Verify mount:
  - `findmnt <mountpoint>`
  - `mount | grep <name>`
- Check duplicate/conflicting `/etc/fstab` entries.
- Run:
  - `sudo systemctl daemon-reload`
  - `sudo mount -a`

## 6) Nothing arrives while camera is remote and idle

- Trigger uploads may require motion/events.
- Use camera app/web test button if available.
- Historical microSD archives may need explicit download through Reolink web/app tools.

## 7) rsync fails with `change_dir "/srv/reolink/incoming" failed`

The generated pull script uses a relative source path (`incoming/`) because the
`reolinkftp` user's home is `/srv/reolink`. If an older script has the absolute
path, patch it:

```bash
sed -i "s|VPS_SRC=/srv/reolink/incoming/|VPS_SRC=incoming/|" ~/bin/reolink_pull.sh
```

Also ensure `/srv/reolink/incoming` exists on the VPS:

```bash
mkdir -p /srv/reolink/incoming
chown reolinkftp:reolinkftp /srv/reolink/incoming
chmod 775 /srv/reolink/incoming
```

## 8) rsync fails with `protocol version mismatch -- is your shell clean?`

This happens when a **forced rsync command** is set in `authorized_keys` and the
flags in that command don't match what the client negotiates. Do **not** use a
forced rsync command for this setup. Instead, use restriction flags only:

```
no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA...
```

Remove a forced command with:

```bash
sed -i 's|command="rsync[^"]*",||' /srv/reolink/.ssh/authorized_keys
```

The `reolinkftp` user is already restricted (no sudo, home-dir only) so the
forced command provides little extra security while breaking rsync negotiation.

## 9) VPS fills up

- Confirm local pull timer active:
  - `systemctl --user list-timers | grep reolink-pull`
- Confirm local script ran and logs are fresh.
- Keep VPS retention prune enabled.


path: scripts/setup_local_pull.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Usage:
#   ./setup_local_pull.sh <vps-host-or-ip> <ssh-key-path> <destination-path> [interval-minutes] [vps-user]
# Example:
#   ./setup_local_pull.sh 134.199.174.28 ~/.ssh/do-reolink-relay /mnt/nas/reolink 10 reolinkftp
#
# Security notes:
#   - vps-user should be the restricted FTP ingest user (NOT root).
#   - That user needs read access to /srv/reolink/incoming on the VPS.
#   - All input is validated before being written to the generated script.

# ── Input validation ─────────────────────────────────────────────────────────

if [[ $# -lt 3 ]]; then
  echo "Usage: $0 <vps-host-or-ip> <ssh-key-path> <destination-path> [interval-minutes] [vps-user]"
  exit 1
fi

VPS_HOST="$1"
SSH_KEY="$2"
DEST_PATH="$3"
INTERVAL_MIN="${4:-10}"
VPS_USER="${5:-reolinkftp}"   # Default: restricted ingest user, never root

# Validate host: alphanumeric, dots, hyphens only (hostnames + IPv4)
if [[ ! "$VPS_HOST" =~ ^[a-zA-Z0-9._-]+$ ]]; then
  echo "ERROR: VPS_HOST contains invalid characters: $VPS_HOST" >&2
  exit 1
fi

# Validate VPS_USER: alphanumeric + underscore/hyphen only
if [[ ! "$VPS_USER" =~ ^[a-zA-Z0-9_-]+$ ]]; then
  echo "ERROR: VPS_USER contains invalid characters: $VPS_USER" >&2
  exit 1
fi

if [[ "$VPS_USER" == "root" ]]; then
  echo "ERROR: Do not run rsync as root. Provide a restricted user (e.g. reolinkftp)." >&2
  echo "       Grant that user read access to /srv/reolink/incoming on the VPS." >&2
  exit 1
fi

# Validate interval: integer 1-1440
if [[ ! "$INTERVAL_MIN" =~ ^[0-9]+$ ]] || (( INTERVAL_MIN < 1 || INTERVAL_MIN > 1440 )); then
  echo "ERROR: interval-minutes must be an integer between 1 and 1440" >&2
  exit 1
fi

# Resolve and validate SSH key path (no path traversal)
SSH_KEY_REAL="$(realpath -m -- "$SSH_KEY")"
if [[ "$SSH_KEY_REAL" != "$HOME/"* && "$SSH_KEY_REAL" != /home/* && "$SSH_KEY_REAL" != /etc/ssh/* ]]; then
  echo "ERROR: SSH key path looks unusual: $SSH_KEY_REAL" >&2
  exit 1
fi

# Resolve and validate destination path
DEST_REAL="$(realpath -m -- "$DEST_PATH")"
if [[ "$DEST_REAL" == / || "$DEST_REAL" == /etc* || "$DEST_REAL" == /usr* ]]; then
  echo "ERROR: Destination path is a system directory: $DEST_REAL" >&2
  exit 1
fi

# ── Generate pull script ──────────────────────────────────────────────────────
# Use printf to write values as data, not code. Each variable is written
# as a quoted assignment with special characters escaped.

mkdir -p "$HOME/bin" "$HOME/.config/systemd/user"
mkdir -p "$DEST_REAL"

PULL_SCRIPT="$HOME/bin/reolink_pull.sh"

# Write header and static config using printf (values quoted, never eval'd)
{
  printf '#!/usr/bin/env bash\n'
  printf 'set -euo pipefail\n\n'
  printf '# Auto-generated by setup_local_pull.sh — do not edit by hand.\n\n'

  # Write config as quoted variables — printf %q ensures shell-safe quoting
  printf 'VPS_HOST=%q\n'  "$VPS_HOST"
  printf 'VPS_USER=%q\n'  "$VPS_USER"
  printf 'VPS_SRC=%q\n'   "incoming/"
  printf 'SSH_KEY=%q\n'   "$SSH_KEY_REAL"
  printf 'DST=%q\n'       "${DEST_REAL}/"
  printf 'LOG=%q\n'       "$HOME/reolink-sync.log"
  printf 'LOCK=%q\n\n'    "/tmp/reolink_pull.lock"

  # Static runtime logic — no user-supplied values embedded as code
  cat <<'STATIC'
exec 9>"$LOCK"
flock -n 9 || exit 0

log(){ echo "$(date -Is) $*" >> "$LOG"; }

# Verify destination mount is present before syncing
dest_parent="$(dirname "$DST")"
if ! mountpoint -q "$dest_parent" 2>/dev/null && ! mountpoint -q "$DST" 2>/dev/null; then
  # Destination may be a local path (not a mount) — only fail if the dir is missing
  if [[ ! -d "$DST" ]]; then
    log "ERROR destination missing and not a mount: $DST"
    exit 1
  fi
fi

mkdir -p "$DST"

for i in 1 2 3; do
  if rsync -az --remove-source-files --partial --append-verify \
      -e "ssh -i ${SSH_KEY} -o BatchMode=yes -o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new" \
      "${VPS_USER}@${VPS_HOST}:${VPS_SRC}" "$DST" >> "$LOG" 2>&1; then
    log "OK rsync pass $i"
    break
  fi
  log "WARN rsync failed pass $i"
  sleep $((i * 20))
done

# Clean up empty dirs on VPS (restricted to the ingest path only)
ssh -i "$SSH_KEY" \
    -o BatchMode=yes \
    -o ConnectTimeout=20 \
    -o StrictHostKeyChecking=accept-new \
    "${VPS_USER}@${VPS_HOST}" \
    'find incoming -mindepth 1 -type d -empty -delete 2>/dev/null || true' \
  >> "$LOG" 2>&1 || log "WARN cleanup skipped"

log "DONE"
STATIC
} > "$PULL_SCRIPT"

chmod 700 "$PULL_SCRIPT"

# ── Systemd user units ────────────────────────────────────────────────────────

cat >"$HOME/.config/systemd/user/reolink-pull.service" <<'UNIT'
[Unit]
Description=Pull Reolink files from VPS to destination storage

[Service]
Type=oneshot
ExecStart=%h/bin/reolink_pull.sh
UNIT

# interval written as validated integer — safe to interpolate
cat >"$HOME/.config/systemd/user/reolink-pull.timer" <<UNIT
[Unit]
Description=Run Reolink pull every ${INTERVAL_MIN} minutes (persistent)

[Timer]
OnCalendar=*:0/${INTERVAL_MIN}
Persistent=true
RandomizedDelaySec=30

[Install]
WantedBy=timers.target
UNIT

systemctl --user daemon-reload
systemctl --user enable --now reolink-pull.timer

echo
systemctl --user list-timers | grep reolink-pull || true

echo
cat <<EOM

Done.

Next steps:
  1) Ensure the VPS user '${VPS_USER}' can read /srv/reolink/incoming:
       sudo usermod -aG reolinkftp ${VPS_USER}   # if needed
  2) Test manually: $PULL_SCRIPT
  3) Ensure linger is enabled (required for user timers without login):
       sudo loginctl enable-linger $USER
  4) Check logs: tail -f $HOME/reolink-sync.log

Security reminder:
  - SSH key at ${SSH_KEY_REAL} should be 0600 and dedicated to this relay only.
  - VPS user '${VPS_USER}' should have no sudo and no shell login if possible.
EOM


path: scripts/setup_vps_retention.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Usage:
#   sudo ./setup_vps_retention.sh [days]
# Default days: 30

DAYS="${1:-30}"

( crontab -l 2>/dev/null | grep -v '/srv/reolink/incoming -type f -mtime' ; \
  echo "30 3 * * * find /srv/reolink/incoming -type f -mtime +${DAYS} -delete" ) | crontab -

echo "Installed VPS retention prune: delete files older than ${DAYS} days"
crontab -l


path: scripts/setup_vps_vsftpd.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Usage:
#   sudo ./setup_vps_vsftpd.sh [FTP_USER] [FTP_ROOT]
# Defaults:
#   FTP_USER=reolinkftp
#   FTP_ROOT=/srv/reolink

FTP_USER="${1:-reolinkftp}"
FTP_ROOT="${2:-/srv/reolink}"
INGEST_DIR="${FTP_ROOT}/incoming"

apt update
apt install -y vsftpd ufw openssl

id -u "$FTP_USER" >/dev/null 2>&1 || useradd -m -d "$FTP_ROOT" -s /bin/bash "$FTP_USER"

mkdir -p "$INGEST_DIR"
chown -R "$FTP_USER:$FTP_USER" "$FTP_ROOT"
chmod 755 "$FTP_ROOT"
chmod 775 "$INGEST_DIR"

echo "Set password for ${FTP_USER}:"
passwd "$FTP_USER"

mkdir -p /etc/ssl/private
if [[ ! -f /etc/ssl/private/vsftpd.key || ! -f /etc/ssl/private/vsftpd.crt ]]; then
  openssl req -x509 -nodes -days 825 -newkey rsa:4096 \
    -keyout /etc/ssl/private/vsftpd.key \
    -out /etc/ssl/private/vsftpd.crt \
    -subj "/CN=$(hostname)"
  chmod 600 /etc/ssl/private/vsftpd.key
fi

cat >/etc/vsftpd.conf <<EOF
listen=YES
listen_ipv6=NO

anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
use_localtime=YES

xferlog_enable=YES
xferlog_std_format=NO
log_ftp_protocol=YES
dual_log_enable=YES

chroot_local_user=YES
allow_writeable_chroot=YES
userlist_enable=YES
userlist_file=/etc/vsftpd.userlist
userlist_deny=NO
local_root=${INGEST_DIR}

pasv_enable=YES
pasv_min_port=50000
pasv_max_port=50100

ssl_enable=YES
rsa_cert_file=/etc/ssl/private/vsftpd.crt
rsa_private_key_file=/etc/ssl/private/vsftpd.key
force_local_logins_ssl=YES
force_local_data_ssl=YES
ssl_sslv2=NO
ssl_sslv3=NO
require_ssl_reuse=NO
EOF

echo "$FTP_USER" >/etc/vsftpd.userlist

systemctl enable --now vsftpd

ufw allow OpenSSH
ufw allow 21/tcp
ufw allow 50000:50100/tcp
ufw --force enable

echo
systemctl --no-pager --full status vsftpd || true
echo
echo "Done. Camera target: host=$(hostname -I | awk '{print $1}') port=21 user=${FTP_USER}"
echo "If app troubleshooting is needed, temporarily set in /etc/vsftpd.conf:"
echo "  force_local_logins_ssl=NO"
echo "  force_local_data_ssl=NO"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)