firewatcher 1.60__tar.gz → 1.61__tar.gz

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.
Files changed (27) hide show
  1. {firewatcher-1.60/firewatcher.egg-info → firewatcher-1.61}/PKG-INFO +7 -1
  2. {firewatcher-1.60 → firewatcher-1.61}/README.md +6 -0
  3. {firewatcher-1.60 → firewatcher-1.61}/examples/firewatcher.conf +2 -0
  4. {firewatcher-1.60 → firewatcher-1.61/firewatcher.egg-info}/PKG-INFO +7 -1
  5. {firewatcher-1.60 → firewatcher-1.61}/firewatcher.py +217 -35
  6. {firewatcher-1.60 → firewatcher-1.61}/tests/conftest.py +7 -2
  7. {firewatcher-1.60 → firewatcher-1.61}/tests/test_capture.py +75 -4
  8. {firewatcher-1.60 → firewatcher-1.61}/tests/test_cli.py +4 -4
  9. {firewatcher-1.60 → firewatcher-1.61}/tests/test_config.py +86 -0
  10. {firewatcher-1.60 → firewatcher-1.61}/tests/test_matching.py +7 -0
  11. firewatcher-1.61/tests/test_notify.py +801 -0
  12. {firewatcher-1.60 → firewatcher-1.61}/tests/test_systemd.py +88 -2
  13. firewatcher-1.60/tests/test_notify.py +0 -400
  14. {firewatcher-1.60 → firewatcher-1.61}/LICENSE +0 -0
  15. {firewatcher-1.60 → firewatcher-1.61}/MANIFEST.in +0 -0
  16. {firewatcher-1.60 → firewatcher-1.61}/examples/nvme_failure.regex +0 -0
  17. {firewatcher-1.60 → firewatcher-1.61}/examples/sys_msg.txt +0 -0
  18. {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/SOURCES.txt +0 -0
  19. {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/dependency_links.txt +0 -0
  20. {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/entry_points.txt +0 -0
  21. {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/top_level.txt +0 -0
  22. {firewatcher-1.60 → firewatcher-1.61}/setup.cfg +0 -0
  23. {firewatcher-1.60 → firewatcher-1.61}/setup.py +0 -0
  24. {firewatcher-1.60 → firewatcher-1.61}/tests/test_compressor.py +0 -0
  25. {firewatcher-1.60 → firewatcher-1.61}/tests/test_log_source.py +0 -0
  26. {firewatcher-1.60 → firewatcher-1.61}/tests/test_patterns.py +0 -0
  27. {firewatcher-1.60 → firewatcher-1.61}/tests/test_slugify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.60
3
+ Version: 1.61
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -108,6 +108,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
108
108
  | `--llm-model NAME` | Model name |
109
109
  | `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
110
110
  | `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
111
+ | `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
111
112
  | `--notify` / `--no-notify` | Enable or disable ntfy pushes |
112
113
  | `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
113
114
  | `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
@@ -136,6 +137,7 @@ base_url = http://127.0.0.1:11434/v1
136
137
  model = llama3.1
137
138
  timeout = 60
138
139
  max_bytes = 48000
140
+ notes = loop/dm devices from pytest and OOMs in capped test containers are expected
139
141
 
140
142
  [notify]
141
143
  enabled = false
@@ -159,6 +161,10 @@ Both features default off.
159
161
  - Both on: a push is sent only when the model sets `notify` to true.
160
162
  - If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
161
163
 
164
+ A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
165
+
166
+ `notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
167
+
162
168
  The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
163
169
 
164
170
  Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
@@ -81,6 +81,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
81
81
  | `--llm-model NAME` | Model name |
82
82
  | `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
83
83
  | `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
84
+ | `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
84
85
  | `--notify` / `--no-notify` | Enable or disable ntfy pushes |
85
86
  | `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
86
87
  | `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
@@ -109,6 +110,7 @@ base_url = http://127.0.0.1:11434/v1
109
110
  model = llama3.1
110
111
  timeout = 60
111
112
  max_bytes = 48000
113
+ notes = loop/dm devices from pytest and OOMs in capped test containers are expected
112
114
 
113
115
  [notify]
114
116
  enabled = false
@@ -132,6 +134,10 @@ Both features default off.
132
134
  - Both on: a push is sent only when the model sets `notify` to true.
133
135
  - If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
134
136
 
137
+ A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
138
+
139
+ `notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
140
+
135
141
  The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
136
142
 
137
143
  Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
@@ -36,6 +36,8 @@ api_key =
36
36
  timeout = 60
37
37
  # Maximum capture bytes sent to the model.
38
38
  max_bytes = 48000
39
+ # Optional note sent to the model with each capture, e.g. what is normal on this host.
40
+ notes =
39
41
 
40
42
  [notify]
41
43
  # Enable pushes; when LLM analysis is on, the model decides whether to notify.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firewatcher
3
- Version: 1.60
3
+ Version: 1.61
4
4
  Summary: Watch logs for incident patterns and persist surrounding context off-box
5
5
  Home-page: https://github.com/yufei-pan/firewatcher
6
6
  Author: Yufei Pan
@@ -108,6 +108,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
108
108
  | `--llm-model NAME` | Model name |
109
109
  | `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
110
110
  | `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
111
+ | `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
111
112
  | `--notify` / `--no-notify` | Enable or disable ntfy pushes |
112
113
  | `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
113
114
  | `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
@@ -136,6 +137,7 @@ base_url = http://127.0.0.1:11434/v1
136
137
  model = llama3.1
137
138
  timeout = 60
138
139
  max_bytes = 48000
140
+ notes = loop/dm devices from pytest and OOMs in capped test containers are expected
139
141
 
140
142
  [notify]
141
143
  enabled = false
@@ -159,6 +161,10 @@ Both features default off.
159
161
  - Both on: a push is sent only when the model sets `notify` to true.
160
162
  - If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
161
163
 
164
+ A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
165
+
166
+ `notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
167
+
162
168
  The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
163
169
 
164
170
  Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
@@ -21,7 +21,7 @@ import socket
21
21
  import syslog
22
22
  import threading
23
23
 
24
- version = '1.60'
24
+ version = '1.61'
25
25
  __version__ = version
26
26
 
27
27
  DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
@@ -53,6 +53,7 @@ LLM_SYSTEM_PROMPT = (
53
53
  'Keys: "title" (short, one line), "summary" (plain text, a few sentences), '
54
54
  '"notify" (boolean: true only if a person should look soon; '
55
55
  'false for benign, expected, or duplicate-looking noise). '
56
+ 'Operator notes about the host, when present, describe what is normal there. '
56
57
  'Do not invent hosts, times, or causes that are not in the excerpt.'
57
58
  )
58
59
 
@@ -99,9 +100,16 @@ class Log_Compressor:
99
100
  self.lastProcessTime = time.time()
100
101
 
101
102
  def print(*args, **kwargs):
102
- '''Print with flush=True by default.'''
103
+ '''Print with flush=True by default. Text the stream can't encode (Python 3.6 under a C locale) is
104
+ escaped instead of stopping the watcher.'''
103
105
  kwargs.setdefault('flush', True)
104
- return builtins.print(*args, **kwargs)
106
+ sep = kwargs.pop('sep', None)
107
+ # One string, so a line that can't be encoded fails before any of it is written.
108
+ text = (' ' if sep is None else sep).join(str(arg) for arg in args)
109
+ try:
110
+ return builtins.print(text, **kwargs)
111
+ except UnicodeEncodeError:
112
+ return builtins.print(text.encode('ascii', 'backslashreplace').decode('ascii'), **kwargs)
105
113
 
106
114
  class bcolors:
107
115
  HEADER = '\033[95m'
@@ -143,7 +151,7 @@ def load_patterns(pattern_files):
143
151
  print(f"Pattern file not found: {pattern_file}")
144
152
  continue
145
153
  try:
146
- with open(pattern_file, 'r') as f:
154
+ with open(pattern_file, 'r', encoding='utf-8', errors='replace') as f:
147
155
  patterns = [line.strip() for line in f if line.strip()]
148
156
  except Exception as e:
149
157
  print(f"Error reading pattern file: {pattern_file}")
@@ -251,6 +259,7 @@ class Settings(object):
251
259
  self.llm_api_key = ''
252
260
  self.llm_timeout = 60
253
261
  self.llm_max_bytes = 48000
262
+ self.llm_notes = ''
254
263
  self.notify_enabled = False
255
264
  self.ntfy_url = ''
256
265
  self.ntfy_token = ''
@@ -340,6 +349,9 @@ def _read_config_file(path):
340
349
  take('llm', 'api_key', 'llm_api_key', 'str')
341
350
  take('llm', 'timeout', 'llm_timeout', 'int')
342
351
  take('llm', 'max_bytes', 'llm_max_bytes', 'int')
352
+ take('llm', 'notes', 'llm_notes', 'str')
353
+ if not values.get('llm_notes'):
354
+ take('llm', 'note', 'llm_notes', 'str')
343
355
  take('notify', 'enabled', 'notify_enabled', 'bool')
344
356
  take('notify', 'ntfy_url', 'ntfy_url', 'str')
345
357
  take('notify', 'token', 'ntfy_token', 'str')
@@ -384,7 +396,7 @@ def resolve_settings(args, environ=None):
384
396
  settings.output_folder = file_values['output_folder']
385
397
  if file_values.get('tail_lines'):
386
398
  settings.tail_lines = file_values['tail_lines']
387
- for key in ('llm_base_url', 'llm_model', 'llm_api_key', 'ntfy_url', 'ntfy_token'):
399
+ for key in ('llm_base_url', 'llm_model', 'llm_api_key', 'llm_notes', 'ntfy_url', 'ntfy_token'):
388
400
  if key in file_values:
389
401
  setattr(settings, key, file_values[key])
390
402
  if getattr(args, 'pattern_file', None):
@@ -415,6 +427,8 @@ def resolve_settings(args, environ=None):
415
427
  settings.llm_timeout = args.llm_timeout
416
428
  if getattr(args, 'llm_max_bytes', None) is not None:
417
429
  settings.llm_max_bytes = args.llm_max_bytes
430
+ if getattr(args, 'llm_notes', None) is not None:
431
+ settings.llm_notes = args.llm_notes
418
432
  if getattr(args, 'notify', None) is not None:
419
433
  settings.notify_enabled = bool(args.notify)
420
434
  if getattr(args, 'ntfy_url', None) is not None:
@@ -427,6 +441,7 @@ def resolve_settings(args, environ=None):
427
441
  settings.llm_api_key = environ[LLM_API_KEY_ENV]
428
442
  if NTFY_TOKEN_ENV in environ:
429
443
  settings.ntfy_token = environ[NTFY_TOKEN_ENV]
444
+ settings.llm_notes = _note_text(settings.llm_notes)
430
445
  settings.llm_base_url = (settings.llm_base_url or '').strip()
431
446
  settings.llm_model = (settings.llm_model or '').strip()
432
447
  settings.ntfy_url = (settings.ntfy_url or '').strip()
@@ -452,6 +467,33 @@ def _ini_patterns(paths):
452
467
  return first + ''.join('\n\t' + item for item in paths[1:])
453
468
 
454
469
 
470
+ def _as_utf8(text):
471
+ """Text that can be written or sent as UTF-8: bytes Python 3.6 decoded with surrogateescape (argv and
472
+ paths under a C locale) are decoded as UTF-8, and anything invalid is replaced."""
473
+ text = str(text)
474
+ try:
475
+ return text.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace')
476
+ except UnicodeEncodeError: # a lone surrogate that is not an escaped byte, e.g. from a JSON reply
477
+ return text.encode('utf-8', 'replace').decode('utf-8')
478
+
479
+
480
+ def _strict_utf8(text):
481
+ """Text for a config or unit file, with the bytes Python decoded from argv, paths or the environment
482
+ with surrogateescape restored (any byte the locale can't decode: non-UTF-8 bytes, or every non-ASCII
483
+ byte on Python 3.6 under a C locale). Bytes that are not UTF-8 are refused, not replaced: replacing
484
+ them would point the service at a different path. The message shows no value, since values include
485
+ the API key, the ntfy token and the private ntfy topic URL."""
486
+ try:
487
+ return text.encode('utf-8', 'surrogateescape').decode('utf-8')
488
+ except UnicodeError:
489
+ raise ConfigError('a path or value is not UTF-8, so it cannot go into a config or unit file')
490
+
491
+
492
+ def _note_text(value):
493
+ """A note as one line: any run of whitespace, newlines included, becomes one space."""
494
+ return ' '.join(str('' if value is None else value).split())
495
+
496
+
455
497
  def _ini_text(value):
456
498
  return str('' if value is None else value).replace('\r', '').replace('\n', '').strip()
457
499
 
@@ -506,6 +548,8 @@ def render_config_text(settings):
506
548
  'timeout = %s' % int(settings.llm_timeout),
507
549
  '# Maximum capture bytes sent to the model.',
508
550
  'max_bytes = %s' % int(settings.llm_max_bytes),
551
+ '# Optional note sent to the model with each capture, e.g. what is normal on this host.',
552
+ 'notes = %s' % settings.llm_notes,
509
553
  '',
510
554
  '[notify]',
511
555
  '# Enable pushes; when LLM analysis is on, the model decides whether to notify.',
@@ -525,10 +569,14 @@ def render_config_text(settings):
525
569
 
526
570
 
527
571
  def write_config_file(path, settings):
572
+ _write_private_file(path, _strict_utf8(render_config_text(settings)))
573
+
574
+
575
+ def _write_private_file(path, text):
576
+ payload = text.encode('utf-8')
528
577
  directory = os.path.dirname(path)
529
578
  if directory:
530
579
  os.makedirs(directory, exist_ok=True)
531
- payload = render_config_text(settings).encode('utf-8')
532
580
  fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
533
581
  try:
534
582
  os.write(fd, payload)
@@ -570,6 +618,8 @@ def _explicit_cli_args(raw):
570
618
  parts.extend(['--llm-timeout', str(raw.llm_timeout)])
571
619
  if getattr(raw, 'llm_max_bytes', None) is not None:
572
620
  parts.extend(['--llm-max-bytes', str(raw.llm_max_bytes)])
621
+ if getattr(raw, 'llm_notes', None) is not None:
622
+ parts.append('--llm-notes=' + _note_text(raw.llm_notes))
573
623
  if getattr(raw, 'notify', None) is True:
574
624
  parts.append('--notify')
575
625
  elif getattr(raw, 'notify', None) is False:
@@ -626,12 +676,12 @@ def _http_header(text, limit):
626
676
 
627
677
 
628
678
  def _notify_body(text, path):
629
- suffix = '\n' + str(path)
679
+ suffix = '\n' + _as_utf8(path)
630
680
  suffix_b = suffix.encode('utf-8')
631
681
  if len(suffix_b) >= NTFY_BODY_MAX:
632
682
  return suffix_b[:NTFY_BODY_MAX].decode('utf-8', 'ignore')
633
683
  budget = NTFY_BODY_MAX - len(suffix_b)
634
- body = _flatten(text).encode('utf-8')[:budget]
684
+ body = _as_utf8(_flatten(text)).encode('utf-8')[:budget]
635
685
  return body.decode('utf-8', 'ignore') + suffix
636
686
 
637
687
 
@@ -689,17 +739,92 @@ def _parse_llm_object(content):
689
739
  return {'title': title, 'summary': str(summary), 'notify': obj['notify']}
690
740
 
691
741
 
692
- def _capture_excerpt(path, max_bytes):
742
+ def _excerpt_window(data, head_end, start, body, limit):
743
+ """Bytes [lo, hi) around start: a quarter of body before it and the rest from it on, the unused share
744
+ of a side that reaches head_end or limit going to the other, cut at line ends. A bigger body never
745
+ gives a smaller window, which _capture_excerpt relies on."""
746
+ before = min(body // 4, start - head_end)
747
+ after = min(body - before, limit - start)
748
+ before = min(body - after, start - head_end)
749
+ lo = start - before
750
+ if lo > head_end:
751
+ # start - 1 is a newline here, so one is always found; a line too long for the share before the
752
+ # matched line is dropped, not cut. Its bytes stay unused: handing them to the after side would
753
+ # let a bigger body end the window earlier.
754
+ lo = data.find(b'\n', lo - 1, start) + 1
755
+ hi = start + after
756
+ if hi < limit:
757
+ nl = data.rfind(b'\n', start, hi)
758
+ hi = nl + 1 if nl != -1 else hi
759
+ return lo, hi
760
+
761
+
762
+ def _capture_excerpt(path, max_bytes, matched_offset=None, later_offsets=(), footer_offset=None):
763
+ """At most max_bytes of a capture for the model.
764
+
765
+ A capture that fits goes whole. Otherwise the model gets the header (the start of the file), a window
766
+ around the matched line (a quarter of the budget before it, the rest from it on), the matched lines
767
+ after the window (a match during a capture only extends it; up to a quarter of the budget, newest
768
+ first, so the end of an escalation is kept) and the capture's last lines with its footer (an eighth). Offsets are bytes into the file,
769
+ as capture_messages recorded them. Every skipped stretch is marked with a "..." line. Without a
770
+ matched offset it is head + tail.
771
+ """
693
772
  max_bytes = int(max_bytes)
694
773
  if max_bytes < 1:
695
774
  return ''
696
775
  with open(path, 'rb') as handle:
697
776
  data = handle.read()
698
- if len(data) <= max_bytes:
777
+ size = len(data)
778
+ head_n = min(EXCERPT_HEAD_BYTES, max(1, max_bytes // 2))
779
+ sep = b'\n...\n'
780
+ if size <= max_bytes:
699
781
  text = data.decode('utf-8', 'replace')
782
+ elif matched_offset is not None and 0 <= matched_offset < size and max_bytes - head_n > 2 * len(sep):
783
+ head_end = data.rfind(b'\n', 0, head_n) + 1 or head_n
784
+ # The matched line starts at a line start; if the offset is stale, snap to the line it is in.
785
+ start = max(head_end, data.rfind(b'\n', 0, matched_offset) + 1)
786
+ # Each piece pays for the "..." before it; the window's two are in body.
787
+ body = max_bytes - head_end - 2 * len(sep)
788
+ # The capture's last lines (often a retry that worked or a service back up) and its footer, in up
789
+ # to an eighth of the budget; the window and the later matches stop where they start.
790
+ limit = size
791
+ end_share = body // 8 - len(sep)
792
+ if footer_offset is not None and start < footer_offset < size and size - footer_offset <= end_share:
793
+ # A last raw log line may lack its newline: then the end piece starts at the footer.
794
+ limit = data.find(b'\n', size - end_share - 1, footer_offset) + 1 or footer_offset
795
+ if limit <= start:
796
+ limit = footer_offset
797
+ body -= size - limit + len(sep)
798
+ later = []
799
+ for pos in sorted(p for p in later_offsets or () if start < p < limit):
800
+ nl = data.find(b'\n', pos, limit)
801
+ later.append((pos, data[pos:limit if nl == -1 else nl + 1]))
802
+ # Room for the later matches (up to a quarter) comes first, and the newest outside the window that
803
+ # fit are kept. The window then gets the rest of the budget; it only grows, so whatever it held
804
+ # stays, and a kept line it now reaches is dropped and its bytes go back to the window.
805
+ room = min(sum(len(line) + len(sep) for _pos, line in later), body // 4)
806
+ lo, hi = _excerpt_window(data, head_end, start, body - room, limit)
807
+ kept, used = [], 0
808
+ for pos, line in reversed(later):
809
+ if pos >= hi and used + len(line) + len(sep) <= room:
810
+ kept.append((pos, line))
811
+ used += len(line) + len(sep)
812
+ while True:
813
+ lo, hi = _excerpt_window(data, head_end, start, body - used, limit)
814
+ if all(pos >= hi for pos, _line in kept):
815
+ break
816
+ kept = [(pos, line) for pos, line in kept if pos >= hi]
817
+ used = sum(len(line) + len(sep) for _pos, line in kept)
818
+ kept.sort()
819
+ pieces = [(lo, data[lo:hi])] + kept + ([(limit, data[limit:])] if limit < size else [])
820
+ out, end = [data[:head_end]], head_end
821
+ for pos, piece in pieces:
822
+ out.extend([sep, piece] if pos > end else [piece])
823
+ end = pos + len(piece)
824
+ if end < size:
825
+ out.append(sep)
826
+ text = b''.join(out).decode('utf-8', 'replace')
700
827
  else:
701
- head_n = min(EXCERPT_HEAD_BYTES, max(1, max_bytes // 2))
702
- sep = b'\n...\n'
703
828
  tail_n = max_bytes - head_n - len(sep)
704
829
  if tail_n < 1:
705
830
  sep = b''
@@ -715,11 +840,17 @@ def _capture_excerpt(path, max_bytes):
715
840
 
716
841
  def call_llm(settings, incident):
717
842
  import json
718
- excerpt = _capture_excerpt(incident['path'], settings.llm_max_bytes)
843
+ excerpt = _capture_excerpt(
844
+ incident['path'], settings.llm_max_bytes, incident.get('matched_offset'),
845
+ incident.get('later_offsets'), incident.get('footer_offset'))
846
+ notes = settings.llm_notes
719
847
  user = (
720
- 'Host: %s\nMatched pattern: %s\nMatched line: %s\nCapture path: %s\n\nExcerpt:\n%s'
721
- % (incident.get('hostname', ''), incident.get('pattern', ''), incident.get('matched_line', ''), incident.get('path', ''), excerpt)
848
+ 'Host: %s\n%sMatched pattern: %s\nMatched line: %s\nCapture path: %s\n\nExcerpt:\n%s'
849
+ % (incident.get('hostname', ''), 'Operator notes about this host: %s\n' % notes if notes else '',
850
+ incident.get('pattern', ''), incident.get('matched_line', ''), incident.get('path', ''), excerpt)
722
851
  )
852
+ # Not lone surrogates, which a JSON parser may refuse.
853
+ user = _as_utf8(user)
723
854
  url = settings.llm_base_url.rstrip('/') + '/chat/completions'
724
855
  payload = {
725
856
  'model': settings.llm_model,
@@ -754,7 +885,7 @@ def send_ntfy(url, title, body, priority, token, timeout):
754
885
 
755
886
  def _append_note(path, text):
756
887
  rows = str(text).splitlines() or ['']
757
- with open(path, 'a', encoding='utf-8') as handle:
888
+ with open(path, 'a', encoding='utf-8', errors='replace') as handle:
758
889
  handle.write('\n')
759
890
  for row in rows:
760
891
  handle.write('%s %s\n' % (SELF_MARKER, row))
@@ -893,13 +1024,18 @@ class CaptureFile(object):
893
1024
  def __init__(self, path, sync_interval):
894
1025
  self.path = path
895
1026
  self.sync_interval = sync_interval
896
- self.handle = open(path, 'a')
1027
+ self.handle = open(path, 'a', encoding='utf-8')
897
1028
  self.last_sync = None
898
1029
  self.dirty = False
899
1030
 
900
1031
  def write(self, text):
901
1032
  self.handle.write(text)
902
1033
 
1034
+ def offset(self):
1035
+ """Bytes in the file so far, i.e. where the next write starts."""
1036
+ self.handle.flush()
1037
+ return self.handle.buffer.tell()
1038
+
903
1039
  def commit(self):
904
1040
  self.handle.flush()
905
1041
  self.dirty = True
@@ -931,6 +1067,8 @@ def _end_capture(capture, captured_line_counter, capture_line_count_max, inciden
931
1067
  if not capture:
932
1068
  return
933
1069
  try:
1070
+ if incident is not None:
1071
+ incident['footer_offset'] = capture.offset()
934
1072
  if captured_line_counter > capture_line_count_max:
935
1073
  capture.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")
936
1074
  capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
@@ -1030,7 +1168,7 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
1030
1168
  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")
1031
1169
  # also record a journal of all logs captured in {output_folder}/journal.log
1032
1170
  journal_file_name = os.path.abspath(f"{output_folder}/journal.log")
1033
- with open(journal_file_name, "a") as journal_file:
1171
+ with open(journal_file_name, "a", encoding='utf-8', errors='surrogateescape') as journal_file:
1034
1172
  journal_file.write(f"{datetime.datetime.fromtimestamp(read_time).isoformat()} Captured logs for {matched_pattern} to {log_file_name}\n")
1035
1173
  print(f"Capturing logs to {log_file_name}")
1036
1174
  incident = {
@@ -1051,13 +1189,18 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
1051
1189
  capture.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
1052
1190
  if dropped_until is not None and read_time - dropped_until <= capture_time:
1053
1191
  capture.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Older cached in-memory lines were dropped ...{bcolors.ENDC}\n")
1054
- for _buffered_time, buffered_line in log_buffer:
1192
+ # The newest buffered line is the trigger itself; remember where it starts for the LLM excerpt.
1193
+ trigger_index = len(log_buffer) - 1
1194
+ for index, (_buffered_time, buffered_line) in enumerate(log_buffer):
1195
+ if index == trigger_index:
1196
+ incident['matched_offset'] = capture.offset()
1055
1197
  if matcher.find(buffered_line) is not None:
1056
1198
  capture.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
1057
1199
  else:
1058
1200
  capture.write(buffered_line)
1059
1201
  else:
1060
- # Append the line to the current log file
1202
+ # Append the line to the current log file; the LLM excerpt keeps these lines.
1203
+ incident.setdefault('later_offsets', []).append(capture.offset())
1061
1204
  capture.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
1062
1205
  captured_line_counter += 1
1063
1206
  capture.commit()
@@ -1159,7 +1302,7 @@ def expand_pattern_sources(paths):
1159
1302
 
1160
1303
 
1161
1304
  def unit_filename(unit_name):
1162
- name = (unit_name or DEFAULT_UNIT_NAME).strip()
1305
+ name = (unit_name or '').strip() or DEFAULT_UNIT_NAME
1163
1306
  if not name.endswith('.service'):
1164
1307
  name = name + '.service'
1165
1308
  base = os.path.basename(name)
@@ -1168,6 +1311,16 @@ def unit_filename(unit_name):
1168
1311
  return base
1169
1312
 
1170
1313
 
1314
+ def _check_new_unit_name(base):
1315
+ """ValueError unless systemd can load and enable a unit file named base: only its characters, at
1316
+ most 255 of them, a name before the first '@' and an instance after it. For install only: uninstall
1317
+ must still remove a unit an older firewatcher installed under another name."""
1318
+ prefix, at, instance = base[:-len('.service')].partition('@')
1319
+ if len(base) > 255 or not prefix or not re.match(r'[A-Za-z0-9:_.@\\-]+\Z', base) or (at and not instance):
1320
+ raise ValueError('unit name must be one systemd can load and enable: letters, digits and : _ . @ - \\, '
1321
+ 'at most 255, and any @ with a name before it and an instance after it')
1322
+
1323
+
1171
1324
  def resolve_executable():
1172
1325
  found = shutil.which('firewatcher')
1173
1326
  if found:
@@ -1175,9 +1328,13 @@ def resolve_executable():
1175
1328
  return os.path.abspath(sys.argv[0])
1176
1329
 
1177
1330
 
1178
- def _quote_unit_arg(value):
1179
- value = str(value)
1180
- if value and not any(ch.isspace() or ch in '"\\' for ch in value):
1331
+ def _quote_unit_arg(value, program=False):
1332
+ # systemd expands % specifiers in all of ExecStart and $ variables in its arguments (not the program
1333
+ # path); a free-text argument must survive both.
1334
+ value = str(value).replace('%', '%%')
1335
+ if not program:
1336
+ value = value.replace('$', '$$')
1337
+ if value and not any(ch.isspace() or ch in '"\\\'' for ch in value):
1181
1338
  return value
1182
1339
  return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
1183
1340
 
@@ -1194,7 +1351,7 @@ def render_unit_file(args, executable=None, config_exists=None, raw=None):
1194
1351
  parts.extend(_explicit_cli_args(raw))
1195
1352
  if not resolved.config_has_patterns and not getattr(raw, 'pattern_file', None):
1196
1353
  parts.extend(resolved.pattern_file or [DEFAULT_PATTERNS_DIR])
1197
- exec_start = ' '.join([_quote_unit_arg(executable)] + [_quote_unit_arg(p) for p in parts])
1354
+ exec_start = ' '.join([_quote_unit_arg(executable, program=True)] + [_quote_unit_arg(p) for p in parts])
1198
1355
  after = ['network-online.target']
1199
1356
  wants = ['network-online.target']
1200
1357
  if not resolved.log_file:
@@ -1211,7 +1368,8 @@ def render_unit_file(args, executable=None, config_exists=None, raw=None):
1211
1368
  'Wants=' + ' '.join(wants),
1212
1369
  ]
1213
1370
  for mount in requires:
1214
- lines.append('RequiresMountsFor=' + mount)
1371
+ # A path list: quoted and % escaped like an argument, but systemd substitutes no $ in it.
1372
+ lines.append('RequiresMountsFor=' + _quote_unit_arg(mount, program=True))
1215
1373
  stop_sec = max(30, int(resolved.llm_timeout) + int(resolved.ntfy_timeout) + 15)
1216
1374
  lines.extend([
1217
1375
  '',
@@ -1295,18 +1453,32 @@ def install_service(args, systemd_available=None, unit_dir=None, systemctl=None,
1295
1453
  resolved.pattern_file = [DEFAULT_PATTERNS_DIR]
1296
1454
  if unit_dir is None:
1297
1455
  unit_dir = SYSTEMD_UNIT_DIR
1456
+ config_existed = os.path.isfile(resolved.config_path)
1457
+ executable = executable or resolve_executable()
1458
+ # The unit name and both files are checked before anything is written: a refusal leaves nothing
1459
+ # behind (a left-over config would make the next install render a different unit).
1460
+ try:
1461
+ unit_name = unit_filename(resolved.unit_name)
1462
+ _check_new_unit_name(unit_name)
1463
+ except ValueError as exc:
1464
+ print(str(exc), file=sys.stderr)
1465
+ return 2
1466
+ try:
1467
+ text = _strict_utf8(render_unit_file(resolved, executable=executable, config_exists=config_existed, raw=raw))
1468
+ config_text = None if config_existed else _strict_utf8(render_config_text(resolved))
1469
+ except ConfigError as exc:
1470
+ print(str(exc), file=sys.stderr)
1471
+ return 2
1298
1472
  os.makedirs(resolved.output_folder, exist_ok=True)
1299
1473
  _seed_example_patterns(resolved.pattern_file)
1300
- config_existed = os.path.isfile(resolved.config_path)
1301
- if not config_existed:
1474
+ if config_text is not None:
1302
1475
  try:
1303
- write_config_file(resolved.config_path, resolved)
1476
+ _write_private_file(resolved.config_path, config_text)
1304
1477
  except OSError as exc:
1305
1478
  print('cannot write config %s: %s' % (resolved.config_path, exc), file=sys.stderr)
1306
1479
  return 1
1307
1480
  os.makedirs(unit_dir, exist_ok=True)
1308
- unit_path = os.path.join(unit_dir, unit_filename(resolved.unit_name))
1309
- text = render_unit_file(resolved, executable=executable, config_exists=config_existed, raw=raw)
1481
+ unit_path = os.path.join(unit_dir, unit_name)
1310
1482
  with open(unit_path, 'w', encoding='utf-8') as fh:
1311
1483
  fh.write(text)
1312
1484
  print(f"Wrote {unit_path}")
@@ -1316,11 +1488,11 @@ def install_service(args, systemd_available=None, unit_dir=None, systemctl=None,
1316
1488
  return reload.returncode
1317
1489
  if getattr(resolved, 'no_enable', False):
1318
1490
  return 0
1319
- enable = _run_systemctl(systemctl, 'enable', '--now', unit_filename(resolved.unit_name))
1491
+ enable = _run_systemctl(systemctl, 'enable', '--now', '--', unit_name)
1320
1492
  if getattr(enable, 'returncode', 0):
1321
1493
  print(f"systemctl enable --now failed (exit {enable.returncode})", file=sys.stderr)
1322
1494
  return enable.returncode
1323
- print(f"Enabled and started {unit_filename(resolved.unit_name)}")
1495
+ print(f"Enabled and started {unit_name}")
1324
1496
  return 0
1325
1497
 
1326
1498
 
@@ -1334,7 +1506,7 @@ def uninstall_service(args, systemd_available=None, unit_dir=None, systemctl=Non
1334
1506
  unit_dir = SYSTEMD_UNIT_DIR
1335
1507
  name = unit_filename(args.unit_name)
1336
1508
  unit_path = os.path.join(unit_dir, name)
1337
- disable = _run_systemctl(systemctl, 'disable', '--now', name)
1509
+ disable = _run_systemctl(systemctl, 'disable', '--now', '--', name)
1338
1510
  if getattr(disable, 'returncode', 0) not in (0, None) and os.path.exists(unit_path):
1339
1511
  print(f"systemctl disable --now {name} exited {disable.returncode}", file=sys.stderr)
1340
1512
  if os.path.exists(unit_path):
@@ -1384,6 +1556,7 @@ def build_parser():
1384
1556
  parser.add_argument('--llm-model', default=None, help='Model name sent to the LLM API.')
1385
1557
  parser.add_argument('--llm-timeout', type=int, default=None, help='LLM HTTP timeout in seconds. Default: 60.')
1386
1558
  parser.add_argument('--llm-max-bytes', type=int, default=None, help='Maximum capture bytes sent to the LLM. Default: 48000.')
1559
+ parser.add_argument('--llm-notes', default=None, metavar='TEXT', help='Short note sent to the LLM with each capture, for example what is normal on this host. Default: none.')
1387
1560
  notify = parser.add_mutually_exclusive_group()
1388
1561
  notify.add_argument('--notify', dest='notify', action='store_true', help='Push a notification for finished captures. With --llm, only when the model asks for one.')
1389
1562
  notify.add_argument('--no-notify', dest='notify', action='store_false', help='Disable notifications, overriding the config file.')
@@ -1431,7 +1604,16 @@ def main(argv=None):
1431
1604
  else:
1432
1605
  parser.error('pattern file or directory required (for example /etc/firewatcher/patterns.d)')
1433
1606
  if args.print_unit:
1434
- print(render_unit_file(resolved, raw=args), end='')
1607
+ try:
1608
+ text = _strict_utf8(render_unit_file(resolved, raw=args))
1609
+ except ConfigError as exc:
1610
+ print(str(exc), file=sys.stderr)
1611
+ return 2
1612
+ # As UTF-8 whatever the locale: the text is pasted into a unit file, where an escape would be read
1613
+ # as a C escape.
1614
+ sys.stdout.flush()
1615
+ sys.stdout.buffer.write(text.encode('utf-8'))
1616
+ sys.stdout.buffer.flush()
1435
1617
  return 0
1436
1618
  if args.install_service:
1437
1619
  return install_service(resolved, raw=args)
@@ -10,11 +10,16 @@ if str(ROOT) not in sys.path:
10
10
 
11
11
  import firewatcher as fw # noqa: E402
12
12
 
13
+ # the real default, before the fixture below points it into each test's directory
14
+ REAL_OUTPUT_FOLDER = fw.DEFAULT_OUTPUT_FOLDER
15
+
13
16
 
14
17
  @pytest.fixture(autouse=True)
15
18
  def isolated_firewatcher_config(tmp_path, monkeypatch):
16
- """Keep tests off the host config and any secret environment variables."""
19
+ """Keep tests off the host config, output and pattern folders, and any secret environment variables."""
17
20
  monkeypatch.setattr(fw, 'DEFAULT_CONFIG_PATH', str(tmp_path / 'firewatcher.conf'))
21
+ monkeypatch.setattr(fw, 'DEFAULT_OUTPUT_FOLDER', str(tmp_path / 'captured_messages') + os.sep)
22
+ monkeypatch.setattr(fw, 'DEFAULT_PATTERNS_DIR', str(tmp_path / 'patterns.d'))
18
23
  monkeypatch.delenv(fw.LLM_API_KEY_ENV, raising=False)
19
24
  monkeypatch.delenv(fw.NTFY_TOKEN_ENV, raising=False)
20
25
 
@@ -44,7 +49,7 @@ def write_log(tmp_path, text, name='src.log'):
44
49
  if isinstance(text, bytes):
45
50
  src.write_bytes(text)
46
51
  else:
47
- src.write_text(text)
52
+ src.write_text(text, encoding='utf-8')
48
53
  return src
49
54
 
50
55