firewatcher 1.55__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.
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: firewatcher
|
|
3
|
+
Version: 1.55
|
|
4
|
+
Summary: Watch logs for incident patterns and persist surrounding context off-box
|
|
5
|
+
Home-page: https://github.com/yufei-pan/firewatcher
|
|
6
|
+
Author: Yufei Pan
|
|
7
|
+
Author-email: pan@zopyr.us
|
|
8
|
+
License: GPLv3+
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Topic :: System :: Logging
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Python: >=3.6
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
# firewatcher
|
|
27
|
+
|
|
28
|
+
Watch a log stream (journald, syslog, or any file) for incident patterns, then persist a window of surrounding messages to a durable location — including a network filesystem — so the record survives even if the machine later dies.
|
|
29
|
+
|
|
30
|
+
This is the PyPI name for the internal `firewatch` tool (in use since 2023). The `firewatch` command remains as an alias. The PyPI name `firewatch` is a different, unrelated project.
|
|
31
|
+
|
|
32
|
+
The watcher prefers `journalctl --follow` when `journalctl` is on PATH. On hosts without journald it falls back to `/var/log/syslog`, then `/var/log/messages`, or `--log-file`. Running the daemon does not require systemd; only `--install-service` / `--uninstall-service` do.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install firewatcher
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
From a clone of this repository:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires **Python 3.6+**. No third-party runtime dependencies.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
firewatcher /etc/firewatcher/patterns.d
|
|
52
|
+
firewatch -t 300 -o /var/log/captured_messages/ /etc/firewatcher/patterns.d
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A directory argument loads every non-hidden pattern file inside it. Files ending in `.regex` are compiled as regular expressions. Any other pattern file is treated as fnmatch (bash-like) substrings, with `*` added on both ends.
|
|
56
|
+
|
|
57
|
+
`--install-service` seeds `/etc/firewatcher/patterns.d` with example patterns when that directory is empty. The `examples/` directory in the source tree is not installed onto `PATH` by `pip install`; copy those files from a clone, or let `--install-service` write them.
|
|
58
|
+
|
|
59
|
+
On a match, `firewatcher` writes:
|
|
60
|
+
|
|
61
|
+
- a per-incident capture under `{output-folder}/{YYYY-MM}/…`
|
|
62
|
+
- a line in `{output-folder}/journal.log`
|
|
63
|
+
|
|
64
|
+
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
|
|
65
|
+
|
|
66
|
+
## systemd service
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
sudo firewatcher --install-service
|
|
70
|
+
sudo firewatcher --print-unit
|
|
71
|
+
sudo firewatcher --uninstall-service
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`--install-service` writes `/etc/systemd/system/firewatcher.service`, creates the output directory, seeds `/etc/firewatcher/patterns.d/` with example patterns **only if that directory is empty**, then `systemctl daemon-reload && systemctl enable --now firewatcher`.
|
|
75
|
+
|
|
76
|
+
If systemd is not available, `--install-service` and `--uninstall-service` print a warning and exit. `--print-unit` still works so you can copy the unit elsewhere.
|
|
77
|
+
|
|
78
|
+
Useful flags:
|
|
79
|
+
|
|
80
|
+
| Flag | Description |
|
|
81
|
+
|------|-------------|
|
|
82
|
+
| `--unit-name NAME` | Unit name (default: `firewatcher`) |
|
|
83
|
+
| `--requires-mounts-for PATH` | Add `RequiresMountsFor=` (repeat for NFS/remote output) |
|
|
84
|
+
| `--no-enable` | Write the unit and reload, but do not enable or start it |
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
88
|
+
--requires-mounts-for /mnt/logs/captured_messages \
|
|
89
|
+
/etc/firewatcher/patterns.d
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
| Flag | Description |
|
|
95
|
+
|------|-------------|
|
|
96
|
+
| `pattern_file …` | Pattern files or directories |
|
|
97
|
+
| `--log-file` | Log file to follow (default: journalctl / syslog / messages) |
|
|
98
|
+
| `-t`, `--capture-time` | Seconds of context after a match (default: 300) |
|
|
99
|
+
| `-o`, `--output-folder` | Where to write captures (default: `/var/log/captured_messages/`) |
|
|
100
|
+
| `--tail_lines` | Lines to start with (default: 20; `+N` from line N, not with journalctl) |
|
|
101
|
+
| `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
|
|
102
|
+
| `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
|
|
103
|
+
| `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
|
|
104
|
+
| `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
|
|
105
|
+
| `--install-service` | Install and enable a systemd unit |
|
|
106
|
+
| `--print-unit` | Print that unit and exit |
|
|
107
|
+
| `--uninstall-service` | Disable and remove the unit |
|
|
108
|
+
| `-V`, `--version` | Show version and exit |
|
|
109
|
+
|
|
110
|
+
## Author
|
|
111
|
+
|
|
112
|
+
Yufei Pan (pan@zopyr.us)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
GPL-3.0-or-later
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
firewatcher.py,sha256=4H_MQ0ncv7jZ9hPIu9YnGP-_-bpQuQV1wjY3262HbnE,25519
|
|
2
|
+
firewatcher-1.55.dist-info/METADATA,sha256=G8cDTF-0U2tURQP5PBagMo-X8jXM4wDJjfz56ZfrQ2k,4674
|
|
3
|
+
firewatcher-1.55.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
firewatcher-1.55.dist-info/entry_points.txt,sha256=mrmL9ezmKF1erizxx0yvrLaqW7UgkMXaosrGQfLsHUY,78
|
|
5
|
+
firewatcher-1.55.dist-info/top_level.txt,sha256=qage9qOHr091gVcFjsUDpfh0DbkxMZ-0GJCSOYY7-Gk,12
|
|
6
|
+
firewatcher-1.55.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
firewatcher
|
firewatcher.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# requires-python = ">=3.6"
|
|
3
|
+
# -*- coding: utf-8 -*-
|
|
4
|
+
"""Watch logs for incident patterns and persist surrounding context.
|
|
5
|
+
|
|
6
|
+
Formerly the internal ``firewatch`` script. The PyPI package and module are
|
|
7
|
+
``firewatcher``; ``firewatch`` remains a console-script alias.
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import fnmatch
|
|
11
|
+
import subprocess
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import builtins
|
|
16
|
+
from collections import deque
|
|
17
|
+
import time
|
|
18
|
+
import datetime
|
|
19
|
+
import os
|
|
20
|
+
import unicodedata
|
|
21
|
+
|
|
22
|
+
version = '1.55'
|
|
23
|
+
__version__ = version
|
|
24
|
+
|
|
25
|
+
DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
|
|
26
|
+
DEFAULT_PATTERNS_DIR = '/etc/firewatcher/patterns.d'
|
|
27
|
+
DEFAULT_UNIT_NAME = 'firewatcher'
|
|
28
|
+
SYSTEMD_RUNTIME_DIR = '/run/systemd/system'
|
|
29
|
+
SYSTEMD_UNIT_DIR = '/etc/systemd/system'
|
|
30
|
+
SLUG_MAX_LEN = 80
|
|
31
|
+
_OWN_SYSLOG_IDENT_RE = re.compile(r'(?:^|\s)firewatch(?:er)?\[\d+\]:')
|
|
32
|
+
|
|
33
|
+
class Log_Compressor:
|
|
34
|
+
def __init__(self, logsDir, compressAfterMonths, deleteLogAfterMonths):
|
|
35
|
+
self.logsDir = logsDir
|
|
36
|
+
self.compressAfterMonths = compressAfterMonths
|
|
37
|
+
self.deleteLogAfterMonths = deleteLogAfterMonths
|
|
38
|
+
self.lastProcessTime = 0
|
|
39
|
+
self.compressLogs()
|
|
40
|
+
|
|
41
|
+
def compressLogs(self):
|
|
42
|
+
# if the compressor had been ran in the last 48 hours, don't run it again
|
|
43
|
+
if time.time() - self.lastProcessTime < 172800:
|
|
44
|
+
return
|
|
45
|
+
# Get the list of files and directories in the logsDir
|
|
46
|
+
if not os.path.exists(self.logsDir):
|
|
47
|
+
return
|
|
48
|
+
pathsList = os.listdir(self.logsDir)
|
|
49
|
+
pathToDelete = []
|
|
50
|
+
pathToCompress = []
|
|
51
|
+
for path in pathsList:
|
|
52
|
+
try:
|
|
53
|
+
# Convert the path to a timestamp
|
|
54
|
+
pathTime = datetime.datetime.strptime(path.partition('.')[0],'%Y-%m')
|
|
55
|
+
if self.deleteLogAfterMonths > 0 and (datetime.datetime.now() - pathTime).days > self.deleteLogAfterMonths*30:
|
|
56
|
+
pathToDelete.append(path)
|
|
57
|
+
elif self.compressAfterMonths > 0 and (datetime.datetime.now() - pathTime).days > self.compressAfterMonths*30 and os.path.isdir(os.path.join(self.logsDir,path)):
|
|
58
|
+
pathToCompress.append(path)
|
|
59
|
+
except:
|
|
60
|
+
pass
|
|
61
|
+
# Iterate copies: mutating the lists while looping skipped every other entry.
|
|
62
|
+
for dirName in list(pathToDelete):
|
|
63
|
+
print(f"Deleting {dirName}")
|
|
64
|
+
full = os.path.join(self.logsDir, dirName)
|
|
65
|
+
if os.path.isdir(full) and not os.path.islink(full):
|
|
66
|
+
shutil.rmtree(full)
|
|
67
|
+
elif os.path.lexists(full):
|
|
68
|
+
os.remove(full)
|
|
69
|
+
for dirName in list(pathToCompress):
|
|
70
|
+
print(f"Compressing {dirName}")
|
|
71
|
+
subprocess.run(['tar','-caf',os.path.join(self.logsDir,dirName+".tar.xz"),'--remove-files',os.path.join(self.logsDir,dirName)])
|
|
72
|
+
|
|
73
|
+
self.lastProcessTime = time.time()
|
|
74
|
+
|
|
75
|
+
def print(*args, **kwargs):
|
|
76
|
+
'''Print with flush=True by default.'''
|
|
77
|
+
kwargs.setdefault('flush', True)
|
|
78
|
+
return builtins.print(*args, **kwargs)
|
|
79
|
+
|
|
80
|
+
class bcolors:
|
|
81
|
+
HEADER = '\033[95m'
|
|
82
|
+
OKBLUE = '\033[94m'
|
|
83
|
+
OKCYAN = '\033[96m'
|
|
84
|
+
OKGREEN = '\033[92m'
|
|
85
|
+
warning = '\033[93m'
|
|
86
|
+
critical = '\033[91m'
|
|
87
|
+
info = '\033[0m'
|
|
88
|
+
debug = '\033[0m'
|
|
89
|
+
ENDC = '\033[0m'
|
|
90
|
+
BOLD = '\033[1m'
|
|
91
|
+
UNDERLINE = '\033[4m'
|
|
92
|
+
|
|
93
|
+
def slugify(value, allow_unicode=False):
|
|
94
|
+
"""
|
|
95
|
+
Taken from https://github.com/django/django/blob/master/django/utils/text.py
|
|
96
|
+
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
|
97
|
+
dashes to single dashes. Remove characters that aren't alphanumerics,
|
|
98
|
+
underscores, or hyphens. Convert to lowercase. Also strip leading and
|
|
99
|
+
trailing whitespace, dashes, and underscores.
|
|
100
|
+
"""
|
|
101
|
+
value = str(value)
|
|
102
|
+
if allow_unicode:
|
|
103
|
+
value = unicodedata.normalize('NFKC', value)
|
|
104
|
+
else:
|
|
105
|
+
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
|
106
|
+
value = re.sub(r'[^\w\s-]', '', value.lower())
|
|
107
|
+
value = re.sub(r'[-\s]+', '-', value).strip('-_')
|
|
108
|
+
if len(value) > SLUG_MAX_LEN:
|
|
109
|
+
value = value[:SLUG_MAX_LEN].rstrip('-_')
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
def load_patterns(pattern_files):
|
|
113
|
+
"""Load patterns from a file and compile them if regex is used."""
|
|
114
|
+
rtn_patterns = {}
|
|
115
|
+
for pattern_file in expand_pattern_sources(pattern_files):
|
|
116
|
+
if not os.path.exists(pattern_file):
|
|
117
|
+
print(f"Pattern file not found: {pattern_file}")
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
with open(pattern_file, 'r') as f:
|
|
121
|
+
patterns = [line.strip() for line in f if line.strip()]
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print(f"Error reading pattern file: {pattern_file}")
|
|
124
|
+
print(e)
|
|
125
|
+
continue
|
|
126
|
+
# process regex patterns if file ends with .regex
|
|
127
|
+
if pattern_file.endswith('.regex'):
|
|
128
|
+
# ignore invalid regex patterns
|
|
129
|
+
for pattern in patterns:
|
|
130
|
+
try:
|
|
131
|
+
pattern = re.compile(pattern)
|
|
132
|
+
if pattern:
|
|
133
|
+
rtn_patterns.setdefault('regex', set()).add(pattern)
|
|
134
|
+
except Exception:
|
|
135
|
+
print(f"Invalid regex pattern: {pattern}")
|
|
136
|
+
# process fnmatch patterns if file ends with .fnmatch
|
|
137
|
+
else:
|
|
138
|
+
patterns = [f'*{pattern}*' for pattern in patterns]
|
|
139
|
+
rtn_patterns.setdefault('fnmatch', set()).update(patterns)
|
|
140
|
+
return rtn_patterns
|
|
141
|
+
|
|
142
|
+
def _is_own_log_line(line):
|
|
143
|
+
"""True for this process's own syslog/journal lines, not incidental 'firewatch' text."""
|
|
144
|
+
if _OWN_SYSLOG_IDENT_RE.search(line):
|
|
145
|
+
return True
|
|
146
|
+
base = os.path.basename(__file__)
|
|
147
|
+
if base and ('/' + base) in line:
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def match_patterns(line, patterns):
|
|
152
|
+
"""Check if the line matches any of the patterns based on the matching mode."""
|
|
153
|
+
if _is_own_log_line(line):
|
|
154
|
+
return False
|
|
155
|
+
if 'regex' in patterns:
|
|
156
|
+
if any(regex.search(line) for regex in patterns['regex']):
|
|
157
|
+
return True
|
|
158
|
+
if 'fnmatch' in patterns:
|
|
159
|
+
if any(fnmatch.fnmatch(line, pattern) for pattern in patterns['fnmatch']):
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def get_matched_pattern(line, patterns):
|
|
164
|
+
'''Get the first matched pattern for the line'''
|
|
165
|
+
if 'regex' in patterns:
|
|
166
|
+
for regex in patterns['regex']:
|
|
167
|
+
# if regex.search(line):
|
|
168
|
+
# return regex.pattern
|
|
169
|
+
matched = regex.search(line)
|
|
170
|
+
if matched:
|
|
171
|
+
return matched[0]
|
|
172
|
+
if 'fnmatch' in patterns:
|
|
173
|
+
for pattern in patterns['fnmatch']:
|
|
174
|
+
if fnmatch.fnmatch(line, pattern):
|
|
175
|
+
return pattern
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _end_capture(log_file_name, captured_line_counter, capture_line_count_max):
|
|
179
|
+
if not log_file_name:
|
|
180
|
+
return
|
|
181
|
+
with open(log_file_name, "a") as log_file:
|
|
182
|
+
if captured_line_counter > capture_line_count_max:
|
|
183
|
+
log_file.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Truncated to {capture_line_count_max} lines.{bcolors.ENDC}\n")
|
|
184
|
+
log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
185
|
+
log_file.write(f"{bcolors.warning}End of capture at {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
|
|
186
|
+
log_file.write(f"{bcolors.warning}Captured {captured_line_counter} lines after first match.{bcolors.ENDC}\n")
|
|
187
|
+
log_file.write(bcolors.warning+'-'*80 + bcolors.ENDC +'\n')
|
|
188
|
+
print(f" End of capture at {datetime.datetime.now().isoformat()}")
|
|
189
|
+
log_file.write('-'*80 +'\n')
|
|
190
|
+
|
|
191
|
+
def capture_messages(command_to_run, patterns, capture_time=300, output_folder=DEFAULT_OUTPUT_FOLDER, compress_after_months=3, delete_after_months=0,capture_line_count_max=10000):
|
|
192
|
+
"""Tail the log file and process lines with buffer management."""
|
|
193
|
+
log_buffer = deque() # Stores tuples of (read_time, line)
|
|
194
|
+
log_compressor = Log_Compressor(output_folder, compress_after_months, delete_after_months)
|
|
195
|
+
capture_until = None
|
|
196
|
+
log_file_name = None
|
|
197
|
+
captured_line_counter = 0
|
|
198
|
+
processed_line_counter = 0
|
|
199
|
+
with subprocess.Popen(
|
|
200
|
+
command_to_run,
|
|
201
|
+
stdout=subprocess.PIPE,
|
|
202
|
+
universal_newlines=True,
|
|
203
|
+
encoding='utf-8',
|
|
204
|
+
errors='replace',
|
|
205
|
+
) as proc:
|
|
206
|
+
while True:
|
|
207
|
+
try:
|
|
208
|
+
line = proc.stdout.readline()
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
print("Exiting.")
|
|
211
|
+
try:
|
|
212
|
+
proc.terminate()
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
break
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f"Error reading line: {e}")
|
|
218
|
+
continue
|
|
219
|
+
if line == '':
|
|
220
|
+
break
|
|
221
|
+
if not line.strip():
|
|
222
|
+
continue
|
|
223
|
+
read_time = time.time()
|
|
224
|
+
processed_line_counter += 1
|
|
225
|
+
|
|
226
|
+
# Maintain a 10-minute buffer
|
|
227
|
+
while log_buffer and read_time - log_buffer[0][0] > capture_time:
|
|
228
|
+
log_buffer.popleft()
|
|
229
|
+
log_buffer.append((read_time, line))
|
|
230
|
+
|
|
231
|
+
# if the current line matches the pattern, capture the logs for the next capture_time seconds
|
|
232
|
+
if match_patterns(line, patterns):
|
|
233
|
+
if not capture_until:
|
|
234
|
+
matched_pattern = get_matched_pattern(line, patterns)
|
|
235
|
+
# Dump the buffer to file at output_folder/{Year-Month}/{Day-Hour-Minute-Second}.log
|
|
236
|
+
log_folder = f"{output_folder}/{datetime.datetime.fromtimestamp(read_time).strftime('%Y-%m')}"
|
|
237
|
+
os.makedirs(log_folder, exist_ok=True)
|
|
238
|
+
log_file_name = os.path.abspath(f"{log_folder}/{slugify(matched_pattern)}_{datetime.datetime.fromtimestamp(read_time).strftime('%Y-%m-%dT%H_%M_%S%z')}.log")
|
|
239
|
+
# also record a journal of all logs captured in {output_folder}/journal.log
|
|
240
|
+
journal_file_name = os.path.abspath(f"{output_folder}/journal.log")
|
|
241
|
+
with open(journal_file_name, "a") as journal_file:
|
|
242
|
+
journal_file.write(f"{datetime.datetime.fromtimestamp(read_time).isoformat()} Captured logs for {matched_pattern} to {log_file_name}\n")
|
|
243
|
+
print(f"Capturing logs to {log_file_name}")
|
|
244
|
+
with open(log_file_name, "a") as log_file:
|
|
245
|
+
log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
246
|
+
# Write the matched pattern and the line that matched
|
|
247
|
+
log_file.write(f"{bcolors.warning}Matched pattern: {bcolors.critical}{matched_pattern}{bcolors.ENDC}\n")
|
|
248
|
+
print(f" Matched pattern: {matched_pattern}")
|
|
249
|
+
log_file.write(f"{bcolors.warning}Matched line: {line}{bcolors.ENDC}")
|
|
250
|
+
print(f" Matched line: {line.strip()}")
|
|
251
|
+
log_file.write(f"{bcolors.warning}Captured at: {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
|
|
252
|
+
print(f" Captured at: {datetime.datetime.now().isoformat()}")
|
|
253
|
+
log_file.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
254
|
+
log_file.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
|
|
255
|
+
skip_lines = 0
|
|
256
|
+
if len(log_buffer) > capture_line_count_max:
|
|
257
|
+
skip_lines = len(log_buffer) - capture_line_count_max
|
|
258
|
+
log_file.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Skipping cached {skip_lines} in-memory lines ...{bcolors.ENDC}\n")
|
|
259
|
+
for i in range(skip_lines, len(log_buffer)):
|
|
260
|
+
buffered_line = log_buffer[i][1]
|
|
261
|
+
if match_patterns(buffered_line, patterns):
|
|
262
|
+
log_file.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
|
|
263
|
+
else:
|
|
264
|
+
log_file.write(buffered_line)
|
|
265
|
+
else:
|
|
266
|
+
# Append the line to the current log file
|
|
267
|
+
with open(log_file_name, "a") as log_file:
|
|
268
|
+
log_file.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
|
|
269
|
+
captured_line_counter += 1
|
|
270
|
+
# If a match is found, set to capture the next capture_time seconds
|
|
271
|
+
capture_until = read_time + capture_time
|
|
272
|
+
if log_file_name and captured_line_counter > capture_line_count_max:
|
|
273
|
+
_end_capture(log_file_name, captured_line_counter, capture_line_count_max)
|
|
274
|
+
capture_until = None
|
|
275
|
+
captured_line_counter = 0
|
|
276
|
+
log_file_name = None
|
|
277
|
+
# if the current line doesn't match the pattern, and capture_until is set and expired, reset capture_until and close the current log file
|
|
278
|
+
elif log_file_name and ((capture_until and read_time > capture_until) or captured_line_counter > capture_line_count_max):
|
|
279
|
+
_end_capture(log_file_name, captured_line_counter, capture_line_count_max)
|
|
280
|
+
capture_until = None
|
|
281
|
+
captured_line_counter = 0
|
|
282
|
+
log_file_name = None
|
|
283
|
+
# if the current line doesn't match the pattern, continue to write the logs to the current log file if capture_until is set and not expired
|
|
284
|
+
elif capture_until and read_time <= capture_until:
|
|
285
|
+
with open(log_file_name, "a") as log_file:
|
|
286
|
+
log_file.write(line)
|
|
287
|
+
captured_line_counter += 1
|
|
288
|
+
if captured_line_counter > capture_line_count_max:
|
|
289
|
+
_end_capture(log_file_name, captured_line_counter, capture_line_count_max)
|
|
290
|
+
capture_until = None
|
|
291
|
+
captured_line_counter = 0
|
|
292
|
+
log_file_name = None
|
|
293
|
+
# if the current line doesn't match the pattern, and capture_until is not set, check for log compression
|
|
294
|
+
else:
|
|
295
|
+
log_compressor.compressLogs()
|
|
296
|
+
if processed_line_counter % 10000 == 0:
|
|
297
|
+
print(f"Processed {processed_line_counter} lines.",flush=True)
|
|
298
|
+
if log_file_name:
|
|
299
|
+
_end_capture(log_file_name, captured_line_counter, capture_line_count_max)
|
|
300
|
+
print("Process ended. Exiting.")
|
|
301
|
+
print(f"Processed {processed_line_counter} lines.")
|
|
302
|
+
|
|
303
|
+
EXAMPLE_PATTERNS = {
|
|
304
|
+
'sys_msg.txt': (
|
|
305
|
+
'hard resetting link\n'
|
|
306
|
+
'Initializing cgroup subsys cpuset\n'
|
|
307
|
+
'Linux version\n'
|
|
308
|
+
'Out of Memory\n'
|
|
309
|
+
'Call Trace\n'
|
|
310
|
+
'I/O error\n'
|
|
311
|
+
'bad sector\n'
|
|
312
|
+
'panic\n'
|
|
313
|
+
'Critical\n'
|
|
314
|
+
'controller is down\n'
|
|
315
|
+
'timeout, aborting\n'
|
|
316
|
+
'timeout, reset controller\n'
|
|
317
|
+
'timeout, disable controller\n'
|
|
318
|
+
'iotest: 1% high is too high compared to 1% low!\n'
|
|
319
|
+
'Hardware error\n'
|
|
320
|
+
),
|
|
321
|
+
'nvme_failure.regex': (
|
|
322
|
+
'(?i)(?:^|[\\s:])nvme(?:\\d+n\\d+)?\\W.*(timeout|abort|restart|reset|unable|cannot|invalid|'
|
|
323
|
+
'froze|fail|down|above|cancel|dead|large|bogus|could not|deprecate)\n'
|
|
324
|
+
),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def systemd_is_available(runtime_dir=None):
|
|
329
|
+
"""True when this host is booted with systemd (PID 1)."""
|
|
330
|
+
if runtime_dir is None:
|
|
331
|
+
runtime_dir = SYSTEMD_RUNTIME_DIR
|
|
332
|
+
return os.path.isdir(runtime_dir)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _is_pattern_filename(name):
|
|
336
|
+
if not name or name.startswith('.'):
|
|
337
|
+
return False
|
|
338
|
+
if name.endswith('~') or name.endswith('.bak'):
|
|
339
|
+
return False
|
|
340
|
+
lower = name.lower()
|
|
341
|
+
if lower == 'readme' or lower.startswith('readme.'):
|
|
342
|
+
return False
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def expand_pattern_sources(paths):
|
|
347
|
+
"""Expand directories to pattern files; keep plain files as-is."""
|
|
348
|
+
files = []
|
|
349
|
+
for path in paths:
|
|
350
|
+
if os.path.isdir(path):
|
|
351
|
+
for name in sorted(os.listdir(path)):
|
|
352
|
+
if not _is_pattern_filename(name):
|
|
353
|
+
continue
|
|
354
|
+
full = os.path.join(path, name)
|
|
355
|
+
if os.path.isfile(full):
|
|
356
|
+
files.append(full)
|
|
357
|
+
else:
|
|
358
|
+
files.append(path)
|
|
359
|
+
return files
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def unit_filename(unit_name):
|
|
363
|
+
name = (unit_name or DEFAULT_UNIT_NAME).strip()
|
|
364
|
+
if not name.endswith('.service'):
|
|
365
|
+
name = name + '.service'
|
|
366
|
+
base = os.path.basename(name)
|
|
367
|
+
if base != name or not base or base in ('.', '..'):
|
|
368
|
+
raise ValueError('unit name must be a simple filename, not a path')
|
|
369
|
+
return base
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def resolve_executable():
|
|
373
|
+
for name in ('firewatcher', 'firewatch'):
|
|
374
|
+
found = shutil.which(name)
|
|
375
|
+
if found:
|
|
376
|
+
return os.path.abspath(found)
|
|
377
|
+
return os.path.abspath(sys.argv[0])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _quote_unit_arg(value):
|
|
381
|
+
value = str(value)
|
|
382
|
+
if value and not any(ch.isspace() or ch in '"\\' for ch in value):
|
|
383
|
+
return value
|
|
384
|
+
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _exec_start_args(args):
|
|
388
|
+
parts = []
|
|
389
|
+
if args.log_file:
|
|
390
|
+
parts.extend(['--log-file', args.log_file])
|
|
391
|
+
if args.capture_time != 300:
|
|
392
|
+
parts.extend(['-t', str(args.capture_time)])
|
|
393
|
+
if args.output_folder != DEFAULT_OUTPUT_FOLDER:
|
|
394
|
+
parts.extend(['-o', args.output_folder])
|
|
395
|
+
if args.tail_lines != '20':
|
|
396
|
+
parts.extend(['--tail_lines', args.tail_lines])
|
|
397
|
+
if args.filter_only:
|
|
398
|
+
parts.append('--filter_only')
|
|
399
|
+
if args.compress_after_months != 3:
|
|
400
|
+
parts.extend(['--compress-after-months', str(args.compress_after_months)])
|
|
401
|
+
if args.capture_line_count_max != 10000:
|
|
402
|
+
parts.extend(['--capture_line_count_max', str(args.capture_line_count_max)])
|
|
403
|
+
if args.delete_after_months != 0:
|
|
404
|
+
parts.extend(['--delete-after-months', str(args.delete_after_months)])
|
|
405
|
+
parts.extend(list(args.pattern_file))
|
|
406
|
+
return parts
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def render_unit_file(args, executable=None):
|
|
410
|
+
"""Render a systemd unit. Does not require systemd to be present."""
|
|
411
|
+
executable = executable or resolve_executable()
|
|
412
|
+
exec_start = ' '.join([_quote_unit_arg(executable)] + [_quote_unit_arg(p) for p in _exec_start_args(args)])
|
|
413
|
+
after = ['network-online.target']
|
|
414
|
+
wants = ['network-online.target']
|
|
415
|
+
if not args.log_file:
|
|
416
|
+
after.insert(0, 'systemd-journald.socket')
|
|
417
|
+
requires = []
|
|
418
|
+
for mount in (getattr(args, 'requires_mounts_for', None) or []):
|
|
419
|
+
requires.append(mount)
|
|
420
|
+
if 'remote-fs.target' not in after:
|
|
421
|
+
after.append('remote-fs.target')
|
|
422
|
+
lines = [
|
|
423
|
+
'[Unit]',
|
|
424
|
+
'Description=firewatcher: persist log context around incident patterns',
|
|
425
|
+
'After=' + ' '.join(after),
|
|
426
|
+
'Wants=' + ' '.join(wants),
|
|
427
|
+
]
|
|
428
|
+
for mount in requires:
|
|
429
|
+
lines.append('RequiresMountsFor=' + mount)
|
|
430
|
+
lines.extend([
|
|
431
|
+
'',
|
|
432
|
+
'[Service]',
|
|
433
|
+
'Type=simple',
|
|
434
|
+
'User=root',
|
|
435
|
+
'ExecStart=' + exec_start,
|
|
436
|
+
'Restart=always',
|
|
437
|
+
'RestartSec=5',
|
|
438
|
+
'TimeoutStopSec=30',
|
|
439
|
+
'KillSignal=SIGINT',
|
|
440
|
+
'StandardOutput=journal',
|
|
441
|
+
'StandardError=journal',
|
|
442
|
+
'',
|
|
443
|
+
'[Install]',
|
|
444
|
+
'WantedBy=multi-user.target',
|
|
445
|
+
'',
|
|
446
|
+
])
|
|
447
|
+
return '\n'.join(lines)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _warn_no_systemd(action):
|
|
451
|
+
print(
|
|
452
|
+
'Warning: systemd is not available on this host. '
|
|
453
|
+
+ action
|
|
454
|
+
+ ' only supports systemd. '
|
|
455
|
+
'firewatcher itself still runs without systemd: it follows journalctl when present, '
|
|
456
|
+
'else /var/log/syslog or /var/log/messages, or --log-file.',
|
|
457
|
+
file=sys.stderr,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _run_systemctl(systemctl, *cmd):
|
|
462
|
+
if systemctl is None:
|
|
463
|
+
return subprocess.run(['systemctl', *cmd])
|
|
464
|
+
return systemctl(*cmd)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _looks_like_pattern_dir(path):
|
|
468
|
+
if os.path.isfile(path):
|
|
469
|
+
return False
|
|
470
|
+
if os.path.isdir(path):
|
|
471
|
+
return True
|
|
472
|
+
base = os.path.basename(path.rstrip(os.sep))
|
|
473
|
+
return path.endswith(('/', os.sep)) or base.endswith('.d')
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _seed_example_patterns(pattern_paths):
|
|
477
|
+
"""Write example patterns into empty directories. Never overwrite existing files."""
|
|
478
|
+
for path in pattern_paths:
|
|
479
|
+
if not _looks_like_pattern_dir(path):
|
|
480
|
+
continue
|
|
481
|
+
os.makedirs(path, exist_ok=True)
|
|
482
|
+
existing = [
|
|
483
|
+
name for name in os.listdir(path)
|
|
484
|
+
if _is_pattern_filename(name) and os.path.isfile(os.path.join(path, name))
|
|
485
|
+
]
|
|
486
|
+
if existing:
|
|
487
|
+
continue
|
|
488
|
+
for name, content in EXAMPLE_PATTERNS.items():
|
|
489
|
+
dest = os.path.join(path, name)
|
|
490
|
+
if not os.path.exists(dest):
|
|
491
|
+
with open(dest, 'w', encoding='utf-8') as fh:
|
|
492
|
+
fh.write(content)
|
|
493
|
+
print(f"Wrote example pattern {dest}")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def install_service(args, systemd_available=None, unit_dir=None, systemctl=None, executable=None):
|
|
497
|
+
if systemd_available is None:
|
|
498
|
+
systemd_available = systemd_is_available()
|
|
499
|
+
if not systemd_available:
|
|
500
|
+
_warn_no_systemd('--install-service')
|
|
501
|
+
return 1
|
|
502
|
+
if unit_dir is None:
|
|
503
|
+
unit_dir = SYSTEMD_UNIT_DIR
|
|
504
|
+
os.makedirs(args.output_folder, exist_ok=True)
|
|
505
|
+
_seed_example_patterns(args.pattern_file)
|
|
506
|
+
os.makedirs(unit_dir, exist_ok=True)
|
|
507
|
+
unit_path = os.path.join(unit_dir, unit_filename(args.unit_name))
|
|
508
|
+
text = render_unit_file(args, executable=executable)
|
|
509
|
+
with open(unit_path, 'w', encoding='utf-8') as fh:
|
|
510
|
+
fh.write(text)
|
|
511
|
+
print(f"Wrote {unit_path}")
|
|
512
|
+
reload = _run_systemctl(systemctl, 'daemon-reload')
|
|
513
|
+
if getattr(reload, 'returncode', 0):
|
|
514
|
+
print(f"systemctl daemon-reload failed (exit {reload.returncode})", file=sys.stderr)
|
|
515
|
+
return reload.returncode
|
|
516
|
+
if getattr(args, 'no_enable', False):
|
|
517
|
+
return 0
|
|
518
|
+
enable = _run_systemctl(systemctl, 'enable', '--now', unit_filename(args.unit_name))
|
|
519
|
+
if getattr(enable, 'returncode', 0):
|
|
520
|
+
print(f"systemctl enable --now failed (exit {enable.returncode})", file=sys.stderr)
|
|
521
|
+
return enable.returncode
|
|
522
|
+
print(f"Enabled and started {unit_filename(args.unit_name)}")
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def uninstall_service(args, systemd_available=None, unit_dir=None, systemctl=None):
|
|
527
|
+
if systemd_available is None:
|
|
528
|
+
systemd_available = systemd_is_available()
|
|
529
|
+
if not systemd_available:
|
|
530
|
+
_warn_no_systemd('--uninstall-service')
|
|
531
|
+
return 1
|
|
532
|
+
if unit_dir is None:
|
|
533
|
+
unit_dir = SYSTEMD_UNIT_DIR
|
|
534
|
+
name = unit_filename(args.unit_name)
|
|
535
|
+
unit_path = os.path.join(unit_dir, name)
|
|
536
|
+
disable = _run_systemctl(systemctl, 'disable', '--now', name)
|
|
537
|
+
if getattr(disable, 'returncode', 0) not in (0, None) and os.path.exists(unit_path):
|
|
538
|
+
print(f"systemctl disable --now {name} exited {disable.returncode}", file=sys.stderr)
|
|
539
|
+
if os.path.exists(unit_path):
|
|
540
|
+
os.remove(unit_path)
|
|
541
|
+
print(f"Removed {unit_path}")
|
|
542
|
+
reload = _run_systemctl(systemctl, 'daemon-reload')
|
|
543
|
+
if getattr(reload, 'returncode', 0):
|
|
544
|
+
print(f"systemctl daemon-reload failed (exit {reload.returncode})", file=sys.stderr)
|
|
545
|
+
return reload.returncode
|
|
546
|
+
print(f"Uninstalled {name} (pattern files and capture logs were left in place)")
|
|
547
|
+
return 0
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def build_parser():
|
|
551
|
+
parser = argparse.ArgumentParser(description='Monitor system logs for specific patterns and persist surrounding context.')
|
|
552
|
+
parser.add_argument('pattern_file', type=str, nargs='*', help='Pattern files or directories. Files ending with .regex load regex patterns. '
|
|
553
|
+
'Other files load fnmatch (bash like) patterns with * wildcards added at both ends. '
|
|
554
|
+
'A directory (such as /etc/firewatcher/patterns.d) loads every non-hidden pattern file inside it. '
|
|
555
|
+
'Optional for --install-service / --print-unit (defaults to /etc/firewatcher/patterns.d) and --uninstall-service.')
|
|
556
|
+
parser.add_argument('--log-file', type=str, help='Path to the log file to monitor. Default: journalctl -> /var/log/syslog -> /var/log/messages.')
|
|
557
|
+
parser.add_argument('--workers', type=int, default=1, help='(Not Implemented) Number of processes for processing logs. '
|
|
558
|
+
' Will divide patterns equally across workers. Use 0 to spawn a process for every pattern in the source file. '
|
|
559
|
+
' Warning: may produce duplicated log files if a line matches multiple patterns. Default: 1 worker.')
|
|
560
|
+
parser.add_argument('-t','--capture-time', type=int, default=300, help='Time in seconds to capture logs after a match. Default: 300 seconds.')
|
|
561
|
+
parser.add_argument('-o','--output-folder', type=str, default=DEFAULT_OUTPUT_FOLDER, help='Output folder for matched logs. Default: /var/log/captured_messages/')
|
|
562
|
+
parser.add_argument('--tail_lines', type=str, default='20', help='Number of lines to tail from the log file. Default: 20 lines. ( Note: Use +N to tail from the Nth line.) ( Note: +N does not work with journalctl.)')
|
|
563
|
+
parser.add_argument('--filter_only', action='store_true', help='Only filter logs and do not wait for new lines. Uses cat instead of tail -F, or journalctl without --follow. Default: False.')
|
|
564
|
+
parser.add_argument('--compress-after-months', type=int, default=3, help='Compress logs after this many months. Default: 3 months.')
|
|
565
|
+
parser.add_argument('--capture_line_count_max', type=int, default=10000, help='Maximum number of lines to capture before and after in a single log file. Default: 10000 lines. Note: The final file maybe 2 * this number.')
|
|
566
|
+
parser.add_argument('--delete-after-months', type=int, default=0, help='Delete logs after this many months. (will only delete files in YYYY-MM format) Default: 0 (never).')
|
|
567
|
+
svc = parser.add_mutually_exclusive_group()
|
|
568
|
+
svc.add_argument('--install-service', action='store_true', help='Install and enable a systemd unit for this command line. Warns and exits if systemd is not available.')
|
|
569
|
+
svc.add_argument('--print-unit', action='store_true', help='Print the systemd unit that --install-service would write, then exit. Works without systemd.')
|
|
570
|
+
svc.add_argument('--uninstall-service', action='store_true', help='Disable and remove the systemd unit. Warns and exits if systemd is not available.')
|
|
571
|
+
parser.add_argument('--unit-name', type=str, default=DEFAULT_UNIT_NAME, help='systemd unit name (default: firewatcher).')
|
|
572
|
+
parser.add_argument('--requires-mounts-for', action='append', default=[], metavar='PATH', help='Add RequiresMountsFor= to the unit (repeatable). Use when -o is on NFS or another remote filesystem.')
|
|
573
|
+
parser.add_argument('--no-enable', action='store_true', help='With --install-service, write the unit and daemon-reload but do not enable or start it.')
|
|
574
|
+
parser.add_argument('-V','--version', action='version', version=f'%(prog)s {version}')
|
|
575
|
+
return parser
|
|
576
|
+
|
|
577
|
+
def log_source_command(args):
|
|
578
|
+
"""Return the argv used to read logs, or None if nothing is available."""
|
|
579
|
+
if args.filter_only:
|
|
580
|
+
file_pre = ['cat']
|
|
581
|
+
else:
|
|
582
|
+
file_pre = ['tail', '-n', args.tail_lines, '-F']
|
|
583
|
+
if args.log_file:
|
|
584
|
+
return file_pre + [args.log_file]
|
|
585
|
+
if shutil.which('journalctl'):
|
|
586
|
+
cmd = ['journalctl', '--all', '--no-pager', f'--lines={args.tail_lines}']
|
|
587
|
+
if not args.filter_only:
|
|
588
|
+
cmd.append('--follow')
|
|
589
|
+
return cmd
|
|
590
|
+
if os.path.exists('/var/log/syslog'):
|
|
591
|
+
return file_pre + ['/var/log/syslog']
|
|
592
|
+
if os.path.exists('/var/log/messages'):
|
|
593
|
+
return file_pre + ['/var/log/messages']
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
def main(argv=None):
|
|
597
|
+
parser = build_parser()
|
|
598
|
+
args = parser.parse_args(argv)
|
|
599
|
+
if args.uninstall_service:
|
|
600
|
+
return uninstall_service(args)
|
|
601
|
+
if not args.pattern_file:
|
|
602
|
+
if args.install_service or args.print_unit:
|
|
603
|
+
args.pattern_file = [DEFAULT_PATTERNS_DIR]
|
|
604
|
+
else:
|
|
605
|
+
parser.error('pattern file or directory required (for example /etc/firewatcher/patterns.d)')
|
|
606
|
+
if args.print_unit:
|
|
607
|
+
print(render_unit_file(args), end='')
|
|
608
|
+
return 0
|
|
609
|
+
if args.install_service:
|
|
610
|
+
return install_service(args)
|
|
611
|
+
print(f"Starting firewatcher v{version}")
|
|
612
|
+
command_to_run = log_source_command(args)
|
|
613
|
+
if not command_to_run:
|
|
614
|
+
print("No log file specified and no journalctl / syslog / messages found. Use --log-file. Exiting.")
|
|
615
|
+
return 1
|
|
616
|
+
pattern_files = expand_pattern_sources(args.pattern_file)
|
|
617
|
+
if not pattern_files:
|
|
618
|
+
print(f"No pattern files found in {args.pattern_file}. Exiting.", file=sys.stderr)
|
|
619
|
+
return 1
|
|
620
|
+
patterns = load_patterns(pattern_files)
|
|
621
|
+
print(f"Monitoring {command_to_run} for patterns: {patterns}")
|
|
622
|
+
print(f"Capturing logs for {args.capture_time} seconds.")
|
|
623
|
+
print(f"Output folder: {args.output_folder}")
|
|
624
|
+
print(f"Compress logs after {args.compress_after_months} months.")
|
|
625
|
+
print(f"Delete logs after {args.delete_after_months} months.")
|
|
626
|
+
capture_messages(command_to_run, patterns,capture_time=args.capture_time,output_folder=args.output_folder,compress_after_months=args.compress_after_months,delete_after_months=args.delete_after_months,capture_line_count_max=args.capture_line_count_max)
|
|
627
|
+
return 0
|
|
628
|
+
|
|
629
|
+
if __name__ == '__main__':
|
|
630
|
+
sys.exit(main() or 0)
|