improved pre-processing and prompt
This commit is contained in:
19
.env.example
19
.env.example
@@ -1,7 +1,15 @@
|
|||||||
# --- AI Configuration ---
|
# --- AI Configuration ---
|
||||||
OLLAMA_API_URL=http://localhost:11434/api/generate
|
OLLAMA_API_URL=http://localhost:11434/api/generate
|
||||||
OLLAMA_MODEL=llama3.2:1b
|
OLLAMA_MODEL=llama3.2:1b
|
||||||
AI_SYSTEM_PROMPT="You are a Linux Sysadmin. Look at these server error logs from yesterday. Summarize the main issues, identify any potential attacks (like failed brute force logins), and ignore harmless background noise. Keep it brief."
|
# How long to wait for the (CPU-only) model to respond before giving up
|
||||||
|
OLLAMA_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# The script now feeds the AI pre-extracted, structured facts (not raw log
|
||||||
|
# lines), so this prompt only needs to tell it HOW to write, not what to look
|
||||||
|
# for -- the analysis itself happens in Python before the AI ever runs.
|
||||||
|
# A sensible default is baked into daily_summary.py; override here only if
|
||||||
|
# you want different tone/length.
|
||||||
|
# AI_SYSTEM_PROMPT="..."
|
||||||
|
|
||||||
# --- Email Reporting Configuration ---
|
# --- Email Reporting Configuration ---
|
||||||
REPORT_TO_EMAIL=admin@yourdomain.com
|
REPORT_TO_EMAIL=admin@yourdomain.com
|
||||||
@@ -13,14 +21,17 @@ SMTP_PASS=
|
|||||||
|
|
||||||
# --- Log Analysis Configuration ---
|
# --- Log Analysis Configuration ---
|
||||||
JOURNAL_UNIT=mailserver
|
JOURNAL_UNIT=mailserver
|
||||||
MAX_ERRORS_TO_ANALYZE=50
|
# Comma-separated CIDR ranges allowed to log in to mailboxes. Logins from
|
||||||
# Comma-separated list of words that trigger the AI
|
# outside these ranges are flagged in the report as noteworthy.
|
||||||
ERROR_KEYWORDS=error,failed,warning,fatal,timeout
|
TRUSTED_LOGIN_NETWORKS=10.0.0.0/24
|
||||||
|
|
||||||
# --- Execution Options ---
|
# --- Execution Options ---
|
||||||
# Set to 'true' to see detailed background processes, 'false' for clean logs
|
# Set to 'true' to see detailed background processes, 'false' for clean logs
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
# Set to 'true' to run also immediately when the container starts, 'false' to wait for the schedule
|
# Set to 'true' to run also immediately when the container starts, 'false' to wait for the schedule
|
||||||
|
# NOTE: leave this 'false' in production. Combined with the systemd unit's
|
||||||
|
# Restart=always, an immediate run on every container (re)start can cause
|
||||||
|
# duplicate/rapid-fire reports if the container is restarting frequently.
|
||||||
RUN_NOW=true
|
RUN_NOW=true
|
||||||
# Time format must be HH:MM in 24-hour format
|
# Time format must be HH:MM in 24-hour format
|
||||||
REPORT_TIME=08:00
|
REPORT_TIME=08:00
|
||||||
|
|||||||
211
daily_summary.py
211
daily_summary.py
@@ -1,15 +1,27 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import ipaddress
|
||||||
import subprocess
|
import subprocess
|
||||||
import requests
|
import requests
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
import time
|
import time
|
||||||
import schedule
|
import schedule
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
# --- CONFIGURATION (Loaded from environment variables) ---
|
# --- CONFIGURATION (Loaded from environment variables) ---
|
||||||
OLLAMA_API = os.getenv("OLLAMA_API_URL", "http://localhost:11434/api/generate")
|
OLLAMA_API = os.getenv("OLLAMA_API_URL", "http://localhost:11434/api/generate")
|
||||||
MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")
|
MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")
|
||||||
AI_SYSTEM_PROMPT = os.getenv("AI_SYSTEM_PROMPT", "You are a Linux Sysadmin. Summarize these logs briefly.")
|
OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", 300))
|
||||||
|
|
||||||
|
AI_SYSTEM_PROMPT = os.getenv("AI_SYSTEM_PROMPT", (
|
||||||
|
"You are a Linux sysadmin writing a brief daily report for a technically "
|
||||||
|
"experienced operator. You are given PRE-EXTRACTED, ALREADY-VERIFIED FACTS "
|
||||||
|
"below -- do not invent, infer, or add any event, IP, or attack that is not "
|
||||||
|
"explicitly listed. If a category shows 0 or is absent, say nothing happened "
|
||||||
|
"in that category. Do not editorialize or add generic security recommendations "
|
||||||
|
"unless a fact explicitly calls for one."
|
||||||
|
))
|
||||||
|
|
||||||
MY_EMAIL = os.getenv("REPORT_TO_EMAIL")
|
MY_EMAIL = os.getenv("REPORT_TO_EMAIL")
|
||||||
FROM_EMAIL = os.getenv("REPORT_FROM_EMAIL")
|
FROM_EMAIL = os.getenv("REPORT_FROM_EMAIL")
|
||||||
@@ -19,22 +31,24 @@ SMTP_USER = os.getenv("SMTP_USER", "")
|
|||||||
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
||||||
|
|
||||||
JOURNAL_UNIT = os.getenv("JOURNAL_UNIT", "mailserver")
|
JOURNAL_UNIT = os.getenv("JOURNAL_UNIT", "mailserver")
|
||||||
MAX_ERRORS = int(os.getenv("MAX_ERRORS_TO_ANALYZE", 50))
|
|
||||||
|
|
||||||
# Parse comma-separated keywords into a list
|
# Networks/IPs that are allowed to log in -- anything outside this is flagged
|
||||||
keyword_string = os.getenv("ERROR_KEYWORDS", "error,failed,warning,fatal,timeout")
|
TRUSTED_LOGIN_NETWORKS = [
|
||||||
KEYWORDS = [k.strip().lower() for k in keyword_string.split(',')]
|
n.strip() for n in os.getenv("TRUSTED_LOGIN_NETWORKS", "10.0.0.0/24").split(",") if n.strip()
|
||||||
|
]
|
||||||
|
|
||||||
# --- EXECUTION TOGGLES ---
|
# --- EXECUTION TOGGLES ---
|
||||||
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
||||||
RUN_NOW = os.getenv("RUN_NOW", "false").lower() in ("true", "1", "yes")
|
RUN_NOW = os.getenv("RUN_NOW", "false").lower() in ("true", "1", "yes")
|
||||||
REPORT_TIME = os.getenv("REPORT_TIME", "08:00")
|
REPORT_TIME = os.getenv("REPORT_TIME", "08:00")
|
||||||
|
|
||||||
|
|
||||||
def debug_print(message):
|
def debug_print(message):
|
||||||
"""Prints only if DEBUG=true in .env"""
|
"""Prints only if DEBUG=true in .env"""
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
print(f"[DEBUG] {message}")
|
print(f"[DEBUG] {message}")
|
||||||
|
|
||||||
|
|
||||||
def get_yesterdays_logs():
|
def get_yesterdays_logs():
|
||||||
print(f"Fetching logs for '{JOURNAL_UNIT}' from yesterday...")
|
print(f"Fetching logs for '{JOURNAL_UNIT}' from yesterday...")
|
||||||
debug_print(f"Running command: journalctl -u {JOURNAL_UNIT} --since yesterday --until today")
|
debug_print(f"Running command: journalctl -u {JOURNAL_UNIT} --since yesterday --until today")
|
||||||
@@ -47,54 +61,166 @@ def get_yesterdays_logs():
|
|||||||
debug_print(f"Fetched {len(lines)} total lines from journalctl.")
|
debug_print(f"Fetched {len(lines)} total lines from journalctl.")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def process_logs(logs):
|
|
||||||
total_lines = len(logs)
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
debug_print(f"Filtering logs using keywords: {KEYWORDS}")
|
def _is_trusted(ip):
|
||||||
for line in logs:
|
try:
|
||||||
line_lower = line.lower()
|
addr = ipaddress.ip_address(ip)
|
||||||
if any(keyword in line_lower for keyword in KEYWORDS):
|
except ValueError:
|
||||||
errors.append(line)
|
return False
|
||||||
|
for net in TRUSTED_LOGIN_NETWORKS:
|
||||||
|
try:
|
||||||
|
if addr in ipaddress.ip_network(net, strict=False):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
debug_print(f"Found {len(errors)} matching lines before truncation.")
|
|
||||||
|
|
||||||
# Truncate to maximum allowed errors to save CPU time
|
# --- Regexes for structured extraction (mail server specific) ---
|
||||||
errors = errors[-MAX_ERRORS:]
|
CONNECT_RE = re.compile(r'postscreen\[\d+\]: CONNECT from \[([\d.:a-fA-F]+)\]')
|
||||||
debug_print(f"Truncated to {len(errors)} lines for AI processing (MAX_ERRORS={MAX_ERRORS}).")
|
REJECT_RE = re.compile(r'postscreen\[\d+\]: NOQUEUE: reject: RCPT from \[[^\]]+\]:\d+: (\d{3} \S+ [^;]+)')
|
||||||
return total_lines, errors
|
DNSBL_RE = re.compile(r'dnsblog\[\d+\]: addr')
|
||||||
|
RELAY_DENIED_RE = re.compile(r'reject: RCPT from \S+: 554 [\d.]+ <[^>]+>: Relay access denied.*from=<([^>]+)>')
|
||||||
|
LOGIN_RE = re.compile(r'imap-login: Login: user=<([^>]+)>, method=(\S+), rip=([\d.:a-fA-F]+)')
|
||||||
|
AUTH_FAIL_RE = re.compile(r'auth failed|authentication failure|invalid password', re.IGNORECASE)
|
||||||
|
SENT_RE = re.compile(r'status=sent')
|
||||||
|
NONSMTP_IP_RE = re.compile(r'non-SMTP command from (?:unknown\[([\d.]+)\]|[\w.\-]+\[([\d.]+)\])')
|
||||||
|
SENDER_AUTH_ANOMALY_RE = re.compile(r'opendkim.*key retrieval failed|opendkim.*failed to parse|policyd-spf.*Temperror|policyd-spf.*Fail')
|
||||||
|
GENERIC_WARNING_RE = re.compile(r'warning: (.*)')
|
||||||
|
HOSTNAME_NORESOLVE_RE = re.compile(r'hostname .* does not resolve')
|
||||||
|
|
||||||
def get_ai_summary(errors):
|
|
||||||
if not errors:
|
|
||||||
debug_print("Skipping AI API call because error list is empty.")
|
|
||||||
return "No errors or warnings were found in the logs yesterday. Everything looks healthy!"
|
|
||||||
|
|
||||||
print(f"Asking AI to summarize {len(errors)} errors... (This will take a few minutes)")
|
def analyze_logs(lines):
|
||||||
|
"""
|
||||||
|
Deterministically extract structured, verifiable facts from raw mail server
|
||||||
|
log lines. This replaces naive keyword grepping -- the LLM only narrates
|
||||||
|
these facts, it never has to "reason" over raw log text, which is what a
|
||||||
|
small local model reliably fails at.
|
||||||
|
"""
|
||||||
|
total_lines = len(lines)
|
||||||
|
|
||||||
error_text = "\n".join(errors)
|
postscreen_rejects = Counter()
|
||||||
|
dnsbl_hits = 0
|
||||||
|
relay_denied = []
|
||||||
|
logins = defaultdict(list)
|
||||||
|
untrusted_logins = []
|
||||||
|
auth_failures = []
|
||||||
|
mail_delivered = 0
|
||||||
|
nonsmtp_by_ip = Counter()
|
||||||
|
sender_auth_anomalies = 0
|
||||||
|
other_warnings = Counter()
|
||||||
|
hostname_noresolve_count = 0
|
||||||
|
|
||||||
# Combine the system prompt from .env with the logs
|
for line in lines:
|
||||||
prompt = f"{AI_SYSTEM_PROMPT}\n\nLOGS:\n{error_text}"
|
if m := REJECT_RE.search(line):
|
||||||
|
postscreen_rejects[m.group(1)] += 1
|
||||||
|
if DNSBL_RE.search(line):
|
||||||
|
dnsbl_hits += 1
|
||||||
|
if m := RELAY_DENIED_RE.search(line):
|
||||||
|
relay_denied.append(m.group(1))
|
||||||
|
if m := LOGIN_RE.search(line):
|
||||||
|
user, method, ip = m.group(1), m.group(2), m.group(3)
|
||||||
|
logins[user].append(ip)
|
||||||
|
if not _is_trusted(ip):
|
||||||
|
untrusted_logins.append((user, method, ip))
|
||||||
|
if AUTH_FAIL_RE.search(line):
|
||||||
|
auth_failures.append(line.strip())
|
||||||
|
if SENT_RE.search(line):
|
||||||
|
mail_delivered += 1
|
||||||
|
if m := NONSMTP_IP_RE.search(line):
|
||||||
|
ip = m.group(1) or m.group(2)
|
||||||
|
nonsmtp_by_ip[ip] += 1
|
||||||
|
elif HOSTNAME_NORESOLVE_RE.search(line):
|
||||||
|
hostname_noresolve_count += 1
|
||||||
|
elif SENDER_AUTH_ANOMALY_RE.search(line):
|
||||||
|
sender_auth_anomalies += 1
|
||||||
|
elif m := GENERIC_WARNING_RE.search(line):
|
||||||
|
other_warnings[m.group(1).strip()] += 1
|
||||||
|
|
||||||
|
facts = []
|
||||||
|
facts.append(f"Total log lines: {total_lines}")
|
||||||
|
facts.append(f"Postscreen automatic rejections (bots/scanners blocked before reaching mailbox): {sum(postscreen_rejects.values())}")
|
||||||
|
for reason, count in postscreen_rejects.most_common():
|
||||||
|
facts.append(f" - {count}x: {reason}")
|
||||||
|
facts.append(f"DNSBL blacklist hits recorded (routine scoring of inbound connections): {dnsbl_hits}")
|
||||||
|
|
||||||
|
facts.append(f"Open-relay abuse attempts blocked (relay access denied): {len(relay_denied)}")
|
||||||
|
for env in relay_denied:
|
||||||
|
facts.append(f" - envelope-from: {env}")
|
||||||
|
|
||||||
|
total_logins = sum(len(v) for v in logins.values())
|
||||||
|
facts.append(f"Successful mailbox logins: {total_logins}")
|
||||||
|
for user, ips in logins.items():
|
||||||
|
facts.append(f" - user={user}, {len(ips)} session(s), source IP(s): {sorted(set(ips))}")
|
||||||
|
|
||||||
|
facts.append(f"Logins from UNTRUSTED/unexpected source IPs (outside {TRUSTED_LOGIN_NETWORKS}): {len(untrusted_logins)}")
|
||||||
|
for user, method, ip in untrusted_logins:
|
||||||
|
facts.append(f" - user={user}, method={method}, source IP={ip} <-- FLAG THIS, review immediately")
|
||||||
|
|
||||||
|
facts.append(f"Authentication FAILURES (should normally be 0; any value above 0 is a possible brute-force indicator): {len(auth_failures)}")
|
||||||
|
for l in auth_failures[:15]:
|
||||||
|
facts.append(f" - {l}")
|
||||||
|
|
||||||
|
facts.append(f"Legitimate messages delivered to mailbox: {mail_delivered}")
|
||||||
|
|
||||||
|
if nonsmtp_by_ip:
|
||||||
|
facts.append("Junk/non-SMTP protocol probes on port 25, by source IP (typical internet-wide scanner noise, e.g. raw TLS/HTTP bytes sent to the SMTP port -- NOT a login attempt):")
|
||||||
|
for ip, count in nonsmtp_by_ip.most_common():
|
||||||
|
facts.append(f" - {ip}: {count} probe(s)")
|
||||||
|
|
||||||
|
facts.append(f"Inbound sender DKIM/SPF anomalies (misconfiguration on the SENDING domain's side, not this server): {sender_auth_anomalies}")
|
||||||
|
facts.append(f"Reverse-DNS 'hostname does not resolve' warnings (routine, from senders with no PTR record): {hostname_noresolve_count}")
|
||||||
|
|
||||||
|
if other_warnings:
|
||||||
|
facts.append(f"Other warning lines not otherwise categorized ({sum(other_warnings.values())} total):")
|
||||||
|
for w, count in other_warnings.most_common(10):
|
||||||
|
facts.append(f" - ({count}x) {w}")
|
||||||
|
|
||||||
|
needs_attention = bool(auth_failures) or bool(untrusted_logins) or bool(relay_denied) or bool(other_warnings)
|
||||||
|
|
||||||
|
return total_lines, "\n".join(facts), needs_attention
|
||||||
|
|
||||||
|
|
||||||
|
def get_ai_summary(facts_text):
|
||||||
|
print("Asking AI to write a summary from pre-extracted facts...")
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"{AI_SYSTEM_PROMPT}\n\n"
|
||||||
|
f"FACTS:\n{facts_text}\n\n"
|
||||||
|
f"Write a brief report (max 150 words): one line on overall status, one "
|
||||||
|
f"line on mail delivered, then list any items flagged above only if their "
|
||||||
|
f"count is greater than zero. Do not repeat the raw fact list verbatim; "
|
||||||
|
f"synthesize it into prose."
|
||||||
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": MODEL,
|
"model": MODEL,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"stream": False
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.1,
|
||||||
|
"num_predict": 400,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_print(f"Sending prompt to Ollama at {OLLAMA_API} using model '{MODEL}'")
|
debug_print(f"Sending prompt to Ollama at {OLLAMA_API} using model '{MODEL}'")
|
||||||
debug_print(f"System Prompt used: {AI_SYSTEM_PROMPT}")
|
debug_print(f"Total prompt length: {len(prompt)} characters.")
|
||||||
debug_print(f"Total payload length: {len(prompt)} characters.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(OLLAMA_API, json=payload)
|
response = requests.post(OLLAMA_API, json=payload, timeout=OLLAMA_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
debug_print(f"AI response status code: {response.status_code}")
|
debug_print(f"AI response status code: {response.status_code}")
|
||||||
return response.json().get('response', 'Error: AI returned empty response.')
|
data = response.json()
|
||||||
|
return data.get('response', 'Error: AI returned empty response.').strip()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
debug_print(f"AI request timed out after {OLLAMA_TIMEOUT}s")
|
||||||
|
return f"AI summary unavailable: Ollama did not respond within {OLLAMA_TIMEOUT}s. See raw facts below."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug_print(f"AI Connection Exception: {str(e)}")
|
debug_print(f"AI Connection Exception: {str(e)}")
|
||||||
return f"Failed to connect to local AI: {str(e)}"
|
return f"AI summary unavailable ({str(e)}). See raw facts below."
|
||||||
|
|
||||||
def send_email_report(total_traffic, ai_summary):
|
|
||||||
|
def send_email_report(total_traffic, ai_summary, facts_text, needs_attention):
|
||||||
if not MY_EMAIL or not FROM_EMAIL:
|
if not MY_EMAIL or not FROM_EMAIL:
|
||||||
print("Skipping email: REPORT_TO_EMAIL or REPORT_FROM_EMAIL not set.")
|
print("Skipping email: REPORT_TO_EMAIL or REPORT_FROM_EMAIL not set.")
|
||||||
return
|
return
|
||||||
@@ -103,8 +229,10 @@ def send_email_report(total_traffic, ai_summary):
|
|||||||
debug_print(f"SMTP Server: {SMTP_SERVER}:{SMTP_PORT}")
|
debug_print(f"SMTP Server: {SMTP_SERVER}:{SMTP_PORT}")
|
||||||
debug_print(f"SMTP Auth User: {'<configured>' if SMTP_USER else '<none>'}")
|
debug_print(f"SMTP Auth User: {'<configured>' if SMTP_USER else '<none>'}")
|
||||||
|
|
||||||
|
tag = "[ATTENTION] " if needs_attention else "[OK] "
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg['Subject'] = f"Daily AI Summary: {JOURNAL_UNIT}"
|
msg['Subject'] = f"{tag}Daily AI Summary: {JOURNAL_UNIT}"
|
||||||
msg['From'] = FROM_EMAIL
|
msg['From'] = FROM_EMAIL
|
||||||
msg['To'] = MY_EMAIL
|
msg['To'] = MY_EMAIL
|
||||||
|
|
||||||
@@ -112,8 +240,10 @@ def send_email_report(total_traffic, ai_summary):
|
|||||||
f"DAILY SERVER REPORT: {JOURNAL_UNIT}\n"
|
f"DAILY SERVER REPORT: {JOURNAL_UNIT}\n"
|
||||||
f"----------------------------------------\n"
|
f"----------------------------------------\n"
|
||||||
f"Total Log Lines Processed: {total_traffic}\n\n"
|
f"Total Log Lines Processed: {total_traffic}\n\n"
|
||||||
f"AI Analysis of Errors/Warnings:\n"
|
f"AI Summary:\n{ai_summary}\n\n"
|
||||||
f"{ai_summary}\n"
|
f"----------------------------------------\n"
|
||||||
|
f"Raw extracted facts (ground truth -- the AI summary above is generated "
|
||||||
|
f"from this and should match it):\n{facts_text}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
@@ -133,14 +263,17 @@ def send_email_report(total_traffic, ai_summary):
|
|||||||
print(f"Failed to send email: {e}")
|
print(f"Failed to send email: {e}")
|
||||||
debug_print(f"Email Exception: {str(e)}")
|
debug_print(f"Email Exception: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def job():
|
def job():
|
||||||
print(f"\n--- Starting log analysis for {JOURNAL_UNIT} ---")
|
print(f"\n--- Starting log analysis for {JOURNAL_UNIT} ---")
|
||||||
logs = get_yesterdays_logs()
|
logs = get_yesterdays_logs()
|
||||||
total_traffic, errors = process_logs(logs)
|
total_traffic, facts_text, needs_attention = analyze_logs(logs)
|
||||||
summary = get_ai_summary(errors)
|
debug_print(f"Extracted facts:\n{facts_text}")
|
||||||
send_email_report(total_traffic, summary)
|
summary = get_ai_summary(facts_text)
|
||||||
|
send_email_report(total_traffic, summary, facts_text, needs_attention)
|
||||||
print("--- Analysis complete. ---")
|
print("--- Analysis complete. ---")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Log Analyzer Container starting up...")
|
print("Log Analyzer Container starting up...")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user