Merge main: resolve conflict, keep WebRTC NAT fix + all new features
Resolved conflict in switch_backend.py — kept both: - Our branch: backup/restore, firewall matrix, services, scheduling, topology, PoE, port forwarding, WireGuard DNS profiles - Main: OPNsense NAT WebRTC/Mattermost calls fix (symmetric NAT → hybrid outbound NAT + static port rules) https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
@@ -0,0 +1,501 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
CONFIG_DIR="$HOME/.config/backup_project"
|
||||||
|
INSTALL_PATH="/usr/local/bin/backup_project"
|
||||||
|
|
||||||
|
# ─── Help ─────────────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
backup_project — Incremental folder backup with versioned naming
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
backup_project [folder] Back up a folder (or current dir)
|
||||||
|
backup_project --set-version X.Y.Z [folder] Set the next version number (respects existing folders)
|
||||||
|
backup_project --force X.Y.Z [folder] Force exact version number (ignores existing folders, one-time)
|
||||||
|
backup_project --install Install script system-wide to /usr/local/bin
|
||||||
|
backup_project --help Show this help message
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
backup_project # backs up current directory
|
||||||
|
backup_project ~/Documents/this-project # backs up specified folder
|
||||||
|
backup_project --set-version 2.0.15 # next backup will be 2.0.15 (or higher if folders exist)
|
||||||
|
backup_project --force 2.0.15 # next backup will be exactly 2.0.15
|
||||||
|
backup_project --force 2.0.15 ~/Documents/this-project
|
||||||
|
|
||||||
|
NOTES:
|
||||||
|
- On first run for any folder, you will be prompted for a starting version (e.g. 2.0.12)
|
||||||
|
- Each folder has its own config stored in ~/.config/backup_project/
|
||||||
|
- Backups are created in the same parent directory as the source folder
|
||||||
|
- To reset a folder's config: rm ~/.config/backup_project/<config>.cfg
|
||||||
|
- To view all configs: ls ~/.config/backup_project/
|
||||||
|
- To uninstall: sudo rm /usr/local/bin/backup_project
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Self-install ─────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$1" == "--install" ]]; then
|
||||||
|
echo "Installing backup_project to $INSTALL_PATH ..."
|
||||||
|
sudo cp "$0" "$INSTALL_PATH"
|
||||||
|
sudo chmod +x "$INSTALL_PATH"
|
||||||
|
echo "Done! You can now run 'backup_project [folder]' from anywhere."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Set version override ─────────────────────────────────────────────────────
|
||||||
|
# Usage: backup_project --set-version 2.0.15 [folder]
|
||||||
|
if [[ "$1" == "--set-version" ]]; then
|
||||||
|
NEW_VERSION="$2"
|
||||||
|
SOURCE="$(realpath "${3:-$(pwd)}")"
|
||||||
|
|
||||||
|
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
|
||||||
|
# If no argument given, use current directory
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
SOURCE="$(pwd)"
|
||||||
|
else
|
||||||
|
SOURCE="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip trailing slash, resolve to absolute path
|
||||||
|
SOURCE="$(realpath "${SOURCE%/}")"
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE" ]; then
|
||||||
|
echo "Error: '$SOURCE' is not a directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Load or create config for this source folder ────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "First time backing up '$(basename "$SOURCE")'."
|
||||||
|
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
|
||||||
|
|
||||||
|
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
|
||||||
|
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
|
||||||
|
echo "Error: version must be in format like 2.0.12 or 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split into prefix (everything before last dot) and starting minor (last number)
|
||||||
|
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
|
||||||
|
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
|
||||||
|
else
|
||||||
|
source "$FOLDER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Find next version number ─────────────────────────────────────────────────
|
||||||
|
BASE_NAME=$(basename "$SOURCE")
|
||||||
|
PARENT_DIR=$(dirname "$SOURCE")
|
||||||
|
|
||||||
|
if [ -n "$FORCE_MINOR" ]; then
|
||||||
|
# --force: use exactly this number, ignore existing folders, then clear the force
|
||||||
|
NEXT="$FORCE_MINOR"
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
|
||||||
|
else
|
||||||
|
# Find highest existing minor version >= START_MINOR
|
||||||
|
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
|
||||||
|
| grep -oP '\d+
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
|
; then
|
||||||
|
echo "Error: version must be in format like 2.0.15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "Error: no config found for '$SOURCE'. Run a backup first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_VERSION="${NEW_VERSION%.*}"
|
||||||
|
START_MINOR="${NEW_VERSION##*.}"
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
|
||||||
|
echo "✓ Next backup of '$(basename "$SOURCE")' will be '$NEW_VERSION'"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Force version override ───────────────────────────────────────────────────
|
||||||
|
# Usage: backup_project --force 2.0.15 [folder]
|
||||||
|
if [[ "$1" == "--force" ]]; then
|
||||||
|
NEW_VERSION="$2"
|
||||||
|
SOURCE="$(realpath "${3:-$(pwd)}")"
|
||||||
|
|
||||||
|
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
|
||||||
|
|
||||||
|
# ─── Resolve source folder ────────────────────────────────────────────────────
|
||||||
|
# If no argument given, use current directory
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
SOURCE="$(pwd)"
|
||||||
|
else
|
||||||
|
SOURCE="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip trailing slash, resolve to absolute path
|
||||||
|
SOURCE="$(realpath "${SOURCE%/}")"
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE" ]; then
|
||||||
|
echo "Error: '$SOURCE' is not a directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Load or create config for this source folder ────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "First time backing up '$(basename "$SOURCE")'."
|
||||||
|
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
|
||||||
|
|
||||||
|
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
|
||||||
|
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
|
||||||
|
echo "Error: version must be in format like 2.0.12 or 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split into prefix (everything before last dot) and starting minor (last number)
|
||||||
|
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
|
||||||
|
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
|
||||||
|
else
|
||||||
|
source "$FOLDER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Find next version number ─────────────────────────────────────────────────
|
||||||
|
BASE_NAME=$(basename "$SOURCE")
|
||||||
|
PARENT_DIR=$(dirname "$SOURCE")
|
||||||
|
|
||||||
|
# Find highest existing minor version, but only consider numbers >= START_MINOR
|
||||||
|
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
|
||||||
|
| grep -oP '\d+$' \
|
||||||
|
| awk -v start="$START_MINOR" '$1 >= start' \
|
||||||
|
| sort -n \
|
||||||
|
| tail -1)
|
||||||
|
|
||||||
|
if [ -z "$LAST" ]; then
|
||||||
|
NEXT="$START_MINOR"
|
||||||
|
else
|
||||||
|
NEXT=$((LAST + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
|
; then
|
||||||
|
echo "Error: version must be in format like 2.0.15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "Error: no config found for '$SOURCE'. Run a backup first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_VERSION="${NEW_VERSION%.*}"
|
||||||
|
FORCE_MINOR="${NEW_VERSION##*.}"
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "FORCE_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "✓ Next backup of '$(basename "$SOURCE")' will be forced to '$NEW_VERSION' (ignoring existing folders)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Resolve source folder ────────────────────────────────────────────────────
|
||||||
|
# If no argument given, use current directory
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
SOURCE="$(pwd)"
|
||||||
|
else
|
||||||
|
SOURCE="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip trailing slash, resolve to absolute path
|
||||||
|
SOURCE="$(realpath "${SOURCE%/}")"
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE" ]; then
|
||||||
|
echo "Error: '$SOURCE' is not a directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Load or create config for this source folder ────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "First time backing up '$(basename "$SOURCE")'."
|
||||||
|
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
|
||||||
|
|
||||||
|
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
|
||||||
|
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
|
||||||
|
echo "Error: version must be in format like 2.0.12 or 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split into prefix (everything before last dot) and starting minor (last number)
|
||||||
|
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
|
||||||
|
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
|
||||||
|
else
|
||||||
|
source "$FOLDER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Find next version number ─────────────────────────────────────────────────
|
||||||
|
BASE_NAME=$(basename "$SOURCE")
|
||||||
|
PARENT_DIR=$(dirname "$SOURCE")
|
||||||
|
|
||||||
|
# Find highest existing minor version, but only consider numbers >= START_MINOR
|
||||||
|
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
|
||||||
|
| grep -oP '\d+$' \
|
||||||
|
| awk -v start="$START_MINOR" '$1 >= start' \
|
||||||
|
| sort -n \
|
||||||
|
| tail -1)
|
||||||
|
|
||||||
|
if [ -z "$LAST" ]; then
|
||||||
|
NEXT="$START_MINOR"
|
||||||
|
else
|
||||||
|
NEXT=$((LAST + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
|
\
|
||||||
|
| awk -v start="$START_MINOR" '$1 >= start' \
|
||||||
|
| sort -n \
|
||||||
|
| tail -1)
|
||||||
|
|
||||||
|
if [ -z "$LAST" ]; then
|
||||||
|
NEXT="$START_MINOR"
|
||||||
|
else
|
||||||
|
NEXT=$((LAST + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
|
; then
|
||||||
|
echo "Error: version must be in format like 2.0.15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "Error: no config found for '$SOURCE'. Run a backup first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_VERSION="${NEW_VERSION%.*}"
|
||||||
|
START_MINOR="${NEW_VERSION##*.}"
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "FORCE_MINOR=" >> "$FOLDER_CONFIG"
|
||||||
|
echo "✓ Next backup of '$(basename "$SOURCE")' will be '$NEW_VERSION'"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Force version override ───────────────────────────────────────────────────
|
||||||
|
# Usage: backup_project --force 2.0.15 [folder]
|
||||||
|
if [[ "$1" == "--force" ]]; then
|
||||||
|
NEW_VERSION="$2"
|
||||||
|
SOURCE="$(realpath "${3:-$(pwd)}")"
|
||||||
|
|
||||||
|
if ! echo "$NEW_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+
|
||||||
|
|
||||||
|
# ─── Resolve source folder ────────────────────────────────────────────────────
|
||||||
|
# If no argument given, use current directory
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
SOURCE="$(pwd)"
|
||||||
|
else
|
||||||
|
SOURCE="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip trailing slash, resolve to absolute path
|
||||||
|
SOURCE="$(realpath "${SOURCE%/}")"
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE" ]; then
|
||||||
|
echo "Error: '$SOURCE' is not a directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Load or create config for this source folder ────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "First time backing up '$(basename "$SOURCE")'."
|
||||||
|
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
|
||||||
|
|
||||||
|
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
|
||||||
|
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
|
||||||
|
echo "Error: version must be in format like 2.0.12 or 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split into prefix (everything before last dot) and starting minor (last number)
|
||||||
|
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
|
||||||
|
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
|
||||||
|
else
|
||||||
|
source "$FOLDER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Find next version number ─────────────────────────────────────────────────
|
||||||
|
BASE_NAME=$(basename "$SOURCE")
|
||||||
|
PARENT_DIR=$(dirname "$SOURCE")
|
||||||
|
|
||||||
|
# Find highest existing minor version, but only consider numbers >= START_MINOR
|
||||||
|
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
|
||||||
|
| grep -oP '\d+$' \
|
||||||
|
| awk -v start="$START_MINOR" '$1 >= start' \
|
||||||
|
| sort -n \
|
||||||
|
| tail -1)
|
||||||
|
|
||||||
|
if [ -z "$LAST" ]; then
|
||||||
|
NEXT="$START_MINOR"
|
||||||
|
else
|
||||||
|
NEXT=$((LAST + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
|
; then
|
||||||
|
echo "Error: version must be in format like 2.0.15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "Error: no config found for '$SOURCE'. Run a backup first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_VERSION="${NEW_VERSION%.*}"
|
||||||
|
FORCE_MINOR="${NEW_VERSION##*.}"
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "FORCE_MINOR=$FORCE_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "✓ Next backup of '$(basename "$SOURCE")' will be forced to '$NEW_VERSION' (ignoring existing folders)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Resolve source folder ────────────────────────────────────────────────────
|
||||||
|
# If no argument given, use current directory
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
SOURCE="$(pwd)"
|
||||||
|
else
|
||||||
|
SOURCE="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip trailing slash, resolve to absolute path
|
||||||
|
SOURCE="$(realpath "${SOURCE%/}")"
|
||||||
|
|
||||||
|
if [ ! -d "$SOURCE" ]; then
|
||||||
|
echo "Error: '$SOURCE' is not a directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Load or create config for this source folder ────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
CONFIG_KEY=$(echo "$SOURCE" | tr '/' '_' | tr ' ' '_')
|
||||||
|
FOLDER_CONFIG="$CONFIG_DIR/${CONFIG_KEY}.cfg"
|
||||||
|
|
||||||
|
if [ ! -f "$FOLDER_CONFIG" ]; then
|
||||||
|
echo "First time backing up '$(basename "$SOURCE")'."
|
||||||
|
read -rp "Enter starting version (e.g. 2.0.12): " FULL_VERSION
|
||||||
|
|
||||||
|
# Validate format: must be X.Y.Z (digits and dots, at least one dot, ends in digits)
|
||||||
|
if ! echo "$FULL_VERSION" | grep -qP '^\d+(\.\d+)*\.\d+$'; then
|
||||||
|
echo "Error: version must be in format like 2.0.12 or 1.0.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split into prefix (everything before last dot) and starting minor (last number)
|
||||||
|
BASE_VERSION="${FULL_VERSION%.*}" # e.g. 2.0
|
||||||
|
START_MINOR="${FULL_VERSION##*.}" # e.g. 12
|
||||||
|
|
||||||
|
echo "BASE_VERSION=$BASE_VERSION" > "$FOLDER_CONFIG"
|
||||||
|
echo "START_MINOR=$START_MINOR" >> "$FOLDER_CONFIG"
|
||||||
|
echo "Saved: prefix='$BASE_VERSION', starting minor='$START_MINOR' for '$(basename "$SOURCE")'."
|
||||||
|
else
|
||||||
|
source "$FOLDER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Find next version number ─────────────────────────────────────────────────
|
||||||
|
BASE_NAME=$(basename "$SOURCE")
|
||||||
|
PARENT_DIR=$(dirname "$SOURCE")
|
||||||
|
|
||||||
|
# Find highest existing minor version, but only consider numbers >= START_MINOR
|
||||||
|
LAST=$(ls -d "${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}".* 2>/dev/null \
|
||||||
|
| grep -oP '\d+$' \
|
||||||
|
| awk -v start="$START_MINOR" '$1 >= start' \
|
||||||
|
| sort -n \
|
||||||
|
| tail -1)
|
||||||
|
|
||||||
|
if [ -z "$LAST" ]; then
|
||||||
|
NEXT="$START_MINOR"
|
||||||
|
else
|
||||||
|
NEXT=$((LAST + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEST="${PARENT_DIR}/${BASE_NAME}-${BASE_VERSION}.${NEXT}"
|
||||||
|
|
||||||
|
# ─── Copy ─────────────────────────────────────────────────────────────────────
|
||||||
|
cp -r "$SOURCE" "$DEST"
|
||||||
|
echo "✓ Backed up '$(basename "$SOURCE")' → '$DEST'"
|
||||||
@@ -5525,3 +5525,138 @@ def poe_budget():
|
|||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# OPNSENSE NAT — WEBRTC / MATTERMOST CALLS FIX
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# Problem: OPNsense uses symmetric NAT by default (port address translation).
|
||||||
|
# Each UDP flow to a *different* destination gets a *different* external source
|
||||||
|
# port. WebRTC ICE relies on STUN to discover the external address, but with
|
||||||
|
# symmetric NAT the STUN server sees a different port than the TURN or peer
|
||||||
|
# server will see — ICE candidate matching fails and calls drop. A commercial
|
||||||
|
# VPN "fixes" it because the VPN encapsulates UDP inside a single TCP/UDP
|
||||||
|
# tunnel that is full-cone from OPNsense's perspective.
|
||||||
|
#
|
||||||
|
# Fix: add a "static port" outbound NAT rule for each VLAN subnet.
|
||||||
|
# "Static port" (fixedport in pfSense/OPNsense) preserves the source port
|
||||||
|
# number through NAT. The external port equals the internal port, so every
|
||||||
|
# STUN server sees the same address:port — ICE succeeds.
|
||||||
|
#
|
||||||
|
# This requires switching outbound NAT from "Automatic" to "Hybrid" mode
|
||||||
|
# (hybrid = keep automatic rules, also honour manual ones). The static-port
|
||||||
|
# rules are added for UDP only; TCP and other protocols are unaffected.
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@app.post("/api/opnsense/nat/fix-webrtc")
|
||||||
|
def opnsense_nat_fix_webrtc(body: dict):
|
||||||
|
"""
|
||||||
|
Fix WebRTC / Mattermost Calls / STUN failures caused by symmetric NAT.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Switch outbound NAT mode to Hybrid (preserves automatic rules).
|
||||||
|
2. For each VLAN subnet in body.vlans, add a UDP static-port outbound
|
||||||
|
NAT rule on the WAN interface. Static port = source port preserved
|
||||||
|
through NAT so STUN candidates are consistent across servers.
|
||||||
|
3. Apply changes.
|
||||||
|
|
||||||
|
Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" }
|
||||||
|
Returns: { success, mode_set, rules_added: [...], rules_failed: [...] }
|
||||||
|
"""
|
||||||
|
require_session(body.get("token", ""))
|
||||||
|
cfg = _load_opnsense_cfg()
|
||||||
|
if not cfg.get("host"):
|
||||||
|
raise HTTPException(503, "OPNsense API not configured")
|
||||||
|
|
||||||
|
vlans = body.get("vlans", [])
|
||||||
|
wan_iface = body.get("wan_interface", "wan")
|
||||||
|
rules_added = []
|
||||||
|
rules_failed = []
|
||||||
|
|
||||||
|
# Step 1 — switch to Hybrid outbound NAT mode
|
||||||
|
try:
|
||||||
|
_opnsense_request(cfg, "firewall/nat/outbound/setMode",
|
||||||
|
method="POST", body={"mode": "hybrid"})
|
||||||
|
mode_set = True
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Could not set outbound NAT to hybrid: {e}")
|
||||||
|
|
||||||
|
# Step 2 — add a static-port UDP rule for each VLAN
|
||||||
|
for vlan in vlans:
|
||||||
|
subnet = vlan.get("subnet", "").strip()
|
||||||
|
name = vlan.get("name", f"VLAN{vlan.get('id','')}")
|
||||||
|
if not subnet:
|
||||||
|
continue
|
||||||
|
rule = {
|
||||||
|
"rule": {
|
||||||
|
"enabled": "1",
|
||||||
|
"sequence": "1",
|
||||||
|
"interface": wan_iface,
|
||||||
|
"ipprotocol": "inet",
|
||||||
|
"protocol": "UDP",
|
||||||
|
"source": {"network": subnet, "port": ""},
|
||||||
|
"sourceport": "",
|
||||||
|
"destination": {"network": "any", "port": ""},
|
||||||
|
"destinationport": "",
|
||||||
|
"target": "",
|
||||||
|
"targetip": "",
|
||||||
|
"targetip_subnet": "32",
|
||||||
|
"nonat": "0",
|
||||||
|
"staticnatport": "1",
|
||||||
|
"descr": f"Static port UDP — {name} WebRTC/STUN fix",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = _opnsense_request(cfg, "firewall/nat/outbound/addRule",
|
||||||
|
method="POST", body=rule)
|
||||||
|
rules_added.append({"vlan": name, "subnet": subnet, "uuid": resp.get("uuid","")})
|
||||||
|
except Exception as e:
|
||||||
|
rules_failed.append({"vlan": name, "subnet": subnet, "error": str(e)})
|
||||||
|
|
||||||
|
# Step 3 — apply
|
||||||
|
try:
|
||||||
|
_opnsense_request(cfg, "firewall/nat/outbound/apply", method="POST")
|
||||||
|
except Exception as e:
|
||||||
|
rules_failed.append({"vlan": "apply", "error": str(e)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": len(rules_added) > 0 and not rules_failed,
|
||||||
|
"mode_set": mode_set,
|
||||||
|
"rules_added": rules_added,
|
||||||
|
"rules_failed": rules_failed,
|
||||||
|
"explanation": (
|
||||||
|
"Static-port NAT preserves UDP source ports through NAT. "
|
||||||
|
"STUN now sees the same external address regardless of destination server. "
|
||||||
|
"WebRTC ICE candidates match — calls work without VPN."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/opnsense/nat/webrtc-status")
|
||||||
|
def opnsense_nat_webrtc_status():
|
||||||
|
"""
|
||||||
|
Check whether static-port outbound NAT rules exist for WebRTC.
|
||||||
|
Returns current outbound NAT mode and any existing static-port rules.
|
||||||
|
"""
|
||||||
|
cfg = _load_opnsense_cfg()
|
||||||
|
if not cfg.get("host"):
|
||||||
|
raise HTTPException(503, "OPNsense API not configured")
|
||||||
|
try:
|
||||||
|
data = _opnsense_request(cfg, "firewall/nat/outbound/get")
|
||||||
|
mode = data.get("natoutbound", {}).get("mode", "unknown")
|
||||||
|
rules = data.get("natoutbound", {}).get("rule", {})
|
||||||
|
static_rules = [
|
||||||
|
{"uuid": uid, "descr": r.get("descr",""), "source": r.get("source",{})}
|
||||||
|
for uid, r in (rules.items() if isinstance(rules, dict) else {}.items())
|
||||||
|
if str(r.get("staticnatport","0")) == "1"
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"hybrid": mode == "hybrid",
|
||||||
|
"static_rules": static_rules,
|
||||||
|
"webrtc_ready": mode == "hybrid" and len(static_rules) > 0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user