feat(partnership): add blocklist to block access after offboard

After offboard, the former partner's hostname is written to
/boot/config/partnership_blocklist.db. This blocks passive reconnection:
  - --check skips the remote entirely (no auto-reconnect noise)
  - rsync.sh refuses to sync with a blocklisted host
  - --onboard warns about the previous offboard but proceeds,
    and clears the block on success (onboard = deliberate intent)

New --unblock <hostname> mode removes an entry to permit re-onboarding
without running a full --onboard first. --status shows the full blocklist.

Blocklist file survives reboots (/boot/config) and Tailscale reconnections —
application-layer guard that complements Tailscale device removal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-08 18:59:29 -04:00
co-authored by Claude Sonnet 4.6
parent 09e9cec0c3
commit 69464ba385
2 changed files with 126 additions and 1 deletions
+118 -1
View File
@@ -67,12 +67,26 @@
# PARTNERSHIP_SYNC_INTERVAL — informational — actual schedule in cron # PARTNERSHIP_SYNC_INTERVAL — informational — actual schedule in cron
# TAILSCALE_API_KEY / TAILSCALE_TAILNET — required when PARTNERSHIP_REMOVE_TAILSCALE=true # TAILSCALE_API_KEY / TAILSCALE_TAILNET — required when PARTNERSHIP_REMOVE_TAILSCALE=true
# #
# ── BLOCKLIST ─────────────────────────────────────────────────────────────────────────────────
# After offboard, the former partner's hostname is written to:
# /boot/config/partnership_blocklist.db (format: hostname|timestamp|reason)
#
# --onboard is hard-blocked if the remote is on the blocklist — exits with error.
# --check silently skips remote state reads for blocklisted hosts (no noise every 15min).
# --unblock <hostname> removes an entry to permit re-onboarding.
# --status shows the full blocklist.
#
# The blocklist persists until explicitly cleared — surviving reboots, array restarts,
# and Tailscale reconnections. Tailscale removal is a separate step at the network layer;
# the blocklist is the application-layer guard.
#
# ── USAGE ───────────────────────────────────────────────────────────────────────────────────── # ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# partnership_manage.sh --onboard # partnership_manage.sh --onboard
# partnership_manage.sh --offboard # partnership_manage.sh --offboard
# partnership_manage.sh --transfer --confirm=i-understand-this-transfers-ownership # partnership_manage.sh --transfer --confirm=i-understand-this-transfers-ownership
# partnership_manage.sh --check --remote-seen|--remote-unseen # partnership_manage.sh --check --remote-seen|--remote-unseen
# partnership_manage.sh --status # partnership_manage.sh --status
# partnership_manage.sh --unblock <hostname>
# Any mode supports --dry-run and --log # Any mode supports --dry-run and --log
# ============================================================================================== # ==============================================================================================
@@ -81,6 +95,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh" source "$SCRIPT_DIR/../load_config.sh"
SSH_TIMEOUT=15 SSH_TIMEOUT=15
BLOCKLIST_FILE="/boot/config/partnership_blocklist.db"
# ── Parse mode flags before parse_args ──────────────────────────────────────────────────────── # ── Parse mode flags before parse_args ────────────────────────────────────────────────────────
MODE="" MODE=""
@@ -88,6 +103,7 @@ TRANSFER_CONFIRM_INPUT=""
REMOTE_SEEN=false REMOTE_SEEN=false
REMOTE_UNSEEN=false REMOTE_UNSEEN=false
REASON="manual" REASON="manual"
UNBLOCK_HOST=""
FILTERED_ARGS=() FILTERED_ARGS=()
for arg in "$@"; do for arg in "$@"; do
@@ -97,11 +113,18 @@ for arg in "$@"; do
--transfer) MODE="transfer" ;; --transfer) MODE="transfer" ;;
--check) MODE="check" ;; --check) MODE="check" ;;
--status) MODE="status" ;; --status) MODE="status" ;;
--unblock) MODE="unblock" ;;
--confirm=*) TRANSFER_CONFIRM_INPUT="${arg#--confirm=}" ;; --confirm=*) TRANSFER_CONFIRM_INPUT="${arg#--confirm=}" ;;
--remote-seen) REMOTE_SEEN=true ;; --remote-seen) REMOTE_SEEN=true ;;
--remote-unseen) REMOTE_UNSEEN=true ;; --remote-unseen) REMOTE_UNSEEN=true ;;
--reason=*) REASON="${arg#--reason=}" ;; --reason=*) REASON="${arg#--reason=}" ;;
*) FILTERED_ARGS+=("$arg") ;; *)
if [[ "$MODE" == "unblock" ]] && [[ -z "$UNBLOCK_HOST" ]] && [[ "$arg" != --* ]]; then
UNBLOCK_HOST="$arg"
else
FILTERED_ARGS+=("$arg")
fi
;;
esac esac
done done
@@ -154,6 +177,7 @@ if [[ -z "$MODE" ]]; then
echo " partnership_manage.sh --transfer --confirm=..." echo " partnership_manage.sh --transfer --confirm=..."
echo " partnership_manage.sh --check --remote-seen|--remote-unseen" echo " partnership_manage.sh --check --remote-seen|--remote-unseen"
echo " partnership_manage.sh --status" echo " partnership_manage.sh --status"
echo " partnership_manage.sh --unblock <hostname>"
exit 1 exit 1
fi fi
@@ -331,6 +355,35 @@ remove_tailscale_device() {
error "Failed to remove $hostname from Tailscale — remove manually" error "Failed to remove $hostname from Tailscale — remove manually"
} }
# ── Blocklist helpers ─────────────────────────────────────────────────────────────────────────
is_blocklisted() {
local hostname="$1"
[[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${hostname}|" "$BLOCKLIST_FILE" 2>/dev/null
}
add_to_blocklist() {
local hostname="$1" reason="${2:-offboard}"
local now
now=$(date '+%Y-%m-%d %H:%M:%S')
if ! is_blocklisted "$hostname"; then
echo "${hostname}|${now}|${reason}" >> "$BLOCKLIST_FILE"
log "Blocklisted: $hostname (reason: $reason)"
else
log "$hostname already on blocklist"
fi
}
remove_from_blocklist() {
local hostname="$1"
if [[ ! -f "$BLOCKLIST_FILE" ]]; then
log "Blocklist empty — nothing to remove"
return 0
fi
sed -i "/^${hostname}|/d" "$BLOCKLIST_FILE" 2>/dev/null
log "Removed from blocklist: $hostname"
}
# ── FolderView3 integration ─────────────────────────────────────────────────────────────────── # ── FolderView3 integration ───────────────────────────────────────────────────────────────────
FOLDERVIEW3_DIR="/usr/local/emhttp/plugins/folder.view3" FOLDERVIEW3_DIR="/usr/local/emhttp/plugins/folder.view3"
FOLDERVIEW3_JSON="/boot/config/plugins/folder.view3/docker.json" FOLDERVIEW3_JSON="/boot/config/plugins/folder.view3/docker.json"
@@ -558,6 +611,40 @@ update_master_conf() {
fi fi
} }
# ==============================================================================================
# ━━━ Unblock ━━━
# ==============================================================================================
if [[ "$MODE" == "unblock" ]]; then
if [[ -z "$UNBLOCK_HOST" ]]; then
error "Usage: partnership_manager.sh --unblock <hostname>"
error "Example: partnership_manager.sh --unblock unRAID-Jayred365"
if [[ -f "$BLOCKLIST_FILE" ]] && [[ -s "$BLOCKLIST_FILE" ]]; then
echo ""
echo "Currently blocklisted:"
while IFS='|' read -r host ts reason; do
echo " $host (blocked $ts$reason)"
done < "$BLOCKLIST_FILE"
else
echo "Blocklist is empty."
fi
exit 1
fi
if ! is_blocklisted "$UNBLOCK_HOST"; then
warn "$UNBLOCK_HOST is not on the blocklist"
exit 0
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove $UNBLOCK_HOST from blocklist"
exit 0
fi
remove_from_blocklist "$UNBLOCK_HOST"
warn "$UNBLOCK_HOST unblocked — re-onboarding is now permitted ✅"
exit 0
fi
# ============================================================================================== # ==============================================================================================
# ━━━ Status ━━━ # ━━━ Status ━━━
# ============================================================================================== # ==============================================================================================
@@ -647,6 +734,16 @@ if [[ "$MODE" == "status" ]]; then
echo " ⚠️ Remote offline counter: ${OFFLINE_DAYS}/${PARTNERSHIP_OFFLINE_THRESHOLD}" echo " ⚠️ Remote offline counter: ${OFFLINE_DAYS}/${PARTNERSHIP_OFFLINE_THRESHOLD}"
fi fi
echo ""
if [[ -f "$BLOCKLIST_FILE" ]] && [[ -s "$BLOCKLIST_FILE" ]]; then
echo " Blocklist:"
while IFS='|' read -r host ts reason; do
echo "$host (blocked $ts$reason)"
done < "$BLOCKLIST_FILE"
else
echo " Blocklist: empty"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0 exit 0
fi fi
@@ -681,6 +778,12 @@ if [[ "$MODE" == "check" ]]; then
log "Partnership check — remote unseen ($OFFLINE_COUNT/$THRESHOLD_INTERVALS)" log "Partnership check — remote unseen ($OFFLINE_COUNT/$THRESHOLD_INTERVALS)"
fi fi
# Skip blocklisted partners — they can't auto-reconnect; only --onboard re-establishes
if is_blocklisted "$REMOTE_SERVER_NAME"; then
log "Partnership check — $REMOTE_SERVER_NAME is blocklisted, skipping"
exit 0
fi
# Read remote state file # Read remote state file
REMOTE_IP=$(tailscale ip -4 "${REMOTE_SERVER_NAME,,}" 2>/dev/null) REMOTE_IP=$(tailscale ip -4 "${REMOTE_SERVER_NAME,,}" 2>/dev/null)
if [[ -z "$REMOTE_IP" ]]; then if [[ -z "$REMOTE_IP" ]]; then
@@ -775,6 +878,12 @@ if [[ "$MODE" == "onboard" ]]; then
fi fi
fi fi
# Warn if re-onboarding a previously blocked partner — onboard is deliberate so it proceeds
if is_blocklisted "$MIRROR"; then
warn "$MIRROR is on the blocklist from a previous offboard"
warn "Proceeding — blocklist will be cleared on successful onboard"
fi
log "Owner: $OWNER_ID ($OWNER)" log "Owner: $OWNER_ID ($OWNER)"
log "Mirror: $MIRROR_ID ($MIRROR)" log "Mirror: $MIRROR_ID ($MIRROR)"
@@ -838,6 +947,8 @@ if [[ "$MODE" == "onboard" ]]; then
write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "onboard" write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "onboard"
log "Local state: ACTIVE ✅" log "Local state: ACTIVE ✅"
[[ "$DRY_RUN" == false ]] && remove_from_blocklist "$MIRROR"
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY" push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
echo "0" > "$OFFLINE_COUNTER" echo "0" > "$OFFLINE_COUNTER"
@@ -924,6 +1035,8 @@ if [[ "$MODE" == "offboard" ]]; then
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON" "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
log "Local state: INACTIVE ✅" log "Local state: INACTIVE ✅"
[[ "$DRY_RUN" == false ]] && add_to_blocklist "$OWNER" "$REASON"
OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null) OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null)
if [[ -n "$OWNER_IP" ]]; then if [[ -n "$OWNER_IP" ]]; then
push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$MIRROR_SSH_KEY" push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$MIRROR_SSH_KEY"
@@ -937,6 +1050,7 @@ if [[ "$MODE" == "offboard" ]]; then
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY ━━━━━" echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY ━━━━━"
echo " Your WebUIs: reconfigured → localhost ✅" echo " Your WebUIs: reconfigured → localhost ✅"
echo " State: INACTIVE ✅" echo " State: INACTIVE ✅"
echo " Blocklist: $OWNER blocked — re-onboard to permit access again ✅"
echo " Owner: will finalise + final sync on next --check ✅" echo " Owner: will finalise + final sync on next --check ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0 exit 0
@@ -1007,6 +1121,8 @@ if [[ "$MODE" == "offboard" ]]; then
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON" "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
log "Local state: INACTIVE ✅" log "Local state: INACTIVE ✅"
[[ "$DRY_RUN" == false ]] && add_to_blocklist "$MIRROR" "$REASON"
if [[ "$MIRROR_REACHABLE" == true ]]; then if [[ "$MIRROR_REACHABLE" == true ]]; then
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY" push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
fi fi
@@ -1057,6 +1173,7 @@ if [[ "$MODE" == "offboard" ]]; then
echo " WebUI failures: $WEBUI_FAILURES" echo " WebUI failures: $WEBUI_FAILURES"
echo " Critical rsync: disabled ✅" echo " Critical rsync: disabled ✅"
echo " State: INACTIVE ✅" echo " State: INACTIVE ✅"
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \ [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
echo " FolderView3: ${PARTNER_FOLDER_NAME:-} cleaned ✅" echo " FolderView3: ${PARTNER_FOLDER_NAME:-} cleaned ✅"
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \ [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
+8
View File
@@ -107,6 +107,14 @@ if ! check_rsync_enabled; then
exit 0 exit 0
fi fi
# Blocklist gate — refuse to sync with a partner blocked after offboard
BLOCKLIST_FILE="/boot/config/partnership_blocklist.db"
if [[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${REMOTE_SERVER_NAME}|" "$BLOCKLIST_FILE" 2>/dev/null; then
error "Rsync blocked — $REMOTE_SERVER_NAME is on the partnership blocklist"
error "Re-onboard the partnership to restore access: partnership_manager.sh --onboard"
exit 1
fi
resolve_remote_ip resolve_remote_ip
# ── Profile inference ───────────────────────────────────────────────────────────────────────── # ── Profile inference ─────────────────────────────────────────────────────────────────────────