ops: add auth activity and session views

This commit is contained in:
server
2026-04-14 16:05:49 +02:00
parent 825cfbc19b
commit f722475f17
2 changed files with 185 additions and 0 deletions

View File

@@ -51,6 +51,20 @@ def parse_args() -> argparse.Namespace:
summary_parser.add_argument("--include-smoke", action="store_true", help="Include smoke-test logins in auth summary")
summary_parser.add_argument("--json", action="store_true", help="Print raw JSON")
auth_activity = subparsers.add_parser("auth-activity", help="Show recent auth success/failure activity")
auth_activity.add_argument("--hours", type=int, default=24, help="How many hours back to inspect")
auth_activity.add_argument("--limit", type=int, default=30, help="Maximum events to show")
auth_activity.add_argument("--status", choices=("all", "success", "failure"), default="all", help="Filter by auth result")
auth_activity.add_argument("--include-smoke", action="store_true", help="Include smoke-test logins")
auth_activity.add_argument("--json", action="store_true", help="Print raw JSON")
sessions = subparsers.add_parser("sessions", help="Show recent login sessions from loginlog2")
sessions.add_argument("--hours", type=int, default=24, help="How many hours back to inspect")
sessions.add_argument("--limit", type=int, default=20, help="Maximum sessions to show")
sessions.add_argument("--active-only", action="store_true", help="Show only sessions without logout_time")
sessions.add_argument("--include-orphans", action="store_true", help="Include rows whose account login no longer exists")
sessions.add_argument("--json", action="store_true", help="Print raw JSON")
status_parser = subparsers.add_parser("status", help="Show current unit state")
status_parser.add_argument("target", nargs="?", default="all", help="stack, db, auth, game, channel:<id>, instance:<name>")
@@ -325,6 +339,69 @@ def summarize_auth_activity(hours: int, include_smoke: bool) -> dict[str, object
}
def filter_auth_events(hours: int, include_smoke: bool, status: str) -> list[dict[str, object]]:
events = load_auth_activity(hours)
filtered = [event for event in events if include_smoke or not event["smoke"]]
if status != "all":
filtered = [event for event in filtered if event["status"] == status]
return filtered
def run_mariadb_query(query: str) -> list[list[str]]:
completed = run(["mariadb", "-N", "-B", "-e", query], require_root=True, capture_output=True)
rows: list[list[str]] = []
for line in completed.stdout.splitlines():
if not line.strip():
continue
rows.append(line.split("\t"))
return rows
def fetch_recent_sessions(hours: int, limit: int, active_only: bool, include_orphans: bool) -> list[dict[str, str]]:
where_clauses = [f"l.login_time >= NOW() - INTERVAL {int(hours)} HOUR"]
if active_only:
where_clauses.append("l.logout_time IS NULL")
if not include_orphans:
where_clauses.append("a.login IS NOT NULL")
query = f"""
SELECT
DATE_FORMAT(l.login_time, '%Y-%m-%d %H:%i:%s'),
COALESCE(DATE_FORMAT(l.logout_time, '%Y-%m-%d %H:%i:%s'), ''),
l.type,
COALESCE(a.login, ''),
l.account_id,
l.pid,
COALESCE(INET_NTOA(l.ip), ''),
COALESCE(l.client_version, '')
FROM log.loginlog2 l
LEFT JOIN account.account a ON a.id = l.account_id
WHERE {' AND '.join(where_clauses)}
ORDER BY l.id DESC
LIMIT {int(limit)}
""".strip()
entries: list[dict[str, str]] = []
for row in run_mariadb_query(query):
while len(row) < 8:
row.append("")
login_time, logout_time, raw_type, login, account_id, pid, ip, client_version = row[:8]
entries.append(
{
"login_time": login_time,
"logout_time": logout_time,
"raw_type": raw_type,
"session_state": "open" if not logout_time else "closed",
"login": login or f"<missing:{account_id}>",
"account_id": account_id,
"pid": pid,
"ip": ip or "-",
"client_version": client_version or "-",
}
)
return entries
def live_ports() -> set[int]:
if shutil.which("ss") is None:
return set()
@@ -483,6 +560,49 @@ def print_summary(hours: int, include_smoke: bool, as_json: bool) -> int:
return 0
def print_auth_activity(hours: int, limit: int, status: str, include_smoke: bool, as_json: bool) -> int:
events = filter_auth_events(hours, include_smoke, status)
events = events[-limit:]
payload = {
"window_hours": hours,
"limit": limit,
"status": status,
"include_smoke": include_smoke,
"count": len(events),
"entries": [
{
"time": event["time"].strftime("%Y-%m-%d %H:%M:%S"),
"status": event["status"],
"login": event["login"],
"ip": event["ip"],
"reason": event["reason"],
}
for event in events
],
}
if as_json:
print(json.dumps(payload, indent=2))
return 0
if not events:
print(f"No auth activity in the last {hours}h for status={status}.")
return 0
rows = [
[
event["time"].strftime("%Y-%m-%d %H:%M:%S"),
str(event["status"]),
str(event["login"]),
str(event["ip"]),
str(event["reason"]),
]
for event in events
]
print_table(["time", "status", "login", "ip", "reason"], rows)
return 0
def resolve_target_units(target: str) -> list[str]:
normalized = target.strip().lower()
@@ -642,6 +762,41 @@ def print_auth_failures(hours: int, limit: int, include_smoke: bool, as_json: bo
return 0
def print_sessions(hours: int, limit: int, active_only: bool, include_orphans: bool, as_json: bool) -> int:
entries = fetch_recent_sessions(hours, limit, active_only, include_orphans)
payload = {
"window_hours": hours,
"limit": limit,
"active_only": active_only,
"include_orphans": include_orphans,
"count": len(entries),
"entries": entries,
}
if as_json:
print(json.dumps(payload, indent=2))
return 0
if not entries:
print(f"No sessions in the last {hours}h.")
return 0
rows = [
[
entry["login_time"],
entry["logout_time"] or "-",
entry["session_state"],
entry["login"],
entry["account_id"],
entry["pid"],
entry["ip"],
]
for entry in entries
]
print_table(["login_time", "logout_time", "state", "login", "account", "pid", "ip"], rows)
return 0
def run_healthcheck(mode: str) -> int:
if not HEALTHCHECK_PATH.exists():
raise SystemExit(f"Missing healthcheck wrapper: {HEALTHCHECK_PATH}")
@@ -696,6 +851,8 @@ def main() -> int:
return print_units()
if args.command == "summary":
return print_summary(args.hours, args.include_smoke, args.json)
if args.command == "auth-activity":
return print_auth_activity(args.hours, args.limit, args.status, args.include_smoke, args.json)
if args.command == "status":
return print_status(args.target)
if args.command == "ports":
@@ -706,6 +863,8 @@ def main() -> int:
return print_incidents(args.limit)
if args.command == "auth-failures":
return print_auth_failures(args.hours, args.limit, args.include_smoke, args.json)
if args.command == "sessions":
return print_sessions(args.hours, args.limit, args.active_only, args.include_orphans, args.json)
if args.command in {"start", "stop", "restart"}:
return run_unit_action(args.command, args.target)
if args.command == "logs":

View File

@@ -33,11 +33,13 @@ The Debian deployment installs:
`metinctl` is a lightweight operational CLI for:
- showing an operational summary
- showing recent auth success/failure activity
- viewing inventory
- listing managed units
- checking service status
- listing declared ports
- listing recent auth failures
- listing recent login sessions
- restarting the whole stack or specific channels/instances
- viewing logs
- listing core files in the runtime tree
@@ -77,12 +79,36 @@ Show recent real auth failures and skip smoke-test logins:
metinctl auth-failures
```
Show recent auth success/failure flow:
```bash
metinctl auth-activity
```
Show only recent auth failures including smoke tests:
```bash
metinctl auth-activity --status failure --include-smoke
```
Include smoke-test failures too:
```bash
metinctl auth-failures --include-smoke
```
Show recent login sessions from `log.loginlog2`:
```bash
metinctl sessions
```
Show only sessions that still have no recorded logout:
```bash
metinctl sessions --active-only
```
Restart only channel 1 cores:
```bash