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:
co-authored by
Claude Sonnet 4.6
parent
09e9cec0c3
commit
69464ba385
@@ -67,12 +67,26 @@
|
||||
# PARTNERSHIP_SYNC_INTERVAL — informational — actual schedule in cron
|
||||
# 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 ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# partnership_manage.sh --onboard
|
||||
# partnership_manage.sh --offboard
|
||||
# partnership_manage.sh --transfer --confirm=i-understand-this-transfers-ownership
|
||||
# partnership_manage.sh --check --remote-seen|--remote-unseen
|
||||
# partnership_manage.sh --status
|
||||
# partnership_manage.sh --unblock <hostname>
|
||||
# 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"
|
||||
|
||||
SSH_TIMEOUT=15
|
||||
BLOCKLIST_FILE="/boot/config/partnership_blocklist.db"
|
||||
|
||||
# ── Parse mode flags before parse_args ────────────────────────────────────────────────────────
|
||||
MODE=""
|
||||
@@ -88,6 +103,7 @@ TRANSFER_CONFIRM_INPUT=""
|
||||
REMOTE_SEEN=false
|
||||
REMOTE_UNSEEN=false
|
||||
REASON="manual"
|
||||
UNBLOCK_HOST=""
|
||||
FILTERED_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
@@ -97,11 +113,18 @@ for arg in "$@"; do
|
||||
--transfer) MODE="transfer" ;;
|
||||
--check) MODE="check" ;;
|
||||
--status) MODE="status" ;;
|
||||
--unblock) MODE="unblock" ;;
|
||||
--confirm=*) TRANSFER_CONFIRM_INPUT="${arg#--confirm=}" ;;
|
||||
--remote-seen) REMOTE_SEEN=true ;;
|
||||
--remote-unseen) REMOTE_UNSEEN=true ;;
|
||||
--reason=*) REASON="${arg#--reason=}" ;;
|
||||
*) FILTERED_ARGS+=("$arg") ;;
|
||||
*)
|
||||
if [[ "$MODE" == "unblock" ]] && [[ -z "$UNBLOCK_HOST" ]] && [[ "$arg" != --* ]]; then
|
||||
UNBLOCK_HOST="$arg"
|
||||
else
|
||||
FILTERED_ARGS+=("$arg")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -154,6 +177,7 @@ if [[ -z "$MODE" ]]; then
|
||||
echo " partnership_manage.sh --transfer --confirm=..."
|
||||
echo " partnership_manage.sh --check --remote-seen|--remote-unseen"
|
||||
echo " partnership_manage.sh --status"
|
||||
echo " partnership_manage.sh --unblock <hostname>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -331,6 +355,35 @@ remove_tailscale_device() {
|
||||
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_DIR="/usr/local/emhttp/plugins/folder.view3"
|
||||
FOLDERVIEW3_JSON="/boot/config/plugins/folder.view3/docker.json"
|
||||
@@ -558,6 +611,40 @@ update_master_conf() {
|
||||
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 ━━━
|
||||
# ==============================================================================================
|
||||
@@ -647,6 +734,16 @@ if [[ "$MODE" == "status" ]]; then
|
||||
echo " ⚠️ Remote offline counter: ${OFFLINE_DAYS}/${PARTNERSHIP_OFFLINE_THRESHOLD}"
|
||||
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 "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
fi
|
||||
@@ -681,6 +778,12 @@ if [[ "$MODE" == "check" ]]; then
|
||||
log "Partnership check — remote unseen ($OFFLINE_COUNT/$THRESHOLD_INTERVALS)"
|
||||
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
|
||||
REMOTE_IP=$(tailscale ip -4 "${REMOTE_SERVER_NAME,,}" 2>/dev/null)
|
||||
if [[ -z "$REMOTE_IP" ]]; then
|
||||
@@ -775,6 +878,12 @@ if [[ "$MODE" == "onboard" ]]; then
|
||||
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 "Mirror: $MIRROR_ID ($MIRROR)"
|
||||
|
||||
@@ -838,6 +947,8 @@ if [[ "$MODE" == "onboard" ]]; then
|
||||
write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "onboard"
|
||||
log "Local state: ACTIVE ✅"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && remove_from_blocklist "$MIRROR"
|
||||
|
||||
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
||||
echo "0" > "$OFFLINE_COUNTER"
|
||||
|
||||
@@ -924,6 +1035,8 @@ if [[ "$MODE" == "offboard" ]]; then
|
||||
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
|
||||
log "Local state: INACTIVE ✅"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && add_to_blocklist "$OWNER" "$REASON"
|
||||
|
||||
OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null)
|
||||
if [[ -n "$OWNER_IP" ]]; then
|
||||
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 " Your WebUIs: reconfigured → localhost ✅"
|
||||
echo " State: INACTIVE ✅"
|
||||
echo " Blocklist: $OWNER blocked — re-onboard to permit access again ✅"
|
||||
echo " Owner: will finalise + final sync on next --check ✅"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
exit 0
|
||||
@@ -1007,6 +1121,8 @@ if [[ "$MODE" == "offboard" ]]; then
|
||||
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
|
||||
log "Local state: INACTIVE ✅"
|
||||
|
||||
[[ "$DRY_RUN" == false ]] && add_to_blocklist "$MIRROR" "$REASON"
|
||||
|
||||
if [[ "$MIRROR_REACHABLE" == true ]]; then
|
||||
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
||||
fi
|
||||
@@ -1057,6 +1173,7 @@ if [[ "$MODE" == "offboard" ]]; then
|
||||
echo " WebUI failures: $WEBUI_FAILURES"
|
||||
echo " Critical rsync: disabled ✅"
|
||||
echo " State: INACTIVE ✅"
|
||||
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
|
||||
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
|
||||
echo " FolderView3: ${PARTNER_FOLDER_NAME:-} cleaned ✅"
|
||||
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
|
||||
|
||||
@@ -107,6 +107,14 @@ if ! check_rsync_enabled; then
|
||||
exit 0
|
||||
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
|
||||
|
||||
# ── Profile inference ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user