383 lines
13 KiB
Bash
Executable File
383 lines
13 KiB
Bash
Executable File
#!/usr/bin/env sh
|
|
set -eu
|
|
|
|
SERVER_HOST="192.0.2.13"
|
|
SERVER_PORT="5600"
|
|
POLL_INTERVAL="5"
|
|
INSTALL_ROOT="/opt/aw-pve-webadmin-logger"
|
|
STATE_DIR="/var/lib/aw-pve-webadmin-logger"
|
|
LOG_DIR="/var/log/aw-pve-webadmin-logger"
|
|
CONFIG_PATH="/etc/aw-pve-webadmin-logger/config.json"
|
|
SERVICE_PATH="/etc/systemd/system/aw-pve-webadmin-logger.service"
|
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
ROOT_DIR="$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)"
|
|
SELF_PATH="${SCRIPT_DIR}/$(basename -- "$0")"
|
|
|
|
if [ "${1:-}" = "--apply-legacy" ]; then
|
|
shift
|
|
else
|
|
TARGET_ROOT="${CARGO_TARGET_DIR:-${ROOT_DIR}/adk-rust/target}"
|
|
for candidate in \
|
|
"${AW_LINUX_INSTALL_RUST:-}" \
|
|
"${TARGET_ROOT}/release/aw-linux-install" \
|
|
"${ROOT_DIR}/adk-rust/target/release/aw-linux-install" \
|
|
"/usr/local/bin/aw-linux-install"; do
|
|
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
|
exec "$candidate" --kind pve-webadmin --legacy-script "$SELF_PATH" "$@"
|
|
fi
|
|
done
|
|
cat >&2 <<'EOF'
|
|
install_aw_pve_webadmin_logger.sh now requires the Rust planner for safe default runs.
|
|
Build it first:
|
|
cd adk-rust && cargo build --release -p aw-linux-install
|
|
|
|
Safe dry-run:
|
|
scripts/install_aw_pve_webadmin_logger.sh --json
|
|
|
|
Explicit install:
|
|
scripts/install_aw_pve_webadmin_logger.sh --apply
|
|
|
|
Old shell install:
|
|
scripts/install_aw_pve_webadmin_logger.sh --apply-legacy
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: install_aw_pve_webadmin_logger.sh [options]
|
|
|
|
Options:
|
|
--server-host HOST AW server host (default: 192.0.2.13)
|
|
--server-port PORT AW server port (default: 5600)
|
|
--poll-interval SEC Poll interval in seconds (default: 5)
|
|
-h, --help Show this help
|
|
EOF
|
|
}
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--server-host)
|
|
SERVER_HOST="$2"
|
|
shift 2
|
|
;;
|
|
--server-port)
|
|
SERVER_PORT="$2"
|
|
shift 2
|
|
;;
|
|
--poll-interval)
|
|
POLL_INTERVAL="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo "Run as root" >&2
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$INSTALL_ROOT" "$STATE_DIR" "$LOG_DIR" "$(dirname "$CONFIG_PATH")"
|
|
|
|
HOST_SHORT="$(hostname -s)"
|
|
|
|
cat > "$CONFIG_PATH" <<EOF
|
|
{
|
|
"server_host": "${SERVER_HOST}",
|
|
"server_port": ${SERVER_PORT},
|
|
"poll_interval_seconds": ${POLL_INTERVAL},
|
|
"host": "${HOST_SHORT}",
|
|
"state_dir": "${STATE_DIR}",
|
|
"access_log": "/var/log/pveproxy/access.log",
|
|
"tasks_index": "/var/log/pve/tasks/index",
|
|
"web_bucket": "aw-pve-webadmin-events_${HOST_SHORT}",
|
|
"task_bucket": "aw-pve-task-events_${HOST_SHORT}",
|
|
"category_bucket": "aw-detmir-web-category_${HOST_SHORT}"
|
|
}
|
|
EOF
|
|
|
|
cat > "${INSTALL_ROOT}/collector.py" <<'PY'
|
|
#!/usr/bin/env python3
|
|
import datetime as dt
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import socket
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ACCESS_RE = re.compile(
|
|
r'^(?P<ip>\S+)\s+-\s+(?P<user>\S+)\s+\[(?P<ts>[^\]]+)\]\s+"(?P<method>\S+)\s+(?P<path>\S+)\s+(?P<proto>[^"]+)"\s+(?P<status>\d{3})\s+(?P<size>\S+)'
|
|
)
|
|
NOISE_GET_PATHS = [
|
|
re.compile(r"^/api2/json/version$"),
|
|
re.compile(r"^/api2/json/cluster/resources$"),
|
|
re.compile(r"^/api2/json/cluster/tasks$"),
|
|
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/status/current$"),
|
|
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/interfaces$"),
|
|
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/rrddata(\?.*)?$"),
|
|
]
|
|
|
|
TASK_RE = re.compile(
|
|
r'^UPID:(?P<node>[^:]+):(?P<pid>[^:]+):(?P<pstart>[^:]+):(?P<start>[^:]+):(?P<action>[^:]*):(?P<target>[^:]*):(?P<user>[^:]*):\s*(?P<msg>.*)$'
|
|
)
|
|
|
|
|
|
def iso_now():
|
|
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
|
|
|
|
def parse_access_ts(value: str) -> str:
|
|
try:
|
|
parsed = dt.datetime.strptime(value, "%d/%b/%Y:%H:%M:%S %z")
|
|
return parsed.astimezone(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
except Exception:
|
|
return iso_now()
|
|
|
|
|
|
class TailState:
|
|
def __init__(self, path: pathlib.Path):
|
|
self.path = path
|
|
self.data = {"inode": None, "offset": 0}
|
|
if path.exists():
|
|
try:
|
|
self.data = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
self.data = {"inode": None, "offset": 0}
|
|
|
|
def save(self):
|
|
self.path.write_text(json.dumps(self.data, ensure_ascii=True), encoding="utf-8")
|
|
|
|
|
|
class Collector:
|
|
def __init__(self, cfg):
|
|
self.cfg = cfg
|
|
self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0"
|
|
self.host = cfg.get("host") or socket.gethostname().split(".")[0]
|
|
self.poll = max(1, int(cfg.get("poll_interval_seconds", 5)))
|
|
self.web_bucket = cfg["web_bucket"]
|
|
self.task_bucket = cfg["task_bucket"]
|
|
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
|
|
self.access_log = pathlib.Path(cfg["access_log"])
|
|
self.tasks_index = pathlib.Path(cfg["tasks_index"])
|
|
self.state_dir = pathlib.Path(cfg["state_dir"])
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
self.access_state = TailState(self.state_dir / "access_state.json")
|
|
self.tasks_state = TailState(self.state_dir / "tasks_state.json")
|
|
self.ensured = set()
|
|
self.recent = {}
|
|
|
|
def ensure_bucket(self, bucket_id: str, bucket_type: str):
|
|
if bucket_id in self.ensured:
|
|
return True
|
|
payload = {"client": "aw-pve-webadmin-logger", "type": bucket_type, "hostname": self.host}
|
|
req = urllib.request.Request(
|
|
f"{self.server}/buckets/{bucket_id}",
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10):
|
|
self.ensured.add(bucket_id)
|
|
return True
|
|
except urllib.error.HTTPError as err:
|
|
if err.code in (304, 409):
|
|
self.ensured.add(bucket_id)
|
|
return True
|
|
return False
|
|
except urllib.error.URLError:
|
|
return False
|
|
|
|
def heartbeat(self, bucket_id: str, payload: dict, bucket_type: str):
|
|
if not self.ensure_bucket(bucket_id, bucket_type):
|
|
return False
|
|
req = urllib.request.Request(
|
|
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime=60",
|
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10):
|
|
return True
|
|
except urllib.error.URLError:
|
|
return False
|
|
|
|
def read_new_lines(self, src: pathlib.Path, st: TailState):
|
|
if not src.exists():
|
|
return []
|
|
fs = src.stat()
|
|
inode = int(fs.st_ino)
|
|
size = int(fs.st_size)
|
|
prev_inode = st.data.get("inode")
|
|
prev_off = int(st.data.get("offset", 0))
|
|
if prev_inode != inode or prev_off > size:
|
|
prev_off = 0
|
|
with src.open("r", encoding="utf-8", errors="replace") as f:
|
|
f.seek(prev_off)
|
|
lines = f.readlines()
|
|
st.data = {"inode": inode, "offset": f.tell()}
|
|
st.save()
|
|
return [ln.rstrip("\n") for ln in lines if ln.strip()]
|
|
|
|
def process_access(self):
|
|
for line in self.read_new_lines(self.access_log, self.access_state):
|
|
m = ACCESS_RE.match(line)
|
|
if not m:
|
|
continue
|
|
user = m.group("user")
|
|
status = int(m.group("status"))
|
|
method = m.group("method")
|
|
path = m.group("path")
|
|
if path == "/api2/json/version":
|
|
continue
|
|
if user == "-" and status < 400:
|
|
continue
|
|
# Proxmox UI does high-frequency read polling. Keep real actions and auth failures.
|
|
if method == "GET" and status == 200 and any(rx.match(path) for rx in NOISE_GET_PATHS):
|
|
continue
|
|
event_kind = "auth_failed" if status in (401, 403) else "request"
|
|
dedup_key = f"{event_kind}|{user}|{m.group('ip')}|{method}|{path}|{status}"
|
|
now = time.time()
|
|
if now - float(self.recent.get(dedup_key, 0)) < 30:
|
|
continue
|
|
self.recent[dedup_key] = now
|
|
event = {
|
|
"timestamp": parse_access_ts(m.group("ts")),
|
|
"duration": 0,
|
|
"data": {
|
|
"source": "pveproxy_access",
|
|
"event_kind": event_kind,
|
|
"host": self.host,
|
|
"user": user,
|
|
"remote_ip": m.group("ip"),
|
|
"method": method,
|
|
"path": path,
|
|
"status": status,
|
|
"protocol": m.group("proto"),
|
|
"raw": line,
|
|
},
|
|
}
|
|
self.heartbeat(self.web_bucket, event, "app.pve.webadmin.event")
|
|
category_event = self.classify_access_event(event)
|
|
if category_event:
|
|
self.heartbeat(self.category_bucket, category_event, "web.tab.current")
|
|
|
|
def process_tasks(self):
|
|
for line in self.read_new_lines(self.tasks_index, self.tasks_state):
|
|
m = TASK_RE.match(line)
|
|
if not m:
|
|
continue
|
|
msg = m.group("msg")
|
|
event = {
|
|
"timestamp": iso_now(),
|
|
"duration": 0,
|
|
"data": {
|
|
"source": "pve_tasks_index",
|
|
"host": self.host,
|
|
"node": m.group("node"),
|
|
"upid_pid": m.group("pid"),
|
|
"action": m.group("action"),
|
|
"target": m.group("target"),
|
|
"user": m.group("user"),
|
|
"message": msg,
|
|
"result": "ok" if " OK" in msg else ("error" if "error" in msg.lower() else "info"),
|
|
"raw": line,
|
|
},
|
|
}
|
|
self.heartbeat(self.task_bucket, event, "app.pve.task.event")
|
|
|
|
def classify_access_event(self, source_event: dict):
|
|
data = source_event.get("data", {})
|
|
path = str(data.get("path") or "")
|
|
user = str(data.get("user") or "")
|
|
remote_ip = str(data.get("remote_ip") or "")
|
|
method = str(data.get("method") or "")
|
|
status = int(data.get("status") or 0)
|
|
# Keep only meaningful API/UI actions for worktime.
|
|
if not path.startswith("/api2/"):
|
|
return None
|
|
if method == "GET" and ("/cluster/resources" in path or "/status/current" in path):
|
|
return None
|
|
category = "Администрирование"
|
|
category_group = "work"
|
|
if status in (401, 403):
|
|
category = "Безопасность"
|
|
title = f"Proxmox API {method} {path}"
|
|
return {
|
|
"timestamp": source_event.get("timestamp") or iso_now(),
|
|
"duration": 0,
|
|
"data": {
|
|
"source": "pve-webadmin-bridge",
|
|
"service": "proxmox",
|
|
"host": self.host,
|
|
"app": "proxmox-webui",
|
|
"title": title[:512],
|
|
"url": f"https://{self.host}:8006{path}",
|
|
"domain": self.host,
|
|
"rootDomain": "proxmox-webui",
|
|
"categoryGroup": category_group,
|
|
"category": category,
|
|
"user": user,
|
|
"remote_ip": remote_ip,
|
|
"method": method,
|
|
"status": status,
|
|
},
|
|
}
|
|
|
|
def run(self):
|
|
while True:
|
|
self.process_access()
|
|
self.process_tasks()
|
|
time.sleep(self.poll)
|
|
|
|
|
|
def main():
|
|
cfg = json.loads(pathlib.Path("/etc/aw-pve-webadmin-logger/config.json").read_text(encoding="utf-8"))
|
|
Collector(cfg).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
PY
|
|
|
|
chmod 0755 "${INSTALL_ROOT}/collector.py"
|
|
|
|
cat > "$SERVICE_PATH" <<EOF
|
|
[Unit]
|
|
Description=AW PVE web-admin activity logger
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=${INSTALL_ROOT}/collector.py
|
|
Restart=always
|
|
RestartSec=3
|
|
WorkingDirectory=${INSTALL_ROOT}
|
|
StandardOutput=append:${LOG_DIR}/collector.log
|
|
StandardError=append:${LOG_DIR}/collector.log
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now aw-pve-webadmin-logger.service
|
|
systemctl --no-pager --full status aw-pve-webadmin-logger.service || true
|
|
|
|
echo "Installed aw-pve-webadmin-logger"
|
|
echo "Config: ${CONFIG_PATH}"
|
|
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}, aw-detmir-web-category_${HOST_SHORT}"
|