Compare commits
12 Commits
e8556e9074
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
6b73b5c79a
|
|||
|
94d2c711ce
|
|||
|
|
7770e09feb | ||
|
5545ccaf1f
|
|||
| d82a96eccb | |||
| 133b6fb281 | |||
|
ee69659a82
|
|||
|
5b8120eb27
|
|||
|
|
fce70b05da | ||
|
0396c86b72
|
|||
|
fb8a44ed2b
|
|||
|
dda1618fa4
|
24
README.md
24
README.md
@@ -8,28 +8,40 @@ This file is regenerated by `scripts/generate_readme.sh` on every push.
|
|||||||
|
|
||||||
Run on Linux/macOS using curl:
|
Run on Linux/macOS using curl:
|
||||||
|
|
||||||
|
### enable_wol_proxmox.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o enable_wol_proxmox.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/enable_wol_proxmox.sh" && chmod +x enable_wol_proxmox.sh && ./"enable_wol_proxmox.sh"
|
||||||
|
```
|
||||||
|
|
||||||
### fakepve.sh
|
### fakepve.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL -o fakepve.sh "ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools/raw/branch/main/fakepve.sh" && chmod +x fakepve.sh && ./"fakepve.sh"
|
curl -fsSL -o fakepve.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/fakepve.sh" && chmod +x fakepve.sh && ./"fakepve.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
### list_root_domains.sh
|
### list_root_domains.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL -o list_root_domains.sh "ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools/raw/branch/main/list_root_domains.sh" && chmod +x list_root_domains.sh && ./"list_root_domains.sh"
|
curl -fsSL -o list_root_domains.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/list_root_domains.sh" && chmod +x list_root_domains.sh && ./"list_root_domains.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
### selfsigned_certs.sh
|
### selfsigned_certs.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL -o selfsigned_certs.sh "ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools/raw/branch/main/selfsigned_certs.sh" && chmod +x selfsigned_certs.sh && ./"selfsigned_certs.sh"
|
curl -fsSL -o selfsigned_certs.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/selfsigned_certs.sh" && chmod +x selfsigned_certs.sh && ./"selfsigned_certs.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### setup-nut-slave.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o setup-nut-slave.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/setup-nut-slave.sh" && chmod +x setup-nut-slave.sh && ./"setup-nut-slave.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
### setup_deploy_user.sh
|
### setup_deploy_user.sh
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL -o setup_deploy_user.sh "ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools/raw/branch/main/setup_deploy_user.sh" && chmod +x setup_deploy_user.sh && ./"setup_deploy_user.sh"
|
curl -fsSL -o setup_deploy_user.sh "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/setup_deploy_user.sh" && chmod +x setup_deploy_user.sh && ./"setup_deploy_user.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Windows (.bat)
|
## Windows (.bat)
|
||||||
@@ -40,10 +52,10 @@ Run from PowerShell:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$dest = Join-Path $env:TEMP "win-mao.bat";
|
$dest = Join-Path $env:TEMP "win-mao.bat";
|
||||||
Invoke-WebRequest -Uri "ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools/raw/branch/main/win-mao.bat" -OutFile $dest;
|
Invoke-WebRequest -Uri "https://git.hudsonriggs.systems/HRiggs/Tools/raw/branch/main/win-mao.bat" -OutFile $dest;
|
||||||
& $dest
|
& $dest
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Generated from repo: `ssh://git@git.hudsonriggs.systems:22113/HRiggs/tools` on branch `main`.
|
Generated from repo: `https://git.hudsonriggs.systems/HRiggs/Tools` on branch `main`.
|
||||||
|
|||||||
221
enable_wol_proxmox.sh
Normal file
221
enable_wol_proxmox.sh
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Enable Wake-on-LAN on the primary network interface of a Proxmox/Debian host
|
||||||
|
# Automates: https://i12bretro.github.io/tutorials/0608.html
|
||||||
|
# - Installs ethtool
|
||||||
|
# - Detects the primary network interface
|
||||||
|
# - Checks WOL support
|
||||||
|
# - Enables WOL and configures persistence via /etc/network/interfaces
|
||||||
|
# - Verifies configuration
|
||||||
|
# - Prints the MAC address and hostname
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Helper functions --------------------------------------------------------
|
||||||
|
|
||||||
|
err() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
echo "[*] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return 0 if interface is physical (non-virtual)
|
||||||
|
is_physical_iface() {
|
||||||
|
local iface="$1"
|
||||||
|
local dev_path
|
||||||
|
dev_path=$(readlink -f "/sys/class/net/${iface}/device" 2>/dev/null || true)
|
||||||
|
[[ -n "$dev_path" && "$dev_path" != *"/virtual/"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve bridge/bond to the underlying physical NIC, if possible
|
||||||
|
resolve_physical_iface() {
|
||||||
|
local iface="$1"
|
||||||
|
local candidate resolved
|
||||||
|
|
||||||
|
# If iface is a bridge, iterate its members
|
||||||
|
if [[ -d "/sys/class/net/${iface}/bridge" ]]; then
|
||||||
|
for brif in /sys/class/net/${iface}/brif/*; do
|
||||||
|
[[ -e "$brif" ]] || continue
|
||||||
|
candidate=$(basename "$brif")
|
||||||
|
resolved=$(resolve_physical_iface "$candidate")
|
||||||
|
[[ -n "$resolved" ]] && echo "$resolved" && return
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If iface is a bond, walk its slaves
|
||||||
|
if [[ -f "/sys/class/net/${iface}/bonding/slaves" ]]; then
|
||||||
|
for slave in $(<"/sys/class/net/${iface}/bonding/slaves"); do
|
||||||
|
resolved=$(resolve_physical_iface "$slave")
|
||||||
|
[[ -n "$resolved" ]] && echo "$resolved" && return
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If iface itself is physical, return it
|
||||||
|
if is_physical_iface "$iface"; then
|
||||||
|
echo "$iface"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: return original iface even if virtual
|
||||||
|
echo "$iface"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Preconditions -----------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
err "This script must be run as root (sudo)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ip >/dev/null 2>&1; then
|
||||||
|
err "The 'ip' command is required but not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Install ethtool ---------------------------------------------------------
|
||||||
|
|
||||||
|
if ! command -v ethtool >/dev/null 2>&1; then
|
||||||
|
info "Installing ethtool..."
|
||||||
|
apt-get update -y >/dev/null
|
||||||
|
apt-get install -y ethtool >/dev/null
|
||||||
|
else
|
||||||
|
info "ethtool already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detect primary network interface ----------------------------------------
|
||||||
|
|
||||||
|
# 1) Try to get interface from default route
|
||||||
|
PRIMARY_IF=$(ip route 2>/dev/null | awk '/default/ {print $5; exit}')
|
||||||
|
|
||||||
|
# 2) Fallback: first interface with a private IPv4 address
|
||||||
|
if [[ -z "${PRIMARY_IF:-}" ]]; then
|
||||||
|
PRIMARY_IF=$(ip -o -4 addr show scope global | \
|
||||||
|
awk '
|
||||||
|
{
|
||||||
|
split($4, a, "/");
|
||||||
|
ip = a[1];
|
||||||
|
if (ip ~ /^10\./ ||
|
||||||
|
ip ~ /^192\.168\./ ||
|
||||||
|
ip ~ /^172\.(1[6-9]|2[0-9]|3[0-1])\./) {
|
||||||
|
print $2;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "${PRIMARY_IF:-}" ]] || err "Could not automatically determine primary network interface."
|
||||||
|
|
||||||
|
info "Detected primary interface: ${PRIMARY_IF}"
|
||||||
|
|
||||||
|
PHYSICAL_IF=$(resolve_physical_iface "$PRIMARY_IF")
|
||||||
|
if [[ "$PHYSICAL_IF" != "$PRIMARY_IF" ]]; then
|
||||||
|
info "Resolved underlying physical interface: ${PHYSICAL_IF}"
|
||||||
|
PRIMARY_IF="$PHYSICAL_IF"
|
||||||
|
else
|
||||||
|
info "Using ${PRIMARY_IF} as the primary physical interface."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Get MAC address & IP ----------------------------------------------------
|
||||||
|
|
||||||
|
MAC_ADDR=$(ip -o link show "$PRIMARY_IF" | awk '{for (i=1; i<=NF; i++) if ($i=="link/ether") print $(i+1)}')
|
||||||
|
IP_ADDR=$(ip -o -4 addr show "$PRIMARY_IF" | awk '{print $4}')
|
||||||
|
|
||||||
|
[[ -n "${MAC_ADDR:-}" ]] || err "Could not determine MAC address for interface ${PRIMARY_IF}."
|
||||||
|
[[ -n "${IP_ADDR:-}" ]] || info "No IPv4 address detected on ${PRIMARY_IF} (continuing anyway)."
|
||||||
|
|
||||||
|
info "Interface ${PRIMARY_IF} MAC: ${MAC_ADDR}"
|
||||||
|
[[ -n "${IP_ADDR:-}" ]] && info "Interface ${PRIMARY_IF} IP: ${IP_ADDR}"
|
||||||
|
|
||||||
|
# --- Check WOL support -------------------------------------------------------
|
||||||
|
|
||||||
|
info "Checking WOL support on ${PRIMARY_IF}..."
|
||||||
|
|
||||||
|
ETHTOOL_OUTPUT=$(ethtool "$PRIMARY_IF" 2>/dev/null || true)
|
||||||
|
[[ -n "$ETHTOOL_OUTPUT" ]] || err "ethtool failed on ${PRIMARY_IF}. Does this interface exist and support ethtool?"
|
||||||
|
|
||||||
|
SUPPORTS_WOL=$(awk -F: '/Supports Wake-on/ {gsub(/ /,"",$2); print $2}' <<<"$ETHTOOL_OUTPUT")
|
||||||
|
|
||||||
|
if [[ "$SUPPORTS_WOL" != *g* ]]; then
|
||||||
|
err "Interface ${PRIMARY_IF} does NOT support Wake-on: g (Supports Wake-on: ${SUPPORTS_WOL}). Aborting."
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Interface ${PRIMARY_IF} supports Wake-on: g."
|
||||||
|
|
||||||
|
# --- Enable WOL immediately --------------------------------------------------
|
||||||
|
|
||||||
|
info "Enabling WOL (g) on ${PRIMARY_IF} now..."
|
||||||
|
ethtool -s "$PRIMARY_IF" wol g
|
||||||
|
|
||||||
|
# --- Configure persistence in /etc/network/interfaces ------------------------
|
||||||
|
|
||||||
|
INTERFACES_FILE="/etc/network/interfaces"
|
||||||
|
|
||||||
|
if [[ ! -f "$INTERFACES_FILE" ]]; then
|
||||||
|
err "${INTERFACES_FILE} not found. Your system may be using another network config method (e.g., systemd-networkd, Netplan)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup the file
|
||||||
|
BACKUP_FILE="${INTERFACES_FILE}.bak-$(date +%F-%H%M%S)"
|
||||||
|
cp "$INTERFACES_FILE" "$BACKUP_FILE"
|
||||||
|
info "Backed up ${INTERFACES_FILE} to ${BACKUP_FILE}"
|
||||||
|
|
||||||
|
# If an ethernet-wol or ethtool post-up line already exists for this interface, don't duplicate
|
||||||
|
if grep -Eq "ethernet-wol g" "$INTERFACES_FILE" || \
|
||||||
|
grep -Eq "ethtool -s ${PRIMARY_IF} wol g" "$INTERFACES_FILE"; then
|
||||||
|
info "WOL persistence settings already present in ${INTERFACES_FILE}; not adding again."
|
||||||
|
else
|
||||||
|
info "Adding 'ethernet-wol g' under iface ${PRIMARY_IF} in ${INTERFACES_FILE}..."
|
||||||
|
|
||||||
|
# Check if iface stanza exists
|
||||||
|
if grep -Eq "^iface[[:space:]]+${PRIMARY_IF}[[:space:]]" "$INTERFACES_FILE"; then
|
||||||
|
# Insert 'ethernet-wol g' directly after the iface line for this interface
|
||||||
|
awk -v iface="$PRIMARY_IF" '
|
||||||
|
{
|
||||||
|
print $0
|
||||||
|
if ($1 == "iface" && $2 == iface && !seen) {
|
||||||
|
print " ethernet-wol g"
|
||||||
|
seen = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$INTERFACES_FILE" > "${INTERFACES_FILE}.tmp"
|
||||||
|
|
||||||
|
mv "${INTERFACES_FILE}.tmp" "$INTERFACES_FILE"
|
||||||
|
info "Updated ${INTERFACES_FILE} to include ethernet-wol g for ${PRIMARY_IF}."
|
||||||
|
else
|
||||||
|
info "No iface stanza for ${PRIMARY_IF} found in ${INTERFACES_FILE}."
|
||||||
|
info "Not modifying the file to avoid breaking your network configuration."
|
||||||
|
info "You may need to manually add 'ethernet-wol g' to the appropriate iface section."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Verify WOL is enabled ---------------------------------------------------
|
||||||
|
|
||||||
|
info "Verifying WOL is enabled on ${PRIMARY_IF}..."
|
||||||
|
|
||||||
|
VERIFY_OUTPUT=$(ethtool "$PRIMARY_IF")
|
||||||
|
WAKE_ON=$(awk -F: '$1 ~ /^[[:space:]]*Wake-on$/ {gsub(/ /,"",$2); print $2; exit}' <<<"$VERIFY_OUTPUT")
|
||||||
|
|
||||||
|
if [[ "$WAKE_ON" != *g* ]]; then
|
||||||
|
err "WOL verification failed: Wake-on is '${WAKE_ON}', expected to include 'g'. Check BIOS settings and /etc/network/interfaces."
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "WOL verification succeeded: Wake-on is '${WAKE_ON}'."
|
||||||
|
|
||||||
|
# --- Final summary -----------------------------------------------------------
|
||||||
|
|
||||||
|
HOSTNAME=$(hostname)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==========================================="
|
||||||
|
echo " Wake-on-LAN Configuration Summary"
|
||||||
|
echo "==========================================="
|
||||||
|
echo " Hostname : ${HOSTNAME}"
|
||||||
|
echo " Interface : ${PRIMARY_IF}"
|
||||||
|
echo " MAC : ${MAC_ADDR}"
|
||||||
|
[[ -n "${IP_ADDR:-}" ]] && echo " IP : ${IP_ADDR}"
|
||||||
|
echo " Wake-on : ${WAKE_ON}"
|
||||||
|
echo "==========================================="
|
||||||
|
echo "NOTE: Make sure WOL is enabled in your system BIOS/UEFI."
|
||||||
|
echo
|
||||||
|
|
||||||
|
exit 0
|
||||||
378
scripts/find_workshop_ids.py
Normal file
378
scripts/find_workshop_ids.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
Find corresponding Steam Workshop IDs for Project Zomboid mods listed in a mods list file.
|
||||||
|
|
||||||
|
This script reads a semicolon-separated list of mod entries (like the contents of mods.txt),
|
||||||
|
indexes the local Workshop directory for Project Zomboid (app id 108600), and for each mod
|
||||||
|
attempts to find the Workshop item id.
|
||||||
|
|
||||||
|
Matching strategy (in order):
|
||||||
|
1) If an entry looks like "<digits>/<anything>", extract the digits as the workshop id directly.
|
||||||
|
2) Exact match on mod IDs parsed from mod.info files.
|
||||||
|
3) Exact match on mod names (from mod.info or workshop.txt when present).
|
||||||
|
4) Normalized match (case-insensitive, non-alphanumeric removed) against mod IDs and names.
|
||||||
|
|
||||||
|
Outputs a CSV-like file with semicolon-separated fields per input entry:
|
||||||
|
original_entry;workshop_id|NOT_FOUND;match_type;matched_value;source_path
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
- Mods file: mods.txt in current directory
|
||||||
|
- Workshop directory: G:\SteamLibrary\steamapps\workshop\content\108600
|
||||||
|
- Output file: workshop_ids_out.txt in current directory
|
||||||
|
|
||||||
|
Usage examples:
|
||||||
|
python scripts/find_workshop_ids.py
|
||||||
|
python scripts/find_workshop_ids.py --mods-file d:\\7. Git\\tools\\mods.txt \
|
||||||
|
--workshop-dir G:\\SteamLibrary\\steamapps\\workshop\\content\\108600 \
|
||||||
|
--output workshop_ids_out.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_WORKSHOP_DIR = Path(r"G:\SteamLibrary\steamapps\workshop\content\108600")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModRecord:
|
||||||
|
workshop_id: str
|
||||||
|
mod_id: Optional[str]
|
||||||
|
mod_name: Optional[str]
|
||||||
|
source_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""Lowercase and strip all non-alphanumeric characters for fuzzy comparisons."""
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mods_list(mods_text: str) -> List[str]:
|
||||||
|
"""Split by semicolons and newlines, strip whitespace, and drop empty entries."""
|
||||||
|
raw_tokens = re.split(r"[;\n\r]+", mods_text)
|
||||||
|
tokens: List[str] = []
|
||||||
|
for token in raw_tokens:
|
||||||
|
trimmed = token.strip()
|
||||||
|
if trimmed:
|
||||||
|
tokens.append(trimmed)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_file(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mod_info(mod_info_text: str) -> Tuple[List[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract mod IDs and mod name from a mod.info file.
|
||||||
|
- IDs may appear as `id=SomeId` and may contain multiple separated by commas/semicolons.
|
||||||
|
- Name appears as `name=Some Name`.
|
||||||
|
"""
|
||||||
|
ids: List[str] = []
|
||||||
|
name: Optional[str] = None
|
||||||
|
for line in mod_info_text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.lower().startswith("id="):
|
||||||
|
value = line[3:].strip()
|
||||||
|
# Split on common separators for multiple IDs in one line
|
||||||
|
for part in re.split(r"[,;]", value):
|
||||||
|
part_trimmed = part.strip()
|
||||||
|
if part_trimmed:
|
||||||
|
ids.append(part_trimmed)
|
||||||
|
elif line.lower().startswith("name="):
|
||||||
|
value = line[5:].strip()
|
||||||
|
if value:
|
||||||
|
name = value
|
||||||
|
return ids, name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_workshop_txt(workshop_txt: str) -> Optional[str]:
|
||||||
|
"""Extract a human-readable name from workshop.txt if present."""
|
||||||
|
for line in workshop_txt.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.lower().startswith("name="):
|
||||||
|
value = line[5:].strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def index_workshop(workshop_dir: Path) -> Tuple[Dict[str, List[ModRecord]], Dict[str, List[ModRecord]], Dict[str, List[ModRecord]]]:
|
||||||
|
"""
|
||||||
|
Walk the workshop directory and build two lookup indices:
|
||||||
|
- by_id: normalized mod id -> ModRecord list
|
||||||
|
- by_name: normalized mod name -> ModRecord list
|
||||||
|
- by_workshop: workshop id string -> ModRecord list
|
||||||
|
"""
|
||||||
|
by_id: Dict[str, List[ModRecord]] = {}
|
||||||
|
by_name: Dict[str, List[ModRecord]] = {}
|
||||||
|
by_workshop: Dict[str, List[ModRecord]] = {}
|
||||||
|
|
||||||
|
if not workshop_dir.exists() or not workshop_dir.is_dir():
|
||||||
|
return by_id, by_name, by_workshop
|
||||||
|
|
||||||
|
for child in workshop_dir.iterdir():
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if not child.name.isdigit():
|
||||||
|
continue
|
||||||
|
workshop_id = child.name
|
||||||
|
|
||||||
|
# Typical structure: <workshop_id>/mods/*/mod.info
|
||||||
|
mods_root = child / "mods"
|
||||||
|
mod_info_paths: List[Path] = []
|
||||||
|
if mods_root.exists() and mods_root.is_dir():
|
||||||
|
# mod.info may exist directly inside mods_root or nested one level down
|
||||||
|
for sub in mods_root.rglob("mod.info"):
|
||||||
|
if sub.is_file():
|
||||||
|
mod_info_paths.append(sub)
|
||||||
|
|
||||||
|
# Fall back to any mod.info anywhere inside the workshop item (less common)
|
||||||
|
if not mod_info_paths:
|
||||||
|
for sub in child.rglob("mod.info"):
|
||||||
|
if sub.is_file():
|
||||||
|
mod_info_paths.append(sub)
|
||||||
|
|
||||||
|
# Try to get workshop name from workshop.txt (optional)
|
||||||
|
workshop_name = None
|
||||||
|
workshop_txt_path = child / "workshop.txt"
|
||||||
|
if workshop_txt_path.exists():
|
||||||
|
workshop_name = parse_workshop_txt(read_text_file(workshop_txt_path))
|
||||||
|
|
||||||
|
# If no mod.info was found, still index by the workshop name to help matching
|
||||||
|
if not mod_info_paths and workshop_name:
|
||||||
|
record = ModRecord(workshop_id=workshop_id, mod_id=None, mod_name=workshop_name, source_path=workshop_txt_path)
|
||||||
|
key = normalize(workshop_name)
|
||||||
|
by_name.setdefault(key, []).append(record)
|
||||||
|
by_workshop.setdefault(workshop_id, []).append(record)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for mod_info_path in mod_info_paths:
|
||||||
|
text = read_text_file(mod_info_path)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
mod_ids, mod_name = parse_mod_info(text)
|
||||||
|
|
||||||
|
# Prefer mod.info name; fall back to workshop.txt name if absent
|
||||||
|
effective_name = mod_name or workshop_name
|
||||||
|
|
||||||
|
if mod_ids:
|
||||||
|
for mod_id in mod_ids:
|
||||||
|
record = ModRecord(
|
||||||
|
workshop_id=workshop_id,
|
||||||
|
mod_id=mod_id,
|
||||||
|
mod_name=effective_name,
|
||||||
|
source_path=mod_info_path,
|
||||||
|
)
|
||||||
|
by_id.setdefault(normalize(mod_id), []).append(record)
|
||||||
|
by_workshop.setdefault(workshop_id, []).append(record)
|
||||||
|
|
||||||
|
if effective_name:
|
||||||
|
record_for_name = ModRecord(
|
||||||
|
workshop_id=workshop_id,
|
||||||
|
mod_id=(mod_ids[0] if mod_ids else None),
|
||||||
|
mod_name=effective_name,
|
||||||
|
source_path=mod_info_path,
|
||||||
|
)
|
||||||
|
by_name.setdefault(normalize(effective_name), []).append(record_for_name)
|
||||||
|
|
||||||
|
return by_id, by_name, by_workshop
|
||||||
|
|
||||||
|
|
||||||
|
def try_extract_numeric_workshop_id(token: str) -> Optional[str]:
|
||||||
|
"""Return leading numeric id if the token looks like '<digits>/<anything>'."""
|
||||||
|
match = re.match(r"^(\d{6,})(?:/|\\).*$", token)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def match_token(
|
||||||
|
token: str,
|
||||||
|
by_id: Dict[str, List[ModRecord]],
|
||||||
|
by_name: Dict[str, List[ModRecord]],
|
||||||
|
) -> Tuple[str, str, str, str]:
|
||||||
|
"""
|
||||||
|
Attempt to find a workshop id for the given token.
|
||||||
|
Returns tuple: (workshop_id_or_NOT_FOUND, match_type, matched_value, source_path)
|
||||||
|
"""
|
||||||
|
# 1) Direct numeric extraction
|
||||||
|
numeric = try_extract_numeric_workshop_id(token)
|
||||||
|
if numeric:
|
||||||
|
return numeric, "provided_numeric", numeric, ""
|
||||||
|
|
||||||
|
norm = normalize(token)
|
||||||
|
|
||||||
|
# 2) Exact id match
|
||||||
|
if norm in by_id and by_id[norm]:
|
||||||
|
record = by_id[norm][0]
|
||||||
|
return record.workshop_id, "mod_id", (record.mod_id or ""), str(record.source_path)
|
||||||
|
|
||||||
|
# 3) Exact name match
|
||||||
|
if norm in by_name and by_name[norm]:
|
||||||
|
record = by_name[norm][0]
|
||||||
|
return record.workshop_id, "mod_name", (record.mod_name or ""), str(record.source_path)
|
||||||
|
|
||||||
|
# 4) Heuristic: strip bracketed tags like [B42]
|
||||||
|
token_wo_brackets = re.sub(r"\[[^\]]*\]", "", token).strip()
|
||||||
|
if token_wo_brackets and token_wo_brackets != token:
|
||||||
|
norm2 = normalize(token_wo_brackets)
|
||||||
|
if norm2 in by_id and by_id[norm2]:
|
||||||
|
record = by_id[norm2][0]
|
||||||
|
return record.workshop_id, "mod_id_normalized", (record.mod_id or ""), str(record.source_path)
|
||||||
|
if norm2 in by_name and by_name[norm2]:
|
||||||
|
record = by_name[norm2][0]
|
||||||
|
return record.workshop_id, "mod_name_normalized", (record.mod_name or ""), str(record.source_path)
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
return "NOT_FOUND", "no_match", "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_output(
|
||||||
|
output_path: Path,
|
||||||
|
rows: Iterable[Tuple[str, str, str, str, str]],
|
||||||
|
mods_line: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write semicolon-separated output and optionally append a Mods= line at the end."""
|
||||||
|
with output_path.open("w", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.writer(f, delimiter=";", lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
|
||||||
|
writer.writerow(["input", "workshop_id", "match_type", "matched_value", "source_path"])
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(list(row))
|
||||||
|
if mods_line:
|
||||||
|
f.write("\n")
|
||||||
|
f.write(mods_line)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Resolve Project Zomboid mod entries to Workshop IDs")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mods-file",
|
||||||
|
type=Path,
|
||||||
|
default=Path("mods.txt"),
|
||||||
|
help="Path to the mods list file (semicolon-separated)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workshop-dir",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_WORKSHOP_DIR,
|
||||||
|
help="Path to the 108600 workshop content directory",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("workshop_ids_out.txt"),
|
||||||
|
help="Path to write the output mapping (CSV with semicolons)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
mods_file: Path = args.mods_file
|
||||||
|
workshop_dir: Path = args.workshop_dir
|
||||||
|
output_path: Path = args.output
|
||||||
|
|
||||||
|
mods_text = read_text_file(mods_file)
|
||||||
|
if not mods_text:
|
||||||
|
print(f"Mods file not found or empty: {mods_file}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
tokens = parse_mods_list(mods_text)
|
||||||
|
if not tokens:
|
||||||
|
print(f"No entries found in mods file: {mods_file}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
by_id, by_name, by_workshop = index_workshop(workshop_dir)
|
||||||
|
if not by_id and not by_name:
|
||||||
|
print(f"No workshop items indexed under: {workshop_dir}")
|
||||||
|
# Continue anyway so provided numeric ids can still pass through
|
||||||
|
|
||||||
|
rows: List[Tuple[str, str, str, str, str]] = []
|
||||||
|
found = 0
|
||||||
|
for token in tokens:
|
||||||
|
workshop_id, match_type, matched_value, source_path = match_token(token, by_id, by_name)
|
||||||
|
if workshop_id != "NOT_FOUND":
|
||||||
|
found += 1
|
||||||
|
rows.append((token, workshop_id, match_type, matched_value, source_path))
|
||||||
|
|
||||||
|
# Build Mods= line
|
||||||
|
def extract_numeric_and_suffix(token_text: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
m = re.match(r"^(\d{6,})(?:[\\/](.*))?$", token_text)
|
||||||
|
if not m:
|
||||||
|
return None, None
|
||||||
|
return m.group(1), (m.group(2) or None)
|
||||||
|
|
||||||
|
mods_pairs: List[Tuple[str, str]] = [] # (workshop_id, mod_id)
|
||||||
|
seen: set[Tuple[str, str]] = set()
|
||||||
|
for token, workshop_id, match_type, matched_value, _source_path in rows:
|
||||||
|
if workshop_id == "NOT_FOUND":
|
||||||
|
continue
|
||||||
|
candidate_mod_ids: List[str] = []
|
||||||
|
|
||||||
|
if match_type in ("mod_id", "mod_id_normalized") and matched_value:
|
||||||
|
candidate_mod_ids = [matched_value]
|
||||||
|
else:
|
||||||
|
# Try to derive from the indexed records
|
||||||
|
records = by_workshop.get(workshop_id, [])
|
||||||
|
if match_type in ("mod_name", "mod_name_normalized") and matched_value:
|
||||||
|
nm = normalize(matched_value)
|
||||||
|
for rec in records:
|
||||||
|
if rec.mod_name and normalize(rec.mod_name) == nm and rec.mod_id:
|
||||||
|
candidate_mod_ids.append(rec.mod_id)
|
||||||
|
if not candidate_mod_ids:
|
||||||
|
num, suffix = extract_numeric_and_suffix(token)
|
||||||
|
if num == workshop_id and records:
|
||||||
|
if suffix:
|
||||||
|
suffix_norm = normalize(suffix)
|
||||||
|
# Try folder name or mod_id match
|
||||||
|
for rec in records:
|
||||||
|
folder_name = rec.source_path.parent.name if rec.source_path else ""
|
||||||
|
if rec.mod_id and (normalize(rec.mod_id) == suffix_norm or normalize(folder_name) == suffix_norm):
|
||||||
|
candidate_mod_ids.append(rec.mod_id)
|
||||||
|
break
|
||||||
|
# If still none, include all available mod_ids for this workshop
|
||||||
|
if not candidate_mod_ids:
|
||||||
|
for rec in records:
|
||||||
|
if rec.mod_id:
|
||||||
|
candidate_mod_ids.append(rec.mod_id)
|
||||||
|
# Fallback to first non-empty mod_id
|
||||||
|
if not candidate_mod_ids and by_workshop.get(workshop_id):
|
||||||
|
for rec in by_workshop[workshop_id]:
|
||||||
|
if rec.mod_id:
|
||||||
|
candidate_mod_ids.append(rec.mod_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
for mod_id in candidate_mod_ids:
|
||||||
|
key = (workshop_id, mod_id)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
mods_pairs.append(key)
|
||||||
|
|
||||||
|
mods_line = None
|
||||||
|
if mods_pairs:
|
||||||
|
mods_line = "Mods=" + ";".join([f"{wid}\\{mid}" for wid, mid in mods_pairs]) + ";"
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_output(output_path, rows, mods_line)
|
||||||
|
|
||||||
|
print(f"Resolved {found}/{len(tokens)} entries. Wrote: {output_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|
||||||
@@ -5,27 +5,8 @@ set -euo pipefail
|
|||||||
# This file is intended to be run in CI and locally.
|
# This file is intended to be run in CI and locally.
|
||||||
|
|
||||||
determine_repo_web_base() {
|
determine_repo_web_base() {
|
||||||
local origin_url
|
# Use hardcoded base URL
|
||||||
origin_url="$(git config --get remote.origin.url || true)"
|
printf '%s' "https://git.hudsonriggs.systems/HRiggs/Tools"
|
||||||
if [[ -z "${origin_url}" ]]; then
|
|
||||||
echo "Error: could not determine git remote origin URL" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local web_base
|
|
||||||
if [[ "${origin_url}" =~ ^https?:// ]]; then
|
|
||||||
web_base="${origin_url%.git}"
|
|
||||||
elif [[ "${origin_url}" =~ ^git@([^:]+):(.+)\.git$ ]]; then
|
|
||||||
local host path
|
|
||||||
host="${BASH_REMATCH[1]}"
|
|
||||||
path="${BASH_REMATCH[2]}"
|
|
||||||
web_base="https://${host}/${path}"
|
|
||||||
else
|
|
||||||
# Fallback: strip trailing .git if present
|
|
||||||
web_base="${origin_url%.git}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "${web_base}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
determine_branch() {
|
determine_branch() {
|
||||||
|
|||||||
174
setup-nut-slave.sh
Normal file
174
setup-nut-slave.sh
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Setup NUT client on a Proxmox slave node.
|
||||||
|
# Usage:
|
||||||
|
# ./setup-nut-slave.sh <MASTER_IP> [UPS_NAME] [NUT_USER] [NUT_PASS]
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ./setup-nut-slave.sh 192.168.1.10 cyberpower remote remotepass
|
||||||
|
#
|
||||||
|
# MASTER_IP = IP of the NUT master (the host with the USB UPS)
|
||||||
|
# UPS_NAME = NUT UPS name defined on the master (default: cyberpower)
|
||||||
|
# NUT_USER = user defined in /etc/nut/upsd.users on master (default: remote)
|
||||||
|
# NUT_PASS = that user's password (default: remotepass)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MASTER_IP="${1:-}"
|
||||||
|
UPS_NAME="${2:-cyberpower}"
|
||||||
|
NUT_USER="${3:-remote}"
|
||||||
|
NUT_PASS="${4:-remotepass}"
|
||||||
|
|
||||||
|
NUT_CONF="/etc/nut/nut.conf"
|
||||||
|
UPSMON_CONF="/etc/nut/upsmon.conf"
|
||||||
|
|
||||||
|
if [[ -z "${MASTER_IP}" ]]; then
|
||||||
|
echo "Usage: $0 <MASTER_IP> [UPS_NAME] [NUT_USER] [NUT_PASS]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== NUT slave setup on Proxmox node ==="
|
||||||
|
echo "Master IP : ${MASTER_IP}"
|
||||||
|
echo "UPS name : ${UPS_NAME}"
|
||||||
|
echo "NUT user : ${NUT_USER}"
|
||||||
|
|
||||||
|
install_nut_client() {
|
||||||
|
echo
|
||||||
|
echo ">>> Installing NUT client..."
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y nut-client
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_nut_mode() {
|
||||||
|
echo
|
||||||
|
echo ">>> Configuring NUT mode (netclient) in ${NUT_CONF}..."
|
||||||
|
if [[ -f "${NUT_CONF}" ]]; then
|
||||||
|
cp "${NUT_CONF}" "${NUT_CONF}.bak.$(date +%s)"
|
||||||
|
echo "Backup created: ${NUT_CONF}.bak.*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${NUT_CONF}" <<EOF
|
||||||
|
# Generated by setup-nut-slave.sh
|
||||||
|
MODE=netclient
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_upsmon() {
|
||||||
|
echo
|
||||||
|
echo ">>> Configuring upsmon in ${UPSMON_CONF}..."
|
||||||
|
if [[ -f "${UPSMON_CONF}" ]]; then
|
||||||
|
cp "${UPSMON_CONF}" "${UPSMON_CONF}.bak.$(date +%s)"
|
||||||
|
echo "Backup created: ${UPSMON_CONF}.bak.*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${UPSMON_CONF}" <<EOF
|
||||||
|
# Generated by setup-nut-slave.sh
|
||||||
|
|
||||||
|
# Run as the 'nut' user (default on Debian/Proxmox)
|
||||||
|
RUN_AS_USER nut
|
||||||
|
|
||||||
|
# We only have one UPS to satisfy
|
||||||
|
MINSUPPLIES 1
|
||||||
|
|
||||||
|
# Proxmox-friendly shutdown command: this will stop VMs/CTs and power off the node
|
||||||
|
SHUTDOWNCMD "/sbin/poweroff"
|
||||||
|
|
||||||
|
# Flag file used by NUT to signal powerdown
|
||||||
|
POWERDOWNFLAG /etc/killpower
|
||||||
|
|
||||||
|
# This node is a SLAVE, connecting to the master NUT server
|
||||||
|
MONITOR ${UPS_NAME}@${MASTER_IP} 1 ${NUT_USER} ${NUT_PASS} slave
|
||||||
|
|
||||||
|
# Basic notifications
|
||||||
|
NOTIFYFLAG ONLINE SYSLOG
|
||||||
|
NOTIFYFLAG ONBATT SYSLOG+WALL
|
||||||
|
NOTIFYFLAG LOWBATT SYSLOG+WALL
|
||||||
|
NOTIFYFLAG FSD SYSLOG+WALL
|
||||||
|
NOTIFYFLAG COMMOK SYSLOG
|
||||||
|
NOTIFYFLAG COMMBAD SYSLOG+WALL
|
||||||
|
NOTIFYFLAG SHUTDOWN SYSLOG+WALL
|
||||||
|
|
||||||
|
# Notification messages (optional, defaults used if omitted)
|
||||||
|
# NOTIFYMSG ONLINE "UPS ${UPS_NAME} on line power"
|
||||||
|
# NOTIFYMSG ONBATT "UPS ${UPS_NAME} on battery"
|
||||||
|
# NOTIFYMSG LOWBATT "UPS ${UPS_NAME} low battery"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_services() {
|
||||||
|
echo
|
||||||
|
echo ">>> Enabling and restarting nut-client..."
|
||||||
|
systemctl enable nut-client || true
|
||||||
|
systemctl restart nut-client
|
||||||
|
|
||||||
|
echo ">>> nut-client status:"
|
||||||
|
systemctl --no-pager status nut-client || true
|
||||||
|
}
|
||||||
|
|
||||||
|
test_connectivity() {
|
||||||
|
echo
|
||||||
|
echo "=== TEST: NUT connectivity from this slave ==="
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "1) Testing raw UPS status from master with 'upsc'..."
|
||||||
|
if command -v upsc >/dev/null 2>&1; then
|
||||||
|
if upsc "${UPS_NAME}@${MASTER_IP}" ups.status 2>/dev/null; then
|
||||||
|
echo "OK: Successfully queried UPS status from master."
|
||||||
|
else
|
||||||
|
echo "ERROR: Could not query UPS status from master."
|
||||||
|
echo " - Check firewall between this node and ${MASTER_IP}:3493"
|
||||||
|
echo " - Check that master has LISTEN 0.0.0.0 3493 in /etc/nut/upsd.conf"
|
||||||
|
echo " - Check MONITOR user/password and UPS name."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Command 'upsc' not found (should be installed with nut-client)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2) Testing upsmon status locally..."
|
||||||
|
if command -v upsmon >/dev/null 2>&1; then
|
||||||
|
if upsmon -c status 2>/dev/null; then
|
||||||
|
echo "OK: upsmon is running and sees the UPS."
|
||||||
|
else
|
||||||
|
echo "WARNING: upsmon status failed. Check /etc/nut/upsmon.conf and nut-client service."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Command 'upsmon' not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3) OPTIONAL: Simulate a forced shutdown (fsd) test"
|
||||||
|
echo " This will cause this node to initiate a real shutdown if everything is wired correctly."
|
||||||
|
echo " ONLY do this if you're prepared for the node to go down."
|
||||||
|
read -r -p "Run 'upsmon -c fsd' now on THIS NODE? [y/N]: " ans
|
||||||
|
case "${ans}" in
|
||||||
|
y|Y)
|
||||||
|
echo ">>> Running 'upsmon -c fsd' (this may trigger a shutdown)..."
|
||||||
|
upsmon -c fsd || echo "upsmon fsd command failed."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Skipped FSD test."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
install_nut_client
|
||||||
|
configure_nut_mode
|
||||||
|
configure_upsmon
|
||||||
|
enable_services
|
||||||
|
test_connectivity
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Done. This Proxmox node is now configured as a NUT SLAVE. ==="
|
||||||
|
echo "When the master detects LOWBATT, it will signal this node to shut down via upsmon."
|
||||||
|
echo "Backups of original configs (if any) are in:"
|
||||||
|
echo " - ${NUT_CONF}.bak.*"
|
||||||
|
echo " - ${UPSMON_CONF}.bak.*"
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
Reference in New Issue
Block a user