orafail 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- orafail/__init__.py +3 -0
- orafail/config.py +47 -0
- orafail/main.py +653 -0
- orafail-0.1.0.dist-info/METADATA +10 -0
- orafail-0.1.0.dist-info/RECORD +7 -0
- orafail-0.1.0.dist-info/WHEEL +4 -0
- orafail-0.1.0.dist-info/entry_points.txt +2 -0
orafail/__init__.py
ADDED
orafail/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Configuration models for the Oracle login failure monitor."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DatabaseConfig(BaseModel):
|
|
7
|
+
"""Represent a single Oracle database connection target.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
name (str): Human-friendly display name for the dashboard.
|
|
11
|
+
dsn (str): Oracle DSN used by the connector.
|
|
12
|
+
user (str): Database username.
|
|
13
|
+
password (str): Database user password.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
dsn: str
|
|
20
|
+
user: str
|
|
21
|
+
password: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AppConfig(BaseModel):
|
|
25
|
+
"""Define runtime configuration for the dashboard process.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
databases (list[DatabaseConfig]): Database definitions to poll.
|
|
29
|
+
max_workers (int): Thread pool size used for concurrent polling.
|
|
30
|
+
refresh_seconds (int): Delay between polling cycles.
|
|
31
|
+
highlight_ttl (int): Number of cycles to keep new-row highlighting.
|
|
32
|
+
log_file (str | None): Log file path.
|
|
33
|
+
log_level (str): Logging level.
|
|
34
|
+
tcp_connect_timeout (int): Timeout in seconds to establish TCP connection.
|
|
35
|
+
query_timeout (int): Timeout in seconds to wait for polling queries.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
databases: list[DatabaseConfig]
|
|
41
|
+
max_workers: int = Field(default=5, ge=1)
|
|
42
|
+
refresh_seconds: int = Field(default=15, ge=1)
|
|
43
|
+
highlight_ttl: int = Field(default=3, ge=1)
|
|
44
|
+
log_file: str | None = Field(default=None)
|
|
45
|
+
log_level: str = Field(default="INFO")
|
|
46
|
+
tcp_connect_timeout: int = Field(default=10, ge=1)
|
|
47
|
+
query_timeout: int = Field(default=10, ge=1)
|
orafail/main.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Render a live dashboard of failed Oracle login events."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections import deque
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, TypeAlias
|
|
11
|
+
|
|
12
|
+
import oracledb
|
|
13
|
+
import yaml
|
|
14
|
+
from loguru import logger
|
|
15
|
+
from rich.align import Align
|
|
16
|
+
from rich.box import ROUNDED, SIMPLE
|
|
17
|
+
from rich.console import Group
|
|
18
|
+
from rich.layout import Layout
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from orafail.config import AppConfig, DatabaseConfig
|
|
25
|
+
|
|
26
|
+
FailureDetail: TypeAlias = dict[str, Any]
|
|
27
|
+
DatabaseResult: TypeAlias = dict[str, int | str | list[FailureDetail]]
|
|
28
|
+
AllResults: TypeAlias = dict[str, DatabaseResult]
|
|
29
|
+
EventKey: TypeAlias = tuple[str, Any, Any, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SUMMARY_QUERY = """
|
|
33
|
+
SELECT
|
|
34
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' MINUTE THEN 1 ELSE 0 END),
|
|
35
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '10' MINUTE THEN 1 ELSE 0 END),
|
|
36
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' HOUR THEN 1 ELSE 0 END)
|
|
37
|
+
FROM unified_audit_trail
|
|
38
|
+
WHERE action_name = 'LOGON'
|
|
39
|
+
AND return_code != 0
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
DETAIL_QUERY = """
|
|
43
|
+
SELECT *
|
|
44
|
+
FROM (
|
|
45
|
+
SELECT
|
|
46
|
+
dbusername,
|
|
47
|
+
userhost,
|
|
48
|
+
MAX(event_timestamp) AS last_failed_at,
|
|
49
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' MINUTE THEN 1 ELSE 0 END) AS cnt_1m,
|
|
50
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '10' MINUTE THEN 1 ELSE 0 END) AS cnt_10m,
|
|
51
|
+
SUM(CASE WHEN event_timestamp > SYSTIMESTAMP - INTERVAL '1' HOUR THEN 1 ELSE 0 END) AS cnt_1h
|
|
52
|
+
FROM unified_audit_trail
|
|
53
|
+
WHERE action_name = 'LOGON'
|
|
54
|
+
AND return_code != 0
|
|
55
|
+
GROUP BY dbusername, userhost
|
|
56
|
+
ORDER BY last_failed_at DESC
|
|
57
|
+
)
|
|
58
|
+
WHERE ROWNUM <= 5
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OracleLoginFailureMonitor:
|
|
63
|
+
"""Monitor and render Oracle login failures in a live terminal dashboard.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
app_config (AppConfig): Runtime configuration for refresh and display behavior.
|
|
67
|
+
databases (list[DatabaseConfig]): Target Oracle databases to query.
|
|
68
|
+
highlight_ttl (int): Number of refresh cycles to keep row highlights visible.
|
|
69
|
+
previous_results (AllResults): Most recent successful snapshot for trend arrows.
|
|
70
|
+
seen_events (dict[str, set[EventKey]]): Per-database event keys already observed.
|
|
71
|
+
event_age (dict[EventKey, int]): Highlight age counters for known events.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def load_config(path: str = "config.yaml") -> AppConfig:
|
|
76
|
+
"""Load and validate dashboard configuration from YAML.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path (str): Path to the YAML file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
AppConfig: Parsed and validated application configuration.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
FileNotFoundError: If the YAML file path does not exist.
|
|
86
|
+
ValueError: If the YAML file is empty or invalid for AppConfig.
|
|
87
|
+
"""
|
|
88
|
+
config_path = Path(path)
|
|
89
|
+
if not config_path.exists():
|
|
90
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
91
|
+
|
|
92
|
+
with config_path.open("r", encoding="utf-8") as config_file:
|
|
93
|
+
raw_config: dict[str, Any] | None = yaml.safe_load(config_file)
|
|
94
|
+
|
|
95
|
+
if raw_config is None:
|
|
96
|
+
raise ValueError(f"Config file is empty: {config_path}")
|
|
97
|
+
|
|
98
|
+
return AppConfig.model_validate(raw_config)
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self, config_path: str = "config.yaml", headless: bool = False
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Initialize monitor state by loading configuration.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
config_path (str): Path to the YAML configuration file.
|
|
107
|
+
headless (bool): Run in headless daemon mode (no terminal UI).
|
|
108
|
+
"""
|
|
109
|
+
self.app_config = self.load_config(config_path)
|
|
110
|
+
self.databases = self.app_config.databases
|
|
111
|
+
self.highlight_ttl = self.app_config.highlight_ttl
|
|
112
|
+
self.previous_results: AllResults = {}
|
|
113
|
+
self.seen_events: dict[str, set[EventKey]] = {}
|
|
114
|
+
self.event_age: dict[EventKey, int] = {}
|
|
115
|
+
self.log_messages: deque[str] = deque(maxlen=5)
|
|
116
|
+
self.headless = headless
|
|
117
|
+
self._connections: dict[str, oracledb.Connection] = {}
|
|
118
|
+
self._conn_lock = threading.Lock()
|
|
119
|
+
|
|
120
|
+
if not self.headless:
|
|
121
|
+
# Remove default logger to prevent screen corruption in alternate screen mode
|
|
122
|
+
logger.remove()
|
|
123
|
+
|
|
124
|
+
if self.app_config.log_file:
|
|
125
|
+
logger.add(
|
|
126
|
+
self.app_config.log_file,
|
|
127
|
+
level=self.app_config.log_level,
|
|
128
|
+
rotation="10 MB",
|
|
129
|
+
retention="5 days",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not self.headless:
|
|
133
|
+
# Capture loguru logs in the in-memory queue for display in the dashboard
|
|
134
|
+
logger.add(
|
|
135
|
+
lambda msg: self.log_messages.append(str(msg).strip()),
|
|
136
|
+
level="INFO",
|
|
137
|
+
format="{time:HH:mm:ss} | {level: <7} | {message}",
|
|
138
|
+
backtrace=False,
|
|
139
|
+
diagnose=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _fetch_failed_logins(self, db: DatabaseConfig) -> DatabaseResult:
|
|
143
|
+
"""Query one database for failed login summaries and details.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
db (DatabaseConfig): Connection details for the target database.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
DatabaseResult: Summary counters and detail rows for one database.
|
|
150
|
+
"""
|
|
151
|
+
start_time = time.perf_counter()
|
|
152
|
+
conn = None
|
|
153
|
+
try:
|
|
154
|
+
with self._conn_lock:
|
|
155
|
+
conn = self._connections.get(db.name)
|
|
156
|
+
if conn is not None:
|
|
157
|
+
try:
|
|
158
|
+
conn.ping()
|
|
159
|
+
except Exception:
|
|
160
|
+
try:
|
|
161
|
+
conn.close()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
conn = None
|
|
165
|
+
|
|
166
|
+
if conn is None:
|
|
167
|
+
conn = oracledb.connect(
|
|
168
|
+
user=db.user,
|
|
169
|
+
password=db.password,
|
|
170
|
+
dsn=db.dsn,
|
|
171
|
+
tcp_connect_timeout=self.app_config.tcp_connect_timeout,
|
|
172
|
+
)
|
|
173
|
+
self._connections[db.name] = conn
|
|
174
|
+
|
|
175
|
+
cursor = conn.cursor()
|
|
176
|
+
|
|
177
|
+
cursor.execute(SUMMARY_QUERY)
|
|
178
|
+
summary_row = cursor.fetchone()
|
|
179
|
+
|
|
180
|
+
cursor.execute(DETAIL_QUERY)
|
|
181
|
+
detail_rows = cursor.fetchall()
|
|
182
|
+
details = [
|
|
183
|
+
{
|
|
184
|
+
"user": row[0],
|
|
185
|
+
"ip": row[1],
|
|
186
|
+
"last_failed_at": row[2],
|
|
187
|
+
"1m": row[3] or 0,
|
|
188
|
+
"10m": row[4] or 0,
|
|
189
|
+
"1h": row[5] or 0,
|
|
190
|
+
}
|
|
191
|
+
for row in detail_rows
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
cursor.close()
|
|
195
|
+
|
|
196
|
+
latency_ms = int((time.perf_counter() - start_time) * 1000)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"status": "ONLINE",
|
|
200
|
+
"latency_ms": latency_ms,
|
|
201
|
+
"1m": summary_row[0] or 0,
|
|
202
|
+
"10m": summary_row[1] or 0,
|
|
203
|
+
"1h": summary_row[2] or 0,
|
|
204
|
+
"details": details,
|
|
205
|
+
}
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Failed to query database {db.name}: {e}")
|
|
208
|
+
with self._conn_lock:
|
|
209
|
+
if db.name in self._connections:
|
|
210
|
+
try:
|
|
211
|
+
self._connections[db.name].close()
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
del self._connections[db.name]
|
|
215
|
+
return {
|
|
216
|
+
"status": "OFFLINE",
|
|
217
|
+
"latency_ms": None,
|
|
218
|
+
"1m": "ERR",
|
|
219
|
+
"10m": "ERR",
|
|
220
|
+
"1h": "ERR",
|
|
221
|
+
"details": [],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
"""Close all cached database connections."""
|
|
226
|
+
with self._conn_lock:
|
|
227
|
+
for db_name, conn in list(self._connections.items()):
|
|
228
|
+
try:
|
|
229
|
+
conn.close()
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.debug(f"Error closing connection for {db_name}: {e}")
|
|
232
|
+
self._connections.clear()
|
|
233
|
+
|
|
234
|
+
def _fetch_all(self) -> AllResults:
|
|
235
|
+
"""Fetch failed-login data for all configured databases concurrently.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
AllResults: Per-database query results for the dashboard.
|
|
239
|
+
"""
|
|
240
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
241
|
+
max_workers=self.app_config.max_workers
|
|
242
|
+
) as executor:
|
|
243
|
+
futures = {
|
|
244
|
+
executor.submit(self._fetch_failed_logins, db): db
|
|
245
|
+
for db in self.databases
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
done, not_done = concurrent.futures.wait(
|
|
249
|
+
futures.keys(),
|
|
250
|
+
timeout=float(self.app_config.query_timeout),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
results: AllResults = {}
|
|
254
|
+
|
|
255
|
+
# Process successfully completed queries
|
|
256
|
+
for future in done:
|
|
257
|
+
db = futures[future]
|
|
258
|
+
try:
|
|
259
|
+
results[db.name] = future.result()
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Error fetching logs for {db.name}: {e}")
|
|
262
|
+
results[db.name] = {
|
|
263
|
+
"status": "OFFLINE",
|
|
264
|
+
"latency_ms": None,
|
|
265
|
+
"1m": "ERR",
|
|
266
|
+
"10m": "ERR",
|
|
267
|
+
"1h": "ERR",
|
|
268
|
+
"details": [],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# Process timed out queries
|
|
272
|
+
for future in not_done:
|
|
273
|
+
db = futures[future]
|
|
274
|
+
logger.warning(
|
|
275
|
+
f"Query timed out for database {db.name} after {self.app_config.query_timeout}s"
|
|
276
|
+
)
|
|
277
|
+
results[db.name] = {
|
|
278
|
+
"status": "OFFLINE",
|
|
279
|
+
"latency_ms": None,
|
|
280
|
+
"1m": "TIMEOUT",
|
|
281
|
+
"10m": "TIMEOUT",
|
|
282
|
+
"1h": "TIMEOUT",
|
|
283
|
+
"details": [],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return results
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _trend_arrow(current: Any, previous: Any) -> str:
|
|
290
|
+
"""Get trend arrow for visual indicator.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
current (Any): Current count.
|
|
294
|
+
previous (Any): Previous count.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
str: Rich formatted trend arrow symbol.
|
|
298
|
+
"""
|
|
299
|
+
if not isinstance(current, int) or not isinstance(previous, int):
|
|
300
|
+
return ""
|
|
301
|
+
if current > previous:
|
|
302
|
+
return "[#ff5555]↑[/#ff5555]"
|
|
303
|
+
if current < previous:
|
|
304
|
+
return "[#50fa7b]↓[/#50fa7b]"
|
|
305
|
+
return "[#ffb86c]→[/#ffb86c]"
|
|
306
|
+
|
|
307
|
+
def _build_summary_table(self, results: AllResults, previous: AllResults) -> Table:
|
|
308
|
+
"""Build the summary table for all databases.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
results (AllResults): Current polling results.
|
|
312
|
+
previous (AllResults): Previous polling results.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Table: Summary table to be rendered.
|
|
316
|
+
"""
|
|
317
|
+
table = Table(box=SIMPLE, expand=True)
|
|
318
|
+
|
|
319
|
+
table.add_column("Database", style="bold #8be9fd")
|
|
320
|
+
table.add_column("Status", justify="center")
|
|
321
|
+
table.add_column("Latency", justify="right", style="#50fa7b")
|
|
322
|
+
table.add_column("1 min", justify="right")
|
|
323
|
+
table.add_column("10 min", justify="right")
|
|
324
|
+
table.add_column("1 hour", justify="right")
|
|
325
|
+
|
|
326
|
+
for db_name, data in results.items():
|
|
327
|
+
prev = previous.get(db_name, {})
|
|
328
|
+
status = data.get("status", "OFFLINE")
|
|
329
|
+
latency_ms = data.get("latency_ms")
|
|
330
|
+
|
|
331
|
+
if status == "ONLINE":
|
|
332
|
+
status_str = "[#50fa7b]● ONLINE[/#50fa7b]"
|
|
333
|
+
latency_str = f"{latency_ms}ms" if latency_ms is not None else "-"
|
|
334
|
+
else:
|
|
335
|
+
status_str = "[#ff5555]● OFFLINE[/#ff5555]"
|
|
336
|
+
latency_str = "[dim]-[/dim]"
|
|
337
|
+
|
|
338
|
+
def fmt(val: int | str, prev_val: Any) -> str:
|
|
339
|
+
arrow = self._trend_arrow(val, prev_val)
|
|
340
|
+
if not isinstance(val, int):
|
|
341
|
+
return f"[#ff5555]{val}[/#ff5555]"
|
|
342
|
+
if val == 0:
|
|
343
|
+
return "[dim #6272a4]-[/dim #6272a4]"
|
|
344
|
+
if val > 20:
|
|
345
|
+
return f"[bold #ff5555]{val}[/bold #ff5555] {arrow}"
|
|
346
|
+
return f"[#ffb86c]{val}[/#ffb86c] {arrow}"
|
|
347
|
+
|
|
348
|
+
table.add_row(
|
|
349
|
+
db_name,
|
|
350
|
+
status_str,
|
|
351
|
+
latency_str,
|
|
352
|
+
fmt(data.get("1m", "ERR"), prev.get("1m")),
|
|
353
|
+
fmt(data.get("10m", "ERR"), prev.get("10m")),
|
|
354
|
+
fmt(data.get("1h", "ERR"), prev.get("1h")),
|
|
355
|
+
)
|
|
356
|
+
return table
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _build_event_key(db_name: str, row: FailureDetail) -> EventKey:
|
|
360
|
+
"""Build a unique event key to track highlight age.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
db_name (str): Name of the database.
|
|
364
|
+
row (FailureDetail): Failed login row details.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
EventKey: Unique key identifying the failure event.
|
|
368
|
+
"""
|
|
369
|
+
return (db_name, row["last_failed_at"], row["user"], row["ip"])
|
|
370
|
+
|
|
371
|
+
def _mark_new_rows(
|
|
372
|
+
self,
|
|
373
|
+
db_name: str,
|
|
374
|
+
rows: list[FailureDetail],
|
|
375
|
+
) -> list[tuple[FailureDetail, str]]:
|
|
376
|
+
"""Mark row styles based on whether they are new or aging.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
db_name (str): Name of the database.
|
|
380
|
+
rows (list[FailureDetail]): Row details to styles.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
list[tuple[FailureDetail, str]]: List of tuples with row and its style string.
|
|
384
|
+
"""
|
|
385
|
+
if db_name not in self.seen_events:
|
|
386
|
+
self.seen_events[db_name] = set()
|
|
387
|
+
|
|
388
|
+
marked: list[tuple[FailureDetail, str]] = []
|
|
389
|
+
for row in rows:
|
|
390
|
+
key = self._build_event_key(db_name, row)
|
|
391
|
+
if key not in self.seen_events[db_name]:
|
|
392
|
+
self.seen_events[db_name].add(key)
|
|
393
|
+
self.event_age[key] = 0
|
|
394
|
+
style = "bold red"
|
|
395
|
+
else:
|
|
396
|
+
age = self.event_age.get(key, 0)
|
|
397
|
+
if age < self.highlight_ttl:
|
|
398
|
+
style = "bold yellow"
|
|
399
|
+
self.event_age[key] = age + 1
|
|
400
|
+
else:
|
|
401
|
+
style = ""
|
|
402
|
+
marked.append((row, style))
|
|
403
|
+
return marked
|
|
404
|
+
|
|
405
|
+
def _build_detail_tables(self, results: AllResults) -> Group:
|
|
406
|
+
"""Build the panel group containing detailed tables for all databases.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
results (AllResults): Current polling results.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Group: Group of panels representing detail tables.
|
|
413
|
+
"""
|
|
414
|
+
components: list[Any] = []
|
|
415
|
+
for db_name, data in results.items():
|
|
416
|
+
table = Table(
|
|
417
|
+
title=f"[bold #8be9fd]{db_name}[/bold #8be9fd]",
|
|
418
|
+
box=SIMPLE,
|
|
419
|
+
expand=True,
|
|
420
|
+
)
|
|
421
|
+
table.title_align = "left"
|
|
422
|
+
table.add_column("User", style="bold #ffb86c")
|
|
423
|
+
table.add_column("Source IP", style="dim white")
|
|
424
|
+
table.add_column("Last Failed", style="#50fa7b")
|
|
425
|
+
table.add_column("1m", justify="right")
|
|
426
|
+
table.add_column("10m", justify="right")
|
|
427
|
+
table.add_column("1h", justify="right")
|
|
428
|
+
|
|
429
|
+
rows = data.get("details", [])
|
|
430
|
+
if rows:
|
|
431
|
+
marked_rows = self._mark_new_rows(db_name, rows)
|
|
432
|
+
for row, style in marked_rows:
|
|
433
|
+
if style == "bold red":
|
|
434
|
+
row_style = "bold #ff5555"
|
|
435
|
+
elif style == "bold yellow":
|
|
436
|
+
row_style = "bold #ffb86c"
|
|
437
|
+
else:
|
|
438
|
+
row_style = ""
|
|
439
|
+
|
|
440
|
+
table.add_row(
|
|
441
|
+
str(row["user"]),
|
|
442
|
+
str(row["ip"]),
|
|
443
|
+
str(row["last_failed_at"]),
|
|
444
|
+
str(row["1m"]),
|
|
445
|
+
str(row["10m"]),
|
|
446
|
+
str(row["1h"]),
|
|
447
|
+
style=row_style,
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
table.add_row(
|
|
451
|
+
"[dim]No failures[/dim]",
|
|
452
|
+
"[dim]-[/dim]",
|
|
453
|
+
"[dim]-[/dim]",
|
|
454
|
+
"[dim]-[/dim]",
|
|
455
|
+
"[dim]-[/dim]",
|
|
456
|
+
"[dim]-[/dim]",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
components.append(Panel(table, border_style="#4e5a65", box=ROUNDED))
|
|
460
|
+
|
|
461
|
+
if not components:
|
|
462
|
+
return Group(
|
|
463
|
+
Align.center(
|
|
464
|
+
Text(
|
|
465
|
+
"Poller starting or no database configured...",
|
|
466
|
+
style="dim white",
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return Group(*components)
|
|
472
|
+
|
|
473
|
+
def _build_layout(self, results: AllResults, previous: AllResults) -> Layout:
|
|
474
|
+
"""Create the dashboard layout with header, body, logs, and footer.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
results (AllResults): Current polling results.
|
|
478
|
+
previous (AllResults): Previous polling results.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Layout: Rich layout containing the structured dashboard components.
|
|
482
|
+
"""
|
|
483
|
+
layout = Layout()
|
|
484
|
+
|
|
485
|
+
layout.split(
|
|
486
|
+
Layout(name="header", size=3),
|
|
487
|
+
Layout(name="body", ratio=1),
|
|
488
|
+
Layout(name="logs", size=7),
|
|
489
|
+
Layout(name="footer", size=1),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
layout["body"].split_row(
|
|
493
|
+
Layout(name="overview", ratio=4),
|
|
494
|
+
Layout(name="details", ratio=6),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Header Text
|
|
498
|
+
header_text = Text()
|
|
499
|
+
header_text.append(
|
|
500
|
+
" 🔒 ORACLE LOGIN FAILURE MONITOR ", style="bold white on #4e5a65"
|
|
501
|
+
)
|
|
502
|
+
header_text.append(
|
|
503
|
+
f" Refresh: {self.app_config.refresh_seconds}s | Workers: {self.app_config.max_workers} | Highlight TTL: {self.highlight_ttl} cycles",
|
|
504
|
+
style="dim white",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
layout["header"].update(
|
|
508
|
+
Panel(
|
|
509
|
+
Align.center(header_text, vertical="middle"),
|
|
510
|
+
border_style="#4e5a65",
|
|
511
|
+
box=ROUNDED,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Overview Pane
|
|
516
|
+
overview_table = self._build_summary_table(results, previous)
|
|
517
|
+
layout["body"]["overview"].update(
|
|
518
|
+
Panel(
|
|
519
|
+
overview_table,
|
|
520
|
+
title="[bold #8be9fd]Database Overview[/bold #8be9fd]",
|
|
521
|
+
border_style="#6272a4",
|
|
522
|
+
box=ROUNDED,
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Details Pane
|
|
527
|
+
detail_tables = self._build_detail_tables(results)
|
|
528
|
+
layout["body"]["details"].update(
|
|
529
|
+
Panel(
|
|
530
|
+
detail_tables,
|
|
531
|
+
title="[bold #8be9fd]Recent Logon Failures (Audit Trail)[/bold #8be9fd]",
|
|
532
|
+
border_style="#6272a4",
|
|
533
|
+
box=ROUNDED,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Logs Pane
|
|
538
|
+
logs_text = Text()
|
|
539
|
+
if self.log_messages:
|
|
540
|
+
for idx, msg in enumerate(self.log_messages):
|
|
541
|
+
if idx > 0:
|
|
542
|
+
logs_text.append("\n")
|
|
543
|
+
logs_text.append(msg, style="dim white")
|
|
544
|
+
else:
|
|
545
|
+
logs_text.append("Waiting for logs...", style="dim white")
|
|
546
|
+
|
|
547
|
+
layout["logs"].update(
|
|
548
|
+
Panel(
|
|
549
|
+
logs_text,
|
|
550
|
+
title="[bold #8be9fd]System Logs[/bold #8be9fd]",
|
|
551
|
+
border_style="#4e5a65",
|
|
552
|
+
box=ROUNDED,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Footer Pane
|
|
557
|
+
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
558
|
+
footer_text = Text()
|
|
559
|
+
footer_text.append(" Ctrl+C ", style="bold black on #8be9fd")
|
|
560
|
+
footer_text.append(" Quit ", style="bold white on #282a36")
|
|
561
|
+
footer_text.append(" Last Polled: ", style="dim white")
|
|
562
|
+
footer_text.append(now_str, style="bold #50fa7b")
|
|
563
|
+
|
|
564
|
+
layout["footer"].update(Align.left(footer_text))
|
|
565
|
+
|
|
566
|
+
return layout
|
|
567
|
+
|
|
568
|
+
def run(self) -> None:
|
|
569
|
+
"""Run the live dashboard refresh loop (TUI or headless)."""
|
|
570
|
+
results: AllResults = {}
|
|
571
|
+
self.previous_results = {}
|
|
572
|
+
|
|
573
|
+
logger.info("Oracle Login Failure Monitor starting...")
|
|
574
|
+
|
|
575
|
+
if self.headless:
|
|
576
|
+
try:
|
|
577
|
+
while True:
|
|
578
|
+
self.previous_results = results.copy()
|
|
579
|
+
results = self._fetch_all()
|
|
580
|
+
|
|
581
|
+
for db_name, data in results.items():
|
|
582
|
+
status = data.get("status", "OFFLINE")
|
|
583
|
+
latency = data.get("latency_ms")
|
|
584
|
+
cnt_1m = data.get("1m")
|
|
585
|
+
cnt_10m = data.get("10m")
|
|
586
|
+
cnt_1h = data.get("1h")
|
|
587
|
+
|
|
588
|
+
latency_str = f"{latency}ms" if latency is not None else "-"
|
|
589
|
+
logger.info(
|
|
590
|
+
f"Database: {db_name} | Status: {status} | Latency: {latency_str} | "
|
|
591
|
+
f"Failures (1m/10m/1h): {cnt_1m}/{cnt_10m}/{cnt_1h}"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
details = data.get("details", [])
|
|
595
|
+
if details:
|
|
596
|
+
marked = self._mark_new_rows(db_name, details)
|
|
597
|
+
for row, style in marked:
|
|
598
|
+
if style == "bold red":
|
|
599
|
+
logger.warning(
|
|
600
|
+
f"NEW FAILURE on {db_name} | User: {row['user']} | "
|
|
601
|
+
f"IP: {row['ip']} | Last Failed: {row['last_failed_at']}"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
time.sleep(self.app_config.refresh_seconds)
|
|
605
|
+
finally:
|
|
606
|
+
self.close()
|
|
607
|
+
else:
|
|
608
|
+
try:
|
|
609
|
+
with Live(
|
|
610
|
+
self._build_layout(results, self.previous_results),
|
|
611
|
+
refresh_per_second=1,
|
|
612
|
+
screen=True,
|
|
613
|
+
) as live:
|
|
614
|
+
# Run the first query immediately
|
|
615
|
+
results = self._fetch_all()
|
|
616
|
+
live.update(self._build_layout(results, self.previous_results))
|
|
617
|
+
|
|
618
|
+
while True:
|
|
619
|
+
self.previous_results = results.copy()
|
|
620
|
+
time.sleep(self.app_config.refresh_seconds)
|
|
621
|
+
results = self._fetch_all()
|
|
622
|
+
live.update(self._build_layout(results, self.previous_results))
|
|
623
|
+
finally:
|
|
624
|
+
self.close()
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def main() -> None:
|
|
628
|
+
"""Create the monitor and start the dashboard loop."""
|
|
629
|
+
parser = argparse.ArgumentParser(description="Oracle Login Failure Monitor")
|
|
630
|
+
parser.add_argument(
|
|
631
|
+
"--config",
|
|
632
|
+
default="config.yaml",
|
|
633
|
+
help="Path to the YAML configuration file (default: config.yaml)",
|
|
634
|
+
)
|
|
635
|
+
parser.add_argument(
|
|
636
|
+
"--headless",
|
|
637
|
+
action="store_true",
|
|
638
|
+
help="Run in headless daemon mode (no terminal UI)",
|
|
639
|
+
)
|
|
640
|
+
args = parser.parse_args()
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
monitor = OracleLoginFailureMonitor(
|
|
644
|
+
config_path=args.config,
|
|
645
|
+
headless=args.headless,
|
|
646
|
+
)
|
|
647
|
+
monitor.run()
|
|
648
|
+
except KeyboardInterrupt:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
if __name__ == "__main__":
|
|
653
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orafail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Oracle login failure monitor dashboard
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: loguru>=0.7.3
|
|
7
|
+
Requires-Dist: oracledb>=3.4.2
|
|
8
|
+
Requires-Dist: pydantic>=2.13.1
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Requires-Dist: rich>=14.3.3
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
orafail/__init__.py,sha256=oF6v_L033caRpZ0VCTku7pvZ-y5dzlxHqM0axVV8eEw,46
|
|
2
|
+
orafail/config.py,sha256=FbUYPidzNjmRsiGSfyRg21CC5NekElW-QW1C1vtz43Y,1647
|
|
3
|
+
orafail/main.py,sha256=7XYXDlpZwV00OWMGgcVGa3caV-0-9MkqCAJzUOlqSfE,22991
|
|
4
|
+
orafail-0.1.0.dist-info/METADATA,sha256=ByquHsi7C6-7RcS73eem68uO8FxrsJrLRy-DjwpjF0Y,272
|
|
5
|
+
orafail-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
orafail-0.1.0.dist-info/entry_points.txt,sha256=zl5fezhkUKkE6R7SV0T8pY8k_gXEPMARn5jGydYJ6j8,46
|
|
7
|
+
orafail-0.1.0.dist-info/RECORD,,
|