Compare commits

..

12 Commits

Author SHA1 Message Date
6b73b5c79a update wol
All checks were successful
Generate README / build (push) Successful in 7s
2025-12-02 23:36:00 -05:00
94d2c711ce push wol
All checks were successful
Generate README / build (push) Successful in 7s
2025-12-02 23:33:44 -05:00
gitea-bot
7770e09feb docs: auto-generate README for scripts [skip ci] 2025-12-03 04:23:14 +00:00
5545ccaf1f fugma
All checks were successful
Generate README / build (push) Successful in 8s
2025-12-02 23:22:41 -05:00
d82a96eccb revert ee69659a82
revert Merge branch 'main' of https://git.hudsonriggs.systems/HRiggs/tools
2025-12-03 04:22:05 +00:00
133b6fb281 revert 5b8120eb27
All checks were successful
Generate README / build (push) Successful in 8s
revert fix remote url
2025-12-03 04:21:50 +00:00
ee69659a82 Merge branch 'main' of https://git.hudsonriggs.systems/HRiggs/tools
Some checks failed
Generate README / build (push) Failing after 8s
2025-12-02 23:19:43 -05:00
5b8120eb27 fix remote url 2025-12-02 23:19:42 -05:00
gitea-bot
fce70b05da docs: auto-generate README for scripts [skip ci] 2025-12-03 04:16:25 +00:00
0396c86b72 wol and nut
All checks were successful
Generate README / build (push) Successful in 16s
2025-12-02 23:15:36 -05:00
fb8a44ed2b Merge branch 'main' of https://git.hudsonriggs.systems/HRiggs/tools 2025-10-05 15:16:23 -04:00
dda1618fa4 find workshop ids script 2025-10-05 15:16:14 -04:00
5 changed files with 793 additions and 27 deletions

View File

@@ -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
View 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

View 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())

View File

@@ -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
View 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