omdnotificationforwarder 4.3__tar.gz → 4.5__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.
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/PKG-INFO +158 -4
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/README.md +156 -2
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/bin/notificationforwarder +1 -1
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/pyproject.toml +1 -1
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/baseclass.py +70 -55
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/email/forwarder.py +3 -1
- omdnotificationforwarder-4.5/src/notificationforwarder/spool.py +113 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/syslog/forwarder.py +2 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/telegram/forwarder.py +5 -2
- omdnotificationforwarder-4.5/tests/TESTS.md +636 -0
- omdnotificationforwarder-4.5/tests/test_contracts_formatters.py +290 -0
- omdnotificationforwarder-4.5/tests/test_contracts_forwarders.py +978 -0
- omdnotificationforwarder-4.5/tests/test_contracts_reporters.py +249 -0
- omdnotificationforwarder-4.5/tests/test_integration_cli.py +102 -0
- omdnotificationforwarder-4.5/tests/test_loading_resolution.py +169 -0
- omdnotificationforwarder-4.5/tests/test_loggers.py +496 -0
- omdnotificationforwarder-4.5/tests/test_nostr.py +701 -0
- omdnotificationforwarder-4.5/tests/test_orchestration_flow.py +275 -0
- omdnotificationforwarder-4.5/tests/test_resilience_spool.py +999 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_runtime_foundation.py +17 -0
- omdnotificationforwarder-4.3/src/notificationforwarder/spool.py +0 -83
- omdnotificationforwarder-4.3/tests/test_nostr.py +0 -355
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/.gitignore +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/component_loader.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/email/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/example/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/example/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/json/__init__.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/json/logger.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/naemonlog/reporter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/__init__.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/runtime_config.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/runtime_flow.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/syslog/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/text/__init__.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/text/logger.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/util.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/webhook/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_alertmanager.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_builtin_plugins_focus.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_classes.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_delivery_resilience.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_discard.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_formatter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_logger.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_oauth2webhook.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_package.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_paths.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_reporter.py +0 -0
- {omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: omdnotificationforwarder
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.5
|
|
4
4
|
Summary: A framework for notification scripts for OMD
|
|
5
5
|
Project-URL: Homepage, https://github.com/lausser/noteventificationforhandlerwarder
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/lausser/noteventificationforhandlerwarder/issues
|
|
@@ -276,13 +276,18 @@ Install the optional Nostr support with `pip install notificationforwarder[nostr
|
|
|
276
276
|
|nsec|bech32 private key for signing|required|
|
|
277
277
|
|tags|extra Nostr tags to include|`monitoring` plus host/service/state tags|
|
|
278
278
|
|
|
279
|
-
|
|
279
|
+
The recipient is identified by a `p` tag containing the recipient's public key (npub). The `p` tag must be present in the tags list — the forwarder will raise an error if no recipient is found.
|
|
280
|
+
|
|
281
|
+
#### Static recipient
|
|
282
|
+
|
|
283
|
+
If all notifications go to the same recipient, pass the `p` tag via `--forwarderopt tags`:
|
|
280
284
|
|
|
281
285
|
```bash
|
|
282
286
|
$USER1$/notificationforwarder \
|
|
283
287
|
--forwarder nostr \
|
|
284
288
|
--forwarderopt relays='wss://relay.damus.io,wss://nostr-pub.wellorder.net' \
|
|
285
289
|
--forwarderopt nsec='$NSEC$' \
|
|
290
|
+
--forwarderopt tags='[["p", "npub1xyz..."]]' \
|
|
286
291
|
--formatter nostr \
|
|
287
292
|
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
288
293
|
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
@@ -290,7 +295,55 @@ $USER1$/notificationforwarder \
|
|
|
290
295
|
--eventopt SERVICEOUTPUT='$SERVICEOUTPUT$'
|
|
291
296
|
```
|
|
292
297
|
|
|
293
|
-
|
|
298
|
+
#### Per-contact recipient
|
|
299
|
+
|
|
300
|
+
In a real monitoring setup, different contacts may have different Nostr public keys. Use a custom contact variable (e.g., `$CONTACTNOSTRNPUB$`) and pass it as an eventopt. The formatter then adds it as the `p` tag.
|
|
301
|
+
|
|
302
|
+
**Contact definition:**
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
define contact {
|
|
306
|
+
contact_name admin-team
|
|
307
|
+
...
|
|
308
|
+
_NOSTRNPUB npub1xyz...
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Command definition:**
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
define command {
|
|
316
|
+
command_name notify-service-nostr
|
|
317
|
+
command_line $USER1$/notificationforwarder \
|
|
318
|
+
--forwarder nostr \
|
|
319
|
+
--forwarderopt relays='wss://relay.damus.io' \
|
|
320
|
+
--forwarderopt nsec='$NSEC$' \
|
|
321
|
+
--formatter nostr_custom \
|
|
322
|
+
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
323
|
+
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
324
|
+
--eventopt SERVICESTATE='$SERVICESTATE$' \
|
|
325
|
+
--eventopt SERVICEOUTPUT='$SERVICEOUTPUT$' \
|
|
326
|
+
--eventopt NOSTR_PUBKEY='$_CONTACTNOSTRNPUB$' \
|
|
327
|
+
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Custom formatter** in `~/local/lib/python/notificationforwarder/nostr_custom/formatter.py`:
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
from notificationforwarder.nostr.formatter import NostrFormatter
|
|
335
|
+
|
|
336
|
+
class NostrCustomFormatter(NostrFormatter):
|
|
337
|
+
def format_event(self, event):
|
|
338
|
+
super().format_event(event)
|
|
339
|
+
pubkey = event.eventopts.get("NOSTR_PUBKEY", "")
|
|
340
|
+
if pubkey:
|
|
341
|
+
event.payload["tags"].append(["p", pubkey])
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The `p` tag is appended to the tags list that the default Nostr formatter already builds with monitoring tags. The forwarder picks it up and uses it as the recipient for the encrypted DM.
|
|
345
|
+
|
|
346
|
+
The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
|
|
294
347
|
|
|
295
348
|
## Loggers
|
|
296
349
|
|
|
@@ -463,3 +516,104 @@ define command{
|
|
|
463
516
|
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
464
517
|
}
|
|
465
518
|
```
|
|
519
|
+
|
|
520
|
+
## Spool Flushing
|
|
521
|
+
|
|
522
|
+
When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
|
|
523
|
+
|
|
524
|
+
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool. Events that still fail stay in the queue for the next attempt.
|
|
525
|
+
|
|
526
|
+
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
527
|
+
|
|
528
|
+
### Probe Method
|
|
529
|
+
|
|
530
|
+
Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
|
|
531
|
+
|
|
532
|
+
The framework uses `probe()` as a pre-check with three possible scenarios:
|
|
533
|
+
|
|
534
|
+
|Forwarder has probe()?|probe() result|What happens|Why|
|
|
535
|
+
|---|---|---|---|
|
|
536
|
+
|No|—|flush proceeds|No way to check availability, must try|
|
|
537
|
+
|Yes|True|flush proceeds|Receiver is confirmed reachable|
|
|
538
|
+
|Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
|
|
539
|
+
|
|
540
|
+
Without `probe()`, every flush attempt would call `submit()` for each spooled event even when the receiver is known to be down — wasting time and producing noisy error logs. With `probe()`, the framework can short-circuit: if the receiver is unreachable, the flush is skipped entirely and spooled events wait in the database until the next call.
|
|
541
|
+
|
|
542
|
+
Example implementation:
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
def probe(self):
|
|
546
|
+
r = requests.head(self.url)
|
|
547
|
+
return r.status_code == 200
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Heartbeat Events
|
|
551
|
+
|
|
552
|
+
Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
|
|
553
|
+
|
|
554
|
+
1. **They skip spooling on failure.** If a heartbeat event cannot be delivered, it is NOT saved to the spool database. This prevents periodic check-in events from accumulating in the retry queue.
|
|
555
|
+
|
|
556
|
+
2. **They guarantee regular flush attempts.** While every event triggers the flush phase, heartbeat events ensure this happens at regular intervals — but only when scheduled. A cronjob or Nagios service using notificationforwarder as its check command provides this scheduling, creating a steady pulse of heartbeat events. Without such a schedule, heartbeats don't exist and spooled events could sit in the queue for hours waiting for the next real notification to trigger a flush.
|
|
557
|
+
|
|
558
|
+
Example formatter that sets the heartbeat flag:
|
|
559
|
+
|
|
560
|
+
```python
|
|
561
|
+
class MyFormatter(NotificationFormatter):
|
|
562
|
+
def format_event(self, event):
|
|
563
|
+
notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
|
|
564
|
+
event.is_heartbeat = notification_type == "HEARTBEAT"
|
|
565
|
+
|
|
566
|
+
event.payload = {
|
|
567
|
+
"hostname": event.eventopts.get("HOSTNAME", ""),
|
|
568
|
+
"heartbeat": event.is_heartbeat,
|
|
569
|
+
}
|
|
570
|
+
event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### Scheduling Heartbeats in Nagios/Naemon
|
|
574
|
+
|
|
575
|
+
Since notificationforwarder always exits with code 0, it can be used as a Nagios check command to schedule periodic heartbeats. The service will always be in OK status — it's using Nagios purely as a scheduling mechanism.
|
|
576
|
+
|
|
577
|
+
**Command definition:**
|
|
578
|
+
|
|
579
|
+
```
|
|
580
|
+
define command {
|
|
581
|
+
command_name notificationforwarder_heartbeat
|
|
582
|
+
command_line $USER1$/notificationforwarder \
|
|
583
|
+
--forwarder webhook \
|
|
584
|
+
--forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
|
|
585
|
+
--formatter myformatter \
|
|
586
|
+
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
587
|
+
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
588
|
+
--eventopt NOTIFICATIONTYPE=HEARTBEAT \
|
|
589
|
+
--eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
|
|
590
|
+
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
**Service definition:**
|
|
595
|
+
|
|
596
|
+
```
|
|
597
|
+
define service {
|
|
598
|
+
host_name monitoring-server
|
|
599
|
+
service_description notificationforwarder-heartbeat
|
|
600
|
+
check_command notificationforwarder_heartbeat
|
|
601
|
+
max_check_attempts 1
|
|
602
|
+
check_interval 5
|
|
603
|
+
retry_interval 1
|
|
604
|
+
notification_interval 0
|
|
605
|
+
notification_options n
|
|
606
|
+
notification_period 24x7
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The key settings:
|
|
611
|
+
- `check_interval 5` — runs the heartbeat every 5 minutes
|
|
612
|
+
- `notification_interval 0` — no Nagios-level notifications
|
|
613
|
+
- `notification_options n` — don't send notifications on state changes
|
|
614
|
+
|
|
615
|
+
The heartbeat goes through the normal notificationforwarder flow: the formatter sets `is_heartbeat = True`, the flush phase checks for spooled events (and calls `probe()` if available), and the heartbeat itself is submitted to the receiver. If the receiver accepts it, the receiving system knows the monitoring infrastructure is alive (dead-man switch). If the receiver is down, the heartbeat is simply dropped — it won't add to the spool queue.
|
|
616
|
+
|
|
617
|
+
The two use cases for periodic heartbeats:
|
|
618
|
+
- **Dead-man switch**: The receiving system (e.g., ServiceNow) gets a regular "I'm alive" signal. If heartbeats stop, it knows the monitoring system is down.
|
|
619
|
+
- **Spool draining**: Even when nothing breaks, the heartbeat triggers flush attempts at regular intervals, so spooled events are delivered quickly once the receiver recovers — without waiting for the next real notification.
|
|
@@ -255,13 +255,18 @@ Install the optional Nostr support with `pip install notificationforwarder[nostr
|
|
|
255
255
|
|nsec|bech32 private key for signing|required|
|
|
256
256
|
|tags|extra Nostr tags to include|`monitoring` plus host/service/state tags|
|
|
257
257
|
|
|
258
|
-
|
|
258
|
+
The recipient is identified by a `p` tag containing the recipient's public key (npub). The `p` tag must be present in the tags list — the forwarder will raise an error if no recipient is found.
|
|
259
|
+
|
|
260
|
+
#### Static recipient
|
|
261
|
+
|
|
262
|
+
If all notifications go to the same recipient, pass the `p` tag via `--forwarderopt tags`:
|
|
259
263
|
|
|
260
264
|
```bash
|
|
261
265
|
$USER1$/notificationforwarder \
|
|
262
266
|
--forwarder nostr \
|
|
263
267
|
--forwarderopt relays='wss://relay.damus.io,wss://nostr-pub.wellorder.net' \
|
|
264
268
|
--forwarderopt nsec='$NSEC$' \
|
|
269
|
+
--forwarderopt tags='[["p", "npub1xyz..."]]' \
|
|
265
270
|
--formatter nostr \
|
|
266
271
|
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
267
272
|
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
@@ -269,7 +274,55 @@ $USER1$/notificationforwarder \
|
|
|
269
274
|
--eventopt SERVICEOUTPUT='$SERVICEOUTPUT$'
|
|
270
275
|
```
|
|
271
276
|
|
|
272
|
-
|
|
277
|
+
#### Per-contact recipient
|
|
278
|
+
|
|
279
|
+
In a real monitoring setup, different contacts may have different Nostr public keys. Use a custom contact variable (e.g., `$CONTACTNOSTRNPUB$`) and pass it as an eventopt. The formatter then adds it as the `p` tag.
|
|
280
|
+
|
|
281
|
+
**Contact definition:**
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
define contact {
|
|
285
|
+
contact_name admin-team
|
|
286
|
+
...
|
|
287
|
+
_NOSTRNPUB npub1xyz...
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Command definition:**
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
define command {
|
|
295
|
+
command_name notify-service-nostr
|
|
296
|
+
command_line $USER1$/notificationforwarder \
|
|
297
|
+
--forwarder nostr \
|
|
298
|
+
--forwarderopt relays='wss://relay.damus.io' \
|
|
299
|
+
--forwarderopt nsec='$NSEC$' \
|
|
300
|
+
--formatter nostr_custom \
|
|
301
|
+
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
302
|
+
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
303
|
+
--eventopt SERVICESTATE='$SERVICESTATE$' \
|
|
304
|
+
--eventopt SERVICEOUTPUT='$SERVICEOUTPUT$' \
|
|
305
|
+
--eventopt NOSTR_PUBKEY='$_CONTACTNOSTRNPUB$' \
|
|
306
|
+
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Custom formatter** in `~/local/lib/python/notificationforwarder/nostr_custom/formatter.py`:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from notificationforwarder.nostr.formatter import NostrFormatter
|
|
314
|
+
|
|
315
|
+
class NostrCustomFormatter(NostrFormatter):
|
|
316
|
+
def format_event(self, event):
|
|
317
|
+
super().format_event(event)
|
|
318
|
+
pubkey = event.eventopts.get("NOSTR_PUBKEY", "")
|
|
319
|
+
if pubkey:
|
|
320
|
+
event.payload["tags"].append(["p", pubkey])
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The `p` tag is appended to the tags list that the default Nostr formatter already builds with monitoring tags. The forwarder picks it up and uses it as the recipient for the encrypted DM.
|
|
324
|
+
|
|
325
|
+
The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
|
|
273
326
|
|
|
274
327
|
## Loggers
|
|
275
328
|
|
|
@@ -442,3 +495,104 @@ define command{
|
|
|
442
495
|
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
443
496
|
}
|
|
444
497
|
```
|
|
498
|
+
|
|
499
|
+
## Spool Flushing
|
|
500
|
+
|
|
501
|
+
When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
|
|
502
|
+
|
|
503
|
+
The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool. Events that still fail stay in the queue for the next attempt.
|
|
504
|
+
|
|
505
|
+
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
506
|
+
|
|
507
|
+
### Probe Method
|
|
508
|
+
|
|
509
|
+
Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
|
|
510
|
+
|
|
511
|
+
The framework uses `probe()` as a pre-check with three possible scenarios:
|
|
512
|
+
|
|
513
|
+
|Forwarder has probe()?|probe() result|What happens|Why|
|
|
514
|
+
|---|---|---|---|
|
|
515
|
+
|No|—|flush proceeds|No way to check availability, must try|
|
|
516
|
+
|Yes|True|flush proceeds|Receiver is confirmed reachable|
|
|
517
|
+
|Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
|
|
518
|
+
|
|
519
|
+
Without `probe()`, every flush attempt would call `submit()` for each spooled event even when the receiver is known to be down — wasting time and producing noisy error logs. With `probe()`, the framework can short-circuit: if the receiver is unreachable, the flush is skipped entirely and spooled events wait in the database until the next call.
|
|
520
|
+
|
|
521
|
+
Example implementation:
|
|
522
|
+
|
|
523
|
+
```python
|
|
524
|
+
def probe(self):
|
|
525
|
+
r = requests.head(self.url)
|
|
526
|
+
return r.status_code == 200
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Heartbeat Events
|
|
530
|
+
|
|
531
|
+
Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
|
|
532
|
+
|
|
533
|
+
1. **They skip spooling on failure.** If a heartbeat event cannot be delivered, it is NOT saved to the spool database. This prevents periodic check-in events from accumulating in the retry queue.
|
|
534
|
+
|
|
535
|
+
2. **They guarantee regular flush attempts.** While every event triggers the flush phase, heartbeat events ensure this happens at regular intervals — but only when scheduled. A cronjob or Nagios service using notificationforwarder as its check command provides this scheduling, creating a steady pulse of heartbeat events. Without such a schedule, heartbeats don't exist and spooled events could sit in the queue for hours waiting for the next real notification to trigger a flush.
|
|
536
|
+
|
|
537
|
+
Example formatter that sets the heartbeat flag:
|
|
538
|
+
|
|
539
|
+
```python
|
|
540
|
+
class MyFormatter(NotificationFormatter):
|
|
541
|
+
def format_event(self, event):
|
|
542
|
+
notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
|
|
543
|
+
event.is_heartbeat = notification_type == "HEARTBEAT"
|
|
544
|
+
|
|
545
|
+
event.payload = {
|
|
546
|
+
"hostname": event.eventopts.get("HOSTNAME", ""),
|
|
547
|
+
"heartbeat": event.is_heartbeat,
|
|
548
|
+
}
|
|
549
|
+
event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Scheduling Heartbeats in Nagios/Naemon
|
|
553
|
+
|
|
554
|
+
Since notificationforwarder always exits with code 0, it can be used as a Nagios check command to schedule periodic heartbeats. The service will always be in OK status — it's using Nagios purely as a scheduling mechanism.
|
|
555
|
+
|
|
556
|
+
**Command definition:**
|
|
557
|
+
|
|
558
|
+
```
|
|
559
|
+
define command {
|
|
560
|
+
command_name notificationforwarder_heartbeat
|
|
561
|
+
command_line $USER1$/notificationforwarder \
|
|
562
|
+
--forwarder webhook \
|
|
563
|
+
--forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
|
|
564
|
+
--formatter myformatter \
|
|
565
|
+
--eventopt HOSTNAME='$HOSTNAME$' \
|
|
566
|
+
--eventopt SERVICEDESC='$SERVICEDESC$' \
|
|
567
|
+
--eventopt NOTIFICATIONTYPE=HEARTBEAT \
|
|
568
|
+
--eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
|
|
569
|
+
>> $USER4$/var/log/notificationforwarder_errors.log 2>&1
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Service definition:**
|
|
574
|
+
|
|
575
|
+
```
|
|
576
|
+
define service {
|
|
577
|
+
host_name monitoring-server
|
|
578
|
+
service_description notificationforwarder-heartbeat
|
|
579
|
+
check_command notificationforwarder_heartbeat
|
|
580
|
+
max_check_attempts 1
|
|
581
|
+
check_interval 5
|
|
582
|
+
retry_interval 1
|
|
583
|
+
notification_interval 0
|
|
584
|
+
notification_options n
|
|
585
|
+
notification_period 24x7
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
The key settings:
|
|
590
|
+
- `check_interval 5` — runs the heartbeat every 5 minutes
|
|
591
|
+
- `notification_interval 0` — no Nagios-level notifications
|
|
592
|
+
- `notification_options n` — don't send notifications on state changes
|
|
593
|
+
|
|
594
|
+
The heartbeat goes through the normal notificationforwarder flow: the formatter sets `is_heartbeat = True`, the flush phase checks for spooled events (and calls `probe()` if available), and the heartbeat itself is submitted to the receiver. If the receiver accepts it, the receiving system knows the monitoring infrastructure is alive (dead-man switch). If the receiver is down, the heartbeat is simply dropped — it won't add to the spool queue.
|
|
595
|
+
|
|
596
|
+
The two use cases for periodic heartbeats:
|
|
597
|
+
- **Dead-man switch**: The receiving system (e.g., ServiceNow) gets a regular "I'm alive" signal. If heartbeats stop, it knows the monitoring system is down.
|
|
598
|
+
- **Spool draining**: Even when nothing breaks, the heartbeat triggers flush attempts at regular intervals, so spooled events are delivered quickly once the receiver recovers — without waiting for the next real notification.
|
|
@@ -78,7 +78,7 @@ Example for an HTTP-based reporter:
|
|
|
78
78
|
help='Logger type: text (default) or json, or custom logger module',
|
|
79
79
|
default='text')
|
|
80
80
|
parser.add_argument('--version', action='version',
|
|
81
|
-
version=f'%(prog)s 4.
|
|
81
|
+
version=f'%(prog)s 4.5')
|
|
82
82
|
|
|
83
83
|
args = parser.parse_args()
|
|
84
84
|
if not hasattr(args, 'formatter'):
|
{omdnotificationforwarder-4.3 → omdnotificationforwarder-4.5}/src/notificationforwarder/baseclass.py
RENAMED
|
@@ -415,6 +415,7 @@ class NotificationForwarder(object):
|
|
|
415
415
|
with open(self.db_lock_file, "w") as lock_file:
|
|
416
416
|
locked = self.acquire_lock_with_retry(lock_file)
|
|
417
417
|
if locked:
|
|
418
|
+
self.baseclass_logs_summary = True
|
|
418
419
|
try:
|
|
419
420
|
dropped = self.spool_store.prune_expired(self.max_spool_minutes)
|
|
420
421
|
replay_attempted = 0
|
|
@@ -426,68 +427,82 @@ class NotificationForwarder(object):
|
|
|
426
427
|
'spooled_count': dropped,
|
|
427
428
|
'action': 'dropped'
|
|
428
429
|
})
|
|
429
|
-
|
|
430
|
+
# walk through the rows which existed when the flush started.
|
|
431
|
+
# rows spooled meanwhile by concurrent processes are left for the
|
|
432
|
+
# next flush and each row is tried at most once, so the flush lock
|
|
433
|
+
# is not held forever while the destination keeps failing.
|
|
434
|
+
last_id = 0
|
|
435
|
+
snapshot_max_id = self.spool_store.max_id()
|
|
430
436
|
while True:
|
|
431
|
-
|
|
432
|
-
if
|
|
433
|
-
logger.info("spooled events to be re-sent", {
|
|
434
|
-
'spooled_count': events_to_flush,
|
|
435
|
-
'action': 'resend'
|
|
436
|
-
})
|
|
437
|
-
else:
|
|
437
|
+
id_events = self.spool_store.fetch_batch(after_id=last_id, max_id=snapshot_max_id)
|
|
438
|
+
if not id_events:
|
|
438
439
|
logger.debug("nothing left to flush", {})
|
|
439
440
|
break
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
441
|
+
logger.info("spooled events to be re-sent", {
|
|
442
|
+
'spooled_count': len(id_events),
|
|
443
|
+
'action': 'resend'
|
|
444
|
+
})
|
|
445
|
+
progress = False
|
|
446
|
+
for id, text in id_events:
|
|
447
|
+
last_id = id
|
|
448
|
+
raw_event = self.spool_store.decode(text)
|
|
449
|
+
formatted_event = self.format_event(raw_event)
|
|
450
|
+
if formatted_event and formatted_event.is_discarded:
|
|
451
|
+
deleted_trash += 1
|
|
452
|
+
logger.info("discard spooled event during replay", {
|
|
453
|
+
'event_id': id,
|
|
454
|
+
'action': 'discard_during_replay'
|
|
445
455
|
})
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
except Exception as e:
|
|
458
|
-
success = False
|
|
459
|
-
error_message = str(e)
|
|
460
|
-
if success:
|
|
461
|
-
self.spool_store.delete(id)
|
|
462
|
-
replayed += 1
|
|
463
|
-
logger.info("delete spooled event", {
|
|
464
|
-
'spooled_count': 1,
|
|
465
|
-
'event_id': id,
|
|
466
|
-
'action': 'delete'
|
|
467
|
-
})
|
|
468
|
-
else:
|
|
469
|
-
stayed_in_spool += 1
|
|
470
|
-
context = {
|
|
471
|
-
'event_id': id,
|
|
472
|
-
'action': 'stays_in_spool'
|
|
473
|
-
}
|
|
474
|
-
if error_message:
|
|
475
|
-
context['exception'] = error_message
|
|
476
|
-
logger.critical("event stays in spool", context)
|
|
477
|
-
else:
|
|
478
|
-
deleted_trash += 1
|
|
479
|
-
logger.critical("could not format spooled event", {
|
|
480
|
-
'raw_event': raw_event,
|
|
481
|
-
'event_id': id,
|
|
482
|
-
'spooled_count': 1,
|
|
483
|
-
'action': 'could_not_format'
|
|
484
|
-
})
|
|
456
|
+
self.spool_store.delete(id)
|
|
457
|
+
progress = True
|
|
458
|
+
elif formatted_event:
|
|
459
|
+
replay_attempted += 1
|
|
460
|
+
try:
|
|
461
|
+
result = self.submit(formatted_event)
|
|
462
|
+
success, _report_payload, error_message = apply_forward_result(result)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
success = False
|
|
465
|
+
error_message = str(e)
|
|
466
|
+
if success:
|
|
485
467
|
self.spool_store.delete(id)
|
|
486
|
-
|
|
468
|
+
progress = True
|
|
469
|
+
replayed += 1
|
|
470
|
+
logger.info("delete spooled event", {
|
|
471
|
+
'spooled_count': 1,
|
|
487
472
|
'event_id': id,
|
|
488
|
-
'action': '
|
|
473
|
+
'action': 'delete'
|
|
489
474
|
})
|
|
490
|
-
|
|
475
|
+
else:
|
|
476
|
+
stayed_in_spool += 1
|
|
477
|
+
context = {
|
|
478
|
+
'event_id': id,
|
|
479
|
+
'action': 'stays_in_spool'
|
|
480
|
+
}
|
|
481
|
+
if error_message:
|
|
482
|
+
context['exception'] = error_message
|
|
483
|
+
logger.critical("event stays in spool", context)
|
|
484
|
+
else:
|
|
485
|
+
deleted_trash += 1
|
|
486
|
+
logger.critical("could not format spooled event", {
|
|
487
|
+
'raw_event': raw_event,
|
|
488
|
+
'event_id': id,
|
|
489
|
+
'spooled_count': 1,
|
|
490
|
+
'action': 'could_not_format'
|
|
491
|
+
})
|
|
492
|
+
self.spool_store.delete(id)
|
|
493
|
+
progress = True
|
|
494
|
+
logger.info("delete trash event", {
|
|
495
|
+
'event_id': id,
|
|
496
|
+
'action': 'delete_trash'
|
|
497
|
+
})
|
|
498
|
+
if not progress:
|
|
499
|
+
# only the rows this flush was responsible for, not
|
|
500
|
+
# the ones spooled concurrently after the snapshot
|
|
501
|
+
logger.critical("spooled events could not be submitted", {
|
|
502
|
+
'spooled_count': self.spool_store.count(max_id=snapshot_max_id),
|
|
503
|
+
'action': 'could_not_submit'
|
|
504
|
+
})
|
|
505
|
+
break
|
|
491
506
|
logger.info("spool replay summary", {
|
|
492
507
|
'attempted': replay_attempted,
|
|
493
508
|
'recovered_count': replayed,
|
|
@@ -5,6 +5,8 @@ from email.mime.text import MIMEText
|
|
|
5
5
|
from email.mime.multipart import MIMEMultipart
|
|
6
6
|
from notificationforwarder.baseclass import NotificationForwarder, NotificationFormatter, timeout
|
|
7
7
|
|
|
8
|
+
logger = logging.getLogger("notificationforwarder.email")
|
|
9
|
+
|
|
8
10
|
class EmailForwarder(NotificationForwarder):
|
|
9
11
|
def __init__(self, opts):
|
|
10
12
|
super(self.__class__, self).__init__(opts)
|
|
@@ -31,6 +33,6 @@ class EmailForwarder(NotificationForwarder):
|
|
|
31
33
|
server.quit()
|
|
32
34
|
return True
|
|
33
35
|
except Exception as e:
|
|
34
|
-
|
|
36
|
+
logger.critical("sending mail failed: {}".format(str(e)))
|
|
35
37
|
return False
|
|
36
38
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import simplejson as json
|
|
3
|
+
except ImportError:
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import fcntl
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpoolStore:
|
|
13
|
+
def __init__(self, db_file, table_name):
|
|
14
|
+
self.db_file = db_file
|
|
15
|
+
self.table_name = table_name
|
|
16
|
+
self.connection = None
|
|
17
|
+
self.cursor = None
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
def open(self):
|
|
21
|
+
self.connection = sqlite3.connect(self.db_file, check_same_thread=False)
|
|
22
|
+
self.cursor = self.connection.cursor()
|
|
23
|
+
|
|
24
|
+
def init_db(self):
|
|
25
|
+
sql_create = """CREATE TABLE IF NOT EXISTS """ + self.table_name + """ (
|
|
26
|
+
id INTEGER PRIMARY KEY,
|
|
27
|
+
payload TEXT NOT NULL,
|
|
28
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
29
|
+
)"""
|
|
30
|
+
with self._lock:
|
|
31
|
+
self.cursor.execute(sql_create)
|
|
32
|
+
self.connection.commit()
|
|
33
|
+
|
|
34
|
+
def count(self, max_id=None):
|
|
35
|
+
sql_count = "SELECT COUNT(*) FROM " + self.table_name
|
|
36
|
+
params = []
|
|
37
|
+
if max_id is not None:
|
|
38
|
+
sql_count += " WHERE id <= ?"
|
|
39
|
+
params.append(max_id)
|
|
40
|
+
with self._lock:
|
|
41
|
+
self.cursor.execute(sql_count, params)
|
|
42
|
+
return self.cursor.fetchone()[0]
|
|
43
|
+
|
|
44
|
+
def enqueue(self, raw_event):
|
|
45
|
+
sql_insert = "INSERT INTO " + self.table_name + "(payload) VALUES (?)"
|
|
46
|
+
with self._lock:
|
|
47
|
+
self.cursor.execute(sql_insert, (json.dumps(raw_event),))
|
|
48
|
+
self.connection.commit()
|
|
49
|
+
|
|
50
|
+
def prune_expired(self, max_spool_minutes):
|
|
51
|
+
outdated = int(time.time() - 60 * max_spool_minutes)
|
|
52
|
+
sql_delete = "DELETE FROM " + self.table_name + " WHERE CAST(STRFTIME('%s', timestamp) AS INTEGER) < ?"
|
|
53
|
+
with self._lock:
|
|
54
|
+
self.cursor.execute(sql_delete, (outdated,))
|
|
55
|
+
dropped = self.cursor.rowcount
|
|
56
|
+
# commit immediately, otherwise the implicit transaction keeps
|
|
57
|
+
# the database write-locked for other processes during a flush
|
|
58
|
+
self.connection.commit()
|
|
59
|
+
return dropped
|
|
60
|
+
|
|
61
|
+
def max_id(self):
|
|
62
|
+
with self._lock:
|
|
63
|
+
self.cursor.execute("SELECT MAX(id) FROM " + self.table_name)
|
|
64
|
+
return self.cursor.fetchone()[0] or 0
|
|
65
|
+
|
|
66
|
+
def fetch_batch(self, limit=10, after_id=0, max_id=None):
|
|
67
|
+
sql_select = "SELECT id, payload FROM " + self.table_name + " WHERE id > ?"
|
|
68
|
+
params = [after_id]
|
|
69
|
+
if max_id is not None:
|
|
70
|
+
sql_select += " AND id <= ?"
|
|
71
|
+
params.append(max_id)
|
|
72
|
+
sql_select += " ORDER BY id LIMIT ?"
|
|
73
|
+
params.append(limit)
|
|
74
|
+
with self._lock:
|
|
75
|
+
self.cursor.execute(sql_select, params)
|
|
76
|
+
return self.cursor.fetchall()
|
|
77
|
+
|
|
78
|
+
def delete(self, event_id):
|
|
79
|
+
with self._lock:
|
|
80
|
+
self.cursor.execute("DELETE FROM " + self.table_name + " WHERE id = ?", (event_id,))
|
|
81
|
+
self.connection.commit()
|
|
82
|
+
|
|
83
|
+
def decode(self, text):
|
|
84
|
+
return json.loads(text)
|
|
85
|
+
|
|
86
|
+
def commit(self):
|
|
87
|
+
with self._lock:
|
|
88
|
+
self.connection.commit()
|
|
89
|
+
|
|
90
|
+
def close(self):
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self.cursor:
|
|
93
|
+
self.cursor.close()
|
|
94
|
+
if self.connection:
|
|
95
|
+
self.connection.commit()
|
|
96
|
+
self.connection.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def acquire_lock_with_retry(lock_file, app_logger, max_attempts=3, base_delay=0.1):
|
|
100
|
+
for attempt in range(max_attempts):
|
|
101
|
+
try:
|
|
102
|
+
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
103
|
+
app_logger.debug("flush lock set", {})
|
|
104
|
+
return True
|
|
105
|
+
except IOError as exc:
|
|
106
|
+
app_logger.debug("flush lock failed", {
|
|
107
|
+
'attempt': attempt + 1,
|
|
108
|
+
'exception': exc,
|
|
109
|
+
})
|
|
110
|
+
if attempt < max_attempts - 1:
|
|
111
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
|
|
112
|
+
time.sleep(delay)
|
|
113
|
+
return False
|