#!/usr/bin/env python3
import argparse
import fcntl
import ipaddress
import json
import os
import pathlib
import subprocess
import tempfile
STATE_DIR = pathlib.Path("/etc/wpmm-firewall")
DENY_FILE = STATE_DIR / "permanent.deny"
LOCK_FILE = STATE_DIR / ".lock"
WATCHDOG_CONFIG = pathlib.Path("/etc/wpmm-watchdog.json")
TABLE = "wpmm_guard"
def run(args, input_text=None, check=True):
result = subprocess.run(args, input=input_text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if check and result.returncode:
raise RuntimeError((result.stderr or result.stdout or "nftables 操作失敗").strip())
return result
def canonical(value):
text = str(value or "").strip()
network = ipaddress.ip_network(text, strict=True)
if network.version != 4 or not network.network_address.is_global:
raise ValueError("僅允許公開 IPv4 或 IPv4 網段")
if network.prefixlen not in (24, 32):
raise ValueError("只允許單一 IPv4 或 /24 C-class 網段")
return str(network.network_address) if network.prefixlen == 32 else str(network)
def protected_ips():
values = {"127.0.0.1", "::1"}
try:
data = json.loads(WATCHDOG_CONFIG.read_text(encoding="utf-8"))
values.update(str(value).strip() for value in data.get("connection_whitelist_ips", []) if str(value).strip())
except Exception:
pass
return values
def reject_protected(target):
network = ipaddress.ip_network(target if "/" in target else target + "/32", strict=False)
conflicts = []
for value in protected_ips():
try:
address = ipaddress.ip_address(value)
if address.version == 4 and address in network:
conflicts.append(str(address))
except ValueError:
continue
if conflicts:
raise ValueError("此範圍包含白名單 IP({}),禁止封鎖".format("、".join(sorted(conflicts))))
def ensure_structure():
if run(["nft", "list", "table", "inet", TABLE], check=False).returncode != 0:
rules = """
add table inet wpmm_guard
add set inet wpmm_guard blocked_ipv4 { type ipv4_addr; flags interval; }
add set inet wpmm_guard temporary_ipv4 { type ipv4_addr; flags interval,timeout; }
add chain inet wpmm_guard input { type filter hook input priority -10; policy accept; }
add rule inet wpmm_guard input tcp dport 111 counter drop comment "wp-MM-System block public rpcbind TCP"
add rule inet wpmm_guard input udp dport 111 counter drop comment "wp-MM-System block public rpcbind UDP"
add rule inet wpmm_guard input ip saddr @blocked_ipv4 counter drop comment "wp-MM-System permanent block"
add rule inet wpmm_guard input ip saddr @temporary_ipv4 counter drop comment "wp-MM-System temporary block"
"""
run(["nft", "-f", "-"], input_text=rules)
return
checks = (
(["nft", "list", "set", "inet", TABLE, "blocked_ipv4"],
"add set inet wpmm_guard blocked_ipv4 { type ipv4_addr; flags interval; }\n"),
(["nft", "list", "set", "inet", TABLE, "temporary_ipv4"],
"add set inet wpmm_guard temporary_ipv4 { type ipv4_addr; flags interval,timeout; }\n"),
(["nft", "list", "chain", "inet", TABLE, "input"],
"add chain inet wpmm_guard input { type filter hook input priority -10; policy accept; }\n"),
)
for command, rule in checks:
if run(command, check=False).returncode != 0:
run(["nft", "-f", "-"], input_text=rule)
chain = run(["nft", "list", "chain", "inet", TABLE, "input"]).stdout
additions = []
if "tcp dport 111" not in chain:
additions.append('add rule inet wpmm_guard input tcp dport 111 counter drop comment "wp-MM-System block public rpcbind TCP"')
if "udp dport 111" not in chain:
additions.append('add rule inet wpmm_guard input udp dport 111 counter drop comment "wp-MM-System block public rpcbind UDP"')
if "@blocked_ipv4" not in chain:
additions.append('add rule inet wpmm_guard input ip saddr @blocked_ipv4 counter drop comment "wp-MM-System permanent block"')
if "@temporary_ipv4" not in chain:
additions.append('add rule inet wpmm_guard input ip saddr @temporary_ipv4 counter drop comment "wp-MM-System temporary block"')
if additions:
run(["nft", "-f", "-"], input_text="\n".join(additions) + "\n")
def load_permanent():
if not DENY_FILE.exists():
return []
result = []
for line in DENY_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
value = canonical(line)
reject_protected(value)
if value not in result:
result.append(value)
except (ValueError, RuntimeError):
continue
return result
def apply_permanent(values):
ensure_structure()
commands = ["flush set inet wpmm_guard blocked_ipv4"]
if values:
commands.append("add element inet wpmm_guard blocked_ipv4 { " + ", ".join(values) + " }")
run(["nft", "-f", "-"], input_text="\n".join(commands) + "\n")
def save_permanent(values):
STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix="permanent.", dir=str(STATE_DIR), text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write("# Managed by wp-MM-System\n")
for value in values:
handle.write(value + "\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(name, 0o600)
os.replace(name, DENY_FILE)
finally:
if os.path.exists(name):
os.unlink(name)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("restore", "add", "temporary", "status"))
parser.add_argument("target", nargs="?")
parser.add_argument("duration", nargs="?", type=int, default=21600)
args = parser.parse_args()
STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
with LOCK_FILE.open("a+") as lock:
os.chmod(LOCK_FILE, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX)
if args.action == "status":
ensure_structure()
print(json.dumps({"backend":"nftables","permanent":load_permanent()}, ensure_ascii=False))
return
if args.action == "restore":
values = load_permanent()
apply_permanent(values)
print(json.dumps({"status":"success","restored":len(values)}, ensure_ascii=False))
return
target = canonical(args.target)
reject_protected(target)
ensure_structure()
if args.action == "temporary":
if args.duration not in (3600, 21600, 86400):
raise ValueError("不支援的暫時封鎖時間")
run(["nft", "add", "element", "inet", TABLE, "temporary_ipv4",
"{", target, "timeout", str(args.duration) + "s", "}"])
print(json.dumps({"status":"success","target":target,"mode":"temporary","duration":args.duration}, ensure_ascii=False))
return
values = load_permanent()
if target not in values:
updated = values + [target]
apply_permanent(updated)
try:
save_permanent(updated)
except Exception:
apply_permanent(values)
raise
values = updated
print(json.dumps({"status":"success","target":target,"mode":"permanent","total":len(values)}, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError) as exc:
raise SystemExit(str(exc))