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
|
# 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 ]] && \
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user