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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ firewatch = firewatcher:main
3
+ firewatcher = firewatcher:main
@@ -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)