commit 897d5a0d36712df752e33b2a6407a954bc7c071a Author: Andrii Syrovatko Date: Wed Apr 8 15:35:23 2026 +0300 Initial release: Billing infrastructure automation (IP manager). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2aa9401 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Build and Release Folders +bin-debug/ +bin-release/ +[Oo]bj/ +[Bb]in/ + +# Other files and folders +.settings/ +logs/ +.logs/ +sps_logs/* +.sps_logs/* + +# Executables +*.conf +*.swf +*.air +*.ipa +*.apk +*.log +*.tmp +*.log* +*.html* +*tmp_* +*variables* + +# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties` +# should NOT be excluded as they contain compiler settings and other important +# information for Eclipse / Flash Builder. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..08b02df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrii Syrovatko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18c9d0d --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# IP Manager for ISPs (Billing ↔ IPSET) + +### 📋 Overview +This script acts as a robust bridge between the **BOSS (Billing System)** (or any other billing/CRM) and the **Linux Netfilter (ipset)**. It automates customer access control by dynamically moving IP addresses between different firewall sets based on their current account status. + +### ⚙️ How It Works +The system logic relies on two primary IPSET groups: +* **Allowed (`allowed_customers_nets`)**: IPs in this set are granted full Internet access. +* **Restricted (`restricted_customers_nets`)**: IPs in this set are redirected to a captive portal (e.g., for billing reminders or payment pages). + +### 🚀 Commands & Logic Flow +The billing system invokes this script with specific commands to reflect customer state changes: + +| Command | Action | Customer Status / Use Case | +| :--- | :--- | :--- | +| **`NEW`** | Add IP to *Allowed* | New connection or service activation. | +| **`RESTRICT`** | Add to *Allowed* + *Restricted* | Balance is zero; redirecting to portal. | +| **`RESUME`** | Remove from *Restricted* | Payment received; restoring access. | +| **`SUSPEND`** | Remove from both sets | Manual temporary service suspension. | +| **`DELETE`** | Remove from both sets | Contract terminated or account closed. | +| **`UPDATE`** | Swap `OLD_IP` with `NEW_IP` | Change of equipment or static IP address. | + +### 🛠 Installation & Setup +1. **Clone the repository:** + ```bash + git clone [https://github.com/your-username/s4k-ip-manager.git](https://github.com/your-username/s4k-ip-manager.git) + cd s4k-ip-manager + ``` +2. **Configure the environment:** +Create your local configuration file from the provided example: + ```bash + cp ip_manager.conf.example ip_manager.conf + ``` +Edit `ip_manager.conf` to set your specific IPSET names, log paths, and email for alerts. +3. **Prepare log directories:** + ```bash +sudo mkdir -p /var/log/ip-manager +sudo chown $USER:$USER /var/log/ip-manager + ``` +4. **Testing:** +Before running in production, ensure `DRY_RUN=1` is set in your .conf file to simulate actions without modifying live firewall rules. + +### 🛡 Reliability & Safety Features +* **Atomic-like Locking:** Utilizes flock to manage a wait queue, preventing race conditions when the billing system sends hundreds of concurrent updates. +* **Strict Validation:** Uses regex to validate IPv4 formats and automatically cleans input (e.g., stripping trailing /32 masks). +* **State Persistence:** Automatically executes ipset save to ensure changes survive a system reboot. +* **Linter-Friendly:** Fully compliant with ShellCheck (SC1090 handled) for high-quality, predictable execution. + +### ⚖️ License +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + + +### Use at your own risk! The author is not responsible for any data loss! diff --git a/ip_manager.conf.example b/ip_manager.conf.example new file mode 100644 index 0000000..f63d1ad --- /dev/null +++ b/ip_manager.conf.example @@ -0,0 +1,22 @@ +# ip_manager.conf - Configuration for IPSET Management Script + +# --- IPSET Sets --- +# Set for active customers with Internet access +ALLOW_SET="allowed_customers_nets" +# Set for customers redirected to the captive portal (no payment) +RESTRICT_SET="restricted_customers_nets" + +# --- Paths & Binaries --- +LOG_DIR="/var/log/ip-manager" +LOG_FILE="${LOG_DIR}/ip-manager.log" +IPSET_SAVE_FILE="/etc/iptables/ipsets" +LOCK_FILE="/tmp/ip-manager.lock" + +# --- Notifications --- +# Email to receive lock timeout warnings +MAIL_RECEIVER="support@your-isp.net" + +# --- Safety Settings --- +LOCK_TIMEOUT=300 +# Set to 0 for production, 1 for testing +DRY_RUN=1 diff --git a/ip_manager.sh b/ip_manager.sh new file mode 100755 index 0000000..3145522 --- /dev/null +++ b/ip_manager.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +# ============================================================================= +# Script Name : ip_manager.sh +# Description : Manage customer IPs in IPSET sets (allow / restrict) +# Called by BOSS4 billing system on customer state changes. +# +# Commands (also PARAMETERS): +# NEW IP — new customer: add to ALLOW set +# DELETE IP — delete/prototype: remove from BOTH sets +# RESTRICT IP — no payment: add to BOTH sets (allow + restrict) +# SUSPEND IP — manual suspend: remove from BOTH sets +# RESUME IP — resume: remove from restrict, add to allow (if not there) +# UPDATE IP_OLD IP_NEW — IP/tariff change: replace OLD with NEW in each set +# +# Usage: +# ./ip_manager.sh COMMAND IP # for NEW/DELETE/RESTRICT/SUSPEND/RESUME +# ./ip_manager.sh UPDATE IP_OLD IP_NEW # for UPDATE +# +# Author : syr4ok (Andrii Syrovatko) +# Version : 2.0.1r +# ============================================================================= + +# --- STRICT MODE --- +set -uo pipefail +IFS=$'\n\t' + +# --- Configuration Loader --- +CONFIG_FILE="$(dirname "$0")/ip_manager.conf" + +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" +else + echo "Error: Configuration file not found. Create ip_manager.conf from example." + exit 1 +fi + +# --- Environment & Tools --- +IPSET_BIN=$(which ipset 2>/dev/null || true) +MAIL_BIN=$(which mail 2>/dev/null || true) + +if [[ -z "$IPSET_BIN" ]]; then + echo "Error: 'ipset' utility not found. Please install it." + exit 1 +fi + +if [[ -z "$MAIL_BIN" ]]; then + echo "Warning: 'mail' utility not found. Notifications will be disabled." +fi + +# --- LOG COLORS --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# INIT — ensure log directory and file exist +mkdir -p "${LOG_DIR}" 2>/dev/null || true +CAN_LOG_TO_FILE=0 +if touch "${LOG_FILE}" 2>/dev/null; then + CAN_LOG_TO_FILE=1 + echo -e "${BLUE}Info: Log file (${LOG_FILE}) created or exists. Logging to file enabled.${NC}" +else + echo -e "${YELLOW}Warning: Cannot write to log file (${LOG_FILE}). Logging to file disabled.${NC}" +fi +# --- LOGGING --- +log() { + local level="$1"; shift + local msg="$*" + local ts + ts=$(date '+%Y-%m-%d %H:%M:%S') + + # Clear colors for file logging (keep colors in console, but remove them from file logs) + local plain_msg + plain_msg=$(echo -e "${msg}" | sed 's/\x1B\[[0-9;]*[mK]//g') + + # Write logs to file if possible + if [[ "${CAN_LOG_TO_FILE}" -eq 1 ]]; then + echo "${ts} [${level}] ${plain_msg}" >> "${LOG_FILE}" + fi + + # Type logs tto console forever + echo -e "${ts} [${level}] ${msg}" +} + +log_info() { log "INFO " "${GREEN}${*}${NC}"; } +log_warn() { log "WARN " "${YELLOW}${*}${NC}"; } +log_error() { log "ERROR" "${RED}${*}${NC}"; } +log_debug() { log "DEBUG" "${CYAN}${*}${NC}"; } +log_sep() { + log "INFO " "${BLUE}------------------------------------------------------------------------------${NC}" +} + +# LOCK — single instance with wait queue (flock-based) +acquire_lock() { + local call_args + call_args="$(IFS=" "; echo "$*")" + exec 9>"${LOCK_FILE}" + log_debug "Waiting for lock (timeout: ${LOCK_TIMEOUT}s) PID=$$" + if ! flock -w "${LOCK_TIMEOUT}" 9; then + log_error "Could not acquire lock after ${LOCK_TIMEOUT}s — another instance hung? Aborting!" + + if [[ -n "${MAIL_BIN}" ]]; then + echo -e "WARN: $0\nProcess for [${call_args}] timed out by lockfile (${LOCK_FILE})\nafter allowed ${LOCK_TIMEOUT}s waiting.\nexit 9" \ + | "${MAIL_BIN}" -s "WARN: Process timed out by lockfile after ${LOCK_TIMEOUT}s" "$MAIL_RECEIVER" + fi + exit 9 + fi + echo $$ >&9 + log_debug "Lock acquired by PID=$$" +} + +release_lock() { + flock -u 9 + exec 9>&- + log_debug "Lock released by PID=$$" +} + +# --- HELPERS --- +usage() { + echo -e "${BOLD}Usage:${NC}" + echo -e " $0 ${CYAN}NEW${NC} IP" + echo -e " $0 ${CYAN}DELETE${NC} IP" + echo -e " $0 ${CYAN}RESTRICT${NC} IP" + echo -e " $0 ${CYAN}SUSPEND${NC} IP" + echo -e " $0 ${CYAN}RESUME${NC} IP" + echo -e " $0 ${CYAN}UPDATE${NC} IP_OLD IP_NEW" + echo +} + +strip_mask() { + local val="${1}" + val="${val// /}" # trim all spaces + echo "${val%%/32}" +} + +# Validate IPv4 address (without mask) +is_valid_ip() { + local ip="$1" + local octet='(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' + [[ "${ip}" =~ ^${octet}\.${octet}\.${octet}\.${octet}$ ]] +} + +ipset_exists() { + local set="$1" + local ip="$2" + # grep -q closes pipe early after first match → ipset gets SIGPIPE → pipefail returns 141 + # fix: read full ipset output first, then grep from variable + local dump + dump=$("${IPSET_BIN}" -L "${set}" 2>/dev/null) + grep -qw "^${ip}$" <<< "${dump}" +} + +ipset_add() { + local set="$1" + local ip="$2" + + if ipset_exists "${set}" "${ip}"; then + log_warn "IP ${ip} already in set [${set}] — skipping add" + return 0 + fi + + log_debug "ipset add ${set} ${ip}" + if [[ "${DRY_RUN}" -eq 1 ]]; then + log_warn "[DRY-RUN] Would ADD ${ip} to [${set}]" + return 0 + fi + if ! "${IPSET_BIN}" add "${set}" "${ip}" 2>&1 | tee -a "${LOG_FILE}"; then + log_error "Failed to ADD ${ip} to [${set}]" + return 1 + fi + log_info "Added ${ip} → [${set}]" +} + +ipset_del() { + local set="$1" + local ip="$2" + + if ! ipset_exists "${set}" "${ip}"; then + log_warn "IP ${ip} not found in set [${set}] — skipping del" + return 0 + fi + + log_debug "ipset del ${set} ${ip}" + if [[ "${DRY_RUN}" -eq 1 ]]; then + log_warn "[DRY-RUN] Would DEL ${ip} from [${set}]" + return 0 + fi + if ! "${IPSET_BIN}" del "${set}" "${ip}" 2>&1 | tee -a "${LOG_FILE}"; then + log_error "Failed to DEL ${ip} from [${set}]" + return 1 + fi + log_info "Removed ${ip} ← [${set}]" +} + +ipset_save() { + log_debug "Saving ipset state to ${IPSET_SAVE_FILE}" + if [[ "${DRY_RUN}" -eq 1 ]]; then + log_warn "[DRY-RUN] Would save ipset state to ${IPSET_SAVE_FILE}" + return 0 + fi + if ! "${IPSET_BIN}" save > "${IPSET_SAVE_FILE}" 2>&1; then + log_error "Failed to save ipset state to ${IPSET_SAVE_FILE}" + return 1 + fi + log_info "ipset state saved → ${IPSET_SAVE_FILE}" +} + +# --- COMMAND HANDLERS --- +cmd_new() { + local ip="$1" + log_info "[NEW] New customer IP: ${ip} — adding to allow set" + ipset_add "${ALLOW_SET}" "${ip}" + ipset_save +} + +cmd_delete() { + local ip="$1" + log_info "[DELETE] Removing ${ip} from BOTH sets" + ipset_del "${ALLOW_SET}" "${ip}" + ipset_del "${RESTRICT_SET}" "${ip}" + ipset_save +} + +cmd_restrict() { + local ip="$1" + log_info "[RESTRICT] Adding ${ip} to ALLOW + RESTRICT sets (no payment)" + ipset_add "${ALLOW_SET}" "${ip}" + ipset_add "${RESTRICT_SET}" "${ip}" + ipset_save +} + +cmd_suspend() { + local ip="$1" + log_info "[SUSPEND] Removing ${ip} from BOTH sets (manual suspend)" + ipset_del "${ALLOW_SET}" "${ip}" + ipset_del "${RESTRICT_SET}" "${ip}" + ipset_save +} + +cmd_resume() { + local ip="$1" + log_info "[RESUME] Resuming ${ip}: remove from restrict, ensure in allow" + ipset_del "${RESTRICT_SET}" "${ip}" + ipset_add "${ALLOW_SET}" "${ip}" + ipset_save +} + +cmd_update() { + local ip_old="$1" + local ip_new="$2" + log_info "[UPDATE] Replacing ${ip_old} → ${ip_new}" + + local changed=0 + + if ipset_exists "${ALLOW_SET}" "${ip_old}"; then + log_debug "${ip_old} found in [${ALLOW_SET}] — swapping" + ipset_del "${ALLOW_SET}" "${ip_old}" + ipset_add "${ALLOW_SET}" "${ip_new}" + changed=1 + else + log_warn "${ip_old} not found in [${ALLOW_SET}] — no change in this set" + fi + + if ipset_exists "${RESTRICT_SET}" "${ip_old}"; then + log_debug "${ip_old} found in [${RESTRICT_SET}] — swapping" + ipset_del "${RESTRICT_SET}" "${ip_old}" + ipset_add "${RESTRICT_SET}" "${ip_new}" + changed=1 + else + log_warn "${ip_old} not found in [${RESTRICT_SET}] — no change in this set" + fi + + if [[ "${changed}" -eq 0 ]]; then + log_warn "[UPDATE] ${ip_old} was not found in ANY set — nothing was changed" + fi + + ipset_save +} + +# MAIN EXIT HELPER — always logs final status + releases lock before return +main_exit() { + local code="$1" + if [[ "${code}" -eq 0 ]]; then + log_info "Done. Exit 0 (OK)" + else + log_error "Finished with errors (code: ${code})" + fi + # log_sep + release_lock + # sleep 45 # DEBUG: keep lock released for a while to allow testing concurrent runs and lock timeout + return "${code}" +} + +# MAIN +main() { + local error_code=0 + + log_sep + log_info "Invoked: $0" + log_info "Arguments ($#): $(IFS=" "; echo "$*")" + acquire_lock "$@" + [[ "${DRY_RUN}" -eq 1 ]] && log_warn "=== DRY-RUN MODE — no real ipset actions will be performed ===" + + if [[ $# -lt 2 ]]; then + log_error "Not enough arguments! At least 2 required, got $#" + usage + main_exit 1; return + fi + + local raw_command="${1^^}" + local ip_arg1 + local ip_arg2 + + ip_arg1=$(strip_mask "${2:-}") + ip_arg2=$(strip_mask "${3:-}") + + log_info "COMMAND: ${raw_command} | IP1: ${ip_arg1:-—} | IP2: ${ip_arg2:-—}" + + # Validate IP1 + if [[ -z "${ip_arg1}" ]]; then + log_error "IP1 is empty or blank!" + main_exit 1; return + fi + if ! is_valid_ip "${ip_arg1}"; then + log_error "IP1 is not a valid IPv4 address: '${ip_arg1}'" + main_exit 1; return + fi + + # Validate IP2 only for UPDATE + if [[ "${raw_command}" == "UPDATE" ]]; then + if [[ $# -ne 3 ]]; then + log_error "UPDATE requires exactly 2 IPs: IP_OLD IP_NEW, got $(( $# - 1 )) IP(s)" + usage + main_exit 1; return + fi + if [[ -z "${ip_arg2}" ]]; then + log_warn "[UPDATE] IP_NEW is empty or blank — nothing to do" + main_exit 0; return + fi + if ! is_valid_ip "${ip_arg2}"; then + log_error "IP2 (IP_NEW) is not a valid IPv4 address: '${ip_arg2}'" + main_exit 1; return + fi + if [[ "${ip_arg1}" == "${ip_arg2}" ]]; then + log_warn "[UPDATE] IP_OLD == IP_NEW (${ip_arg1}) — nothing to do" + main_exit 0; return + fi + fi + + case "${raw_command}" in + + NEW | DELETE | RESTRICT | SUSPEND | RESUME) + if [[ $# -gt 2 ]]; then + log_error "Command ${raw_command} accepts exactly 1 IP, but got $(( $# - 1 )): ${*:2}" + usage + main_exit 1; return + fi + case "${raw_command}" in + NEW) cmd_new "${ip_arg1}" ;; + DELETE) cmd_delete "${ip_arg1}" ;; + RESTRICT) cmd_restrict "${ip_arg1}" ;; + SUSPEND) cmd_suspend "${ip_arg1}" ;; + RESUME) cmd_resume "${ip_arg1}" ;; + esac + ;; + + UPDATE) + cmd_update "${ip_arg1}" "${ip_arg2}" + ;; + + *) + log_error "Unknown command: '${raw_command}'" + usage + main_exit 5; return + ;; + esac + + main_exit "${error_code}" +} + +main "$@"; _ec=$? +[[ "${DRY_RUN}" -eq 1 ]] && exit 0 || exit "${_ec}"