Skip to content

Commit 81202af

Browse files
committed
PLG+DOCS: better scaffolding
1 parent e44b17f commit 81202af

17 files changed

Lines changed: 307 additions & 55 deletions

File tree

‎docs/PLUGINS_DEV.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ Check your plugin against these repo-wide conventions before opening a PR (verif
235235

236236
- **`RUN` defaults to `"disabled"`.** True for the large majority of plugins; only core maintenance plugins (`csv_backup`, `db_cleanup`, `maintenance`, `vendor_update`) default to `schedule`. A new optional plugin should load disabled until the user configures it.
237237
- **Pick `RUN_SCHD` from precedent, not an arbitrary value.** Check the closest existing plugin for its schedule (e.g. `pihole_api_scan` uses `*/5 * * * *`) rather than inventing a new cadence — consistency keeps first-time setup predictable across plugins.
238-
- **`RUN_TIMEOUT` is a subprocess kill-timer, not a per-request budget.** The core plugin runner (`server/plugin.py`) passes this same value as the hard timeout for the *entire* script (`subprocess` `timeout=`). If your script makes multiple sequential network calls (e.g. two upstream instances, or a per-device lookup in a loop), don't also reuse `RUN_TIMEOUT` as each individual call's timeout — one slow call can then consume the whole budget and get the process killed before it writes its result file, silently dropping the entire run.
238+
- **`RUN_TIMEOUT` is a subprocess kill-timer, not a per-request budget.** The core plugin runner (`server/plugin.py`) passes this same value as the hard timeout for the *entire* script (`subprocess` `timeout=`). If your script makes multiple sequential network calls (e.g. two upstream instances, or a per-device lookup in a loop), don't also reuse `RUN_TIMEOUT` as each individual call's timeout — one slow call can then consume the whole budget and get the process killed before it writes its result file, silently dropping the entire run. Two correct alternatives, depending on the shape of your loop:
239+
- **Looping over a config-declared, known-length list** (e.g. a subnets or IPs setting) — mark that `params` entry with `"timeoutMultiplier": true` in `config.json`. The framework then multiplies the *outer* kill-timeout by that list's length before running your script, so each iteration can safely use the full `RUN_TIMEOUT` internally. See `arp_scan/config.json`'s `subnets` param for a working example.
240+
- **Looping over a runtime-variable-length collection** (e.g. a notification queue, where length isn't known until the script runs) — `timeoutMultiplier` doesn't apply here since there's no config-declared count. Instead, divide the *inner* per-call timeout down using `plugin_helper.per_item_timeout(run_timeout, item_count)`, so N sequential calls can't collectively exceed the outer budget. See `server/plugins/_publisher_ntfy/ntfy.py`'s notification loop for a working example.
239241
- **Reuse existing core settings instead of duplicating them.** If NetAlertX already has a concept your plugin needs (e.g. `API_TOKEN` for its own GraphQL/API endpoint), read it with `get_setting_value("API_TOKEN")` rather than adding a plugin-specific `<PREFIX>_API_TOKEN` — see `server/plugins/sync/sync.py` for the pattern.
240242
- **Keep `description` strings short.** They render directly in the Settings UI. Put implementation rationale and design trade-offs in the plugin's README or code comments, not the UI-facing description.
241243
- **For "one or more instances of the same thing," use the nested array + popup-form settings pattern**, not a fixed hardcoded count (e.g. "primary"/"secondary"). See `rest_import` (`RSTIMPRT`)'s `imports` setting for a working example — it also gives each instance its own sub-settings (URL, credentials, per-instance flags) for free.

‎docs/PLUGINS_DEV_DATA_CONTRACT.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ The library automatically:
8585
| 11 | `helpVal3` | string | *conditional* | Helper value 3. If used, all help values must be supplied |
8686
| 12 | `helpVal4` | string | *conditional* | Helper value 4. If used, all help values must be supplied |
8787

88+
> **Gotcha:** `plugin_helper.py`'s `Plugin_Object.__init__` applies `helpVal1-4 or ""` internally, collapsing any falsy value (`0`, `False`, `""`) to `""` - indistinguishable from "not supplied." `watchedValue1-4` are stored as-is with no such coercion. If a real `0` or `False` is a meaningful value you need to preserve (not just "absent"), put it in a `watchedValue*` column instead of a `helpVal*` one.
89+
8890
## Usage Guide
8991

9092
### Empty/Null Values

‎server/plugins/_publisher_ntfy/ntfy.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import conf # noqa: E402 [flake8 lint suppression]
1515
from const import confFileName, logPath # noqa: E402 [flake8 lint suppression]
16-
from plugin_helper import Plugin_Objects, handleEmpty # noqa: E402 [flake8 lint suppression]
16+
from plugin_helper import Plugin_Objects, handleEmpty, per_item_timeout # noqa: E402 [flake8 lint suppression]
1717
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
1818
from logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
1919
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
@@ -55,11 +55,18 @@ def main():
5555
# Retrieve new notifications
5656
new_notifications = notifications.getNew()
5757

58+
# RUN_TIMEOUT is enforced by the core plugin runner as this whole
59+
# script's kill-timeout, not a safe per-request timeout - divide it
60+
# across the queue so a burst of notifications can't let one slow send()
61+
# call consume the whole budget and get the process killed mid-loop.
62+
run_timeout = int(get_setting_value('NTFY_RUN_TIMEOUT') or 10)
63+
per_call_timeout = per_item_timeout(run_timeout, len(new_notifications))
64+
5865
# Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint)
5966
for notification in new_notifications:
6067

6168
# Send notification
62-
response_text, response_status_code = send(notification["HTML"], notification["Text"])
69+
response_text, response_status_code = send(notification["HTML"], notification["Text"], per_call_timeout)
6370

6471
# Log result
6572
plugin_objects.add_object(
@@ -131,11 +138,14 @@ def build_custom_headers(entries, reserved_headers):
131138

132139

133140
# -------------------------------------------------------------------------------
134-
def send(html, text):
141+
def send(html, text, timeout=None):
135142

136143
response_text = ''
137144
response_status_code = ''
138145

146+
if timeout is None:
147+
timeout = int(get_setting_value('NTFY_RUN_TIMEOUT') or 10)
148+
139149
# settings
140150
token = get_setting_value('NTFY_TOKEN')
141151
user = get_setting_value('NTFY_USER')
@@ -178,7 +188,7 @@ def send(html, text):
178188
headers = headers,
179189
params = url_query_string if url_query_string != '' else None,
180190
verify = verify_ssl,
181-
timeout = get_setting_value('NTFY_RUN_TIMEOUT')
191+
timeout = timeout
182192
)
183193

184194
response_status_code = response.status_code

‎server/plugins/_publisher_pushsafer/pushsafer.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import conf # noqa: E402 [flake8 lint suppression]
1212
from const import confFileName, logPath # noqa: E402 [flake8 lint suppression]
13-
from plugin_helper import Plugin_Objects, handleEmpty # noqa: E402 [flake8 lint suppression]
13+
from plugin_helper import Plugin_Objects, handleEmpty, per_item_timeout # noqa: E402 [flake8 lint suppression]
1414
from logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
1515
from helper import get_setting_value, hide_string # noqa: E402 [flake8 lint suppression]
1616
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
@@ -52,11 +52,18 @@ def main():
5252
# Retrieve new notifications
5353
new_notifications = notifications.getNew()
5454

55+
# RUN_TIMEOUT is enforced by the core plugin runner as this whole
56+
# script's kill-timeout, not a safe per-request timeout - divide it
57+
# across the queue so a burst of notifications can't let one slow send()
58+
# call consume the whole budget and get the process killed mid-loop.
59+
run_timeout = int(get_setting_value("PUSHSAFER_RUN_TIMEOUT") or 10)
60+
per_call_timeout = per_item_timeout(run_timeout, len(new_notifications))
61+
5562
# Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint)
5663
for notification in new_notifications:
5764

5865
# Send notification
59-
response_text, response_status_code = send(notification["Text"])
66+
response_text, response_status_code = send(notification["Text"], per_call_timeout)
6067

6168
# Log result
6269
plugin_objects.add_object(
@@ -74,13 +81,16 @@ def main():
7481

7582

7683
# -------------------------------------------------------------------------------
77-
def send(text):
84+
def send(text, timeout=None):
7885

7986
response_text = ''
8087
response_status_code = ''
8188

8289
token = get_setting_value('PUSHSAFER_TOKEN')
8390

91+
if timeout is None:
92+
timeout = int(get_setting_value("PUSHSAFER_RUN_TIMEOUT") or 10)
93+
8494
mylog('verbose', [f'[{pluginName}] PUSHSAFER_TOKEN: "{hide_string(token)}"'])
8595

8696
try:
@@ -97,7 +107,7 @@ def send(text):
97107
"ut" : 'Open NetAlertX',
98108
"k" : token,
99109
}
100-
response = requests.post(url, data=post_fields, timeout=get_setting_value("PUSHSAFER_RUN_TIMEOUT"))
110+
response = requests.post(url, data=post_fields, timeout=timeout)
101111
response_status_code = response.status_code
102112

103113
# Check if the request was successful (status code 200)

‎server/plugins/_publisher_telegram/tg.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import conf # noqa: E402 [flake8 lint suppression]
1313
from const import confFileName, logPath # noqa: E402 [flake8 lint suppression]
14-
from plugin_helper import Plugin_Objects # noqa: E402 [flake8 lint suppression]
14+
from plugin_helper import Plugin_Objects, per_item_timeout # noqa: E402 [flake8 lint suppression]
1515
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
1616
from logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
1717
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
@@ -53,10 +53,17 @@ def main():
5353
# Retrieve new notifications
5454
new_notifications = notifications.getNew()
5555

56+
# RUN_TIMEOUT is enforced by the core plugin runner as this whole
57+
# script's kill-timeout, not a safe per-request timeout - divide it
58+
# across the queue so a burst of notifications can't let one slow send()
59+
# call consume the whole budget and get the process killed mid-loop.
60+
run_timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT'))
61+
per_call_timeout = per_item_timeout(run_timeout, len(new_notifications))
62+
5663
# Process the new notifications (see the Notifications DB table for structure or check the /php/server/query_json.php?file=table_notifications.json endpoint)
5764
for notification in new_notifications:
5865
# Send notification
59-
result = send(notification["Text"])
66+
result = send(notification["Text"], per_call_timeout)
6067

6168
# Log result
6269
plugin_objects.add_object(
@@ -79,13 +86,14 @@ def check_config():
7986

8087

8188
# -------------------------------------------------------------------------------
82-
def send(text):
89+
def send(text, timeout=None):
8390
"""
8491
Send a Telegram notification.
8592
"""
8693
limit = get_setting_value('TELEGRAM_SIZE')
87-
run_timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT'))
88-
curl_timeout = str(max(1, run_timeout - 1))
94+
if timeout is None:
95+
timeout = int(get_setting_value('TELEGRAM_RUN_TIMEOUT'))
96+
curl_timeout = str(max(1, timeout - 1))
8997

9098
# Ensure the final payload, including the truncation marker,
9199
# never exceeds TELEGRAM_SIZE.

‎server/plugins/adguard_export/script.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ def main():
374374
# Read settings
375375
# ------------------------------------------------------------------
376376
agrd_url = get_setting_value("ADGUARDEXP_URL") or "http://localhost:3000"
377-
agrd_user = get_setting_value("ADGUARDEXP_USER") or ""
377+
agrd_user = get_setting_value("ADGUARDEXP_USER") or "admin"
378378
agrd_pass = get_setting_value("ADGUARDEXP_PASSWORD") or ""
379379
verify_ssl_str = get_setting_value("ADGUARDEXP_VERIFYSSL") or "true"
380380
include_offline_str = get_setting_value("ADGUARDEXP_INCLUDE_OFFLINE") or "true"

‎server/plugins/adguard_import/adguard_import.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def main():
6666
user = get_setting_value("ADGUARDIMP_USER")
6767
pw = get_setting_value("ADGUARDIMP_PASS")
6868
fake_mac_enabled = get_setting_value("ADGUARDIMP_FAKE_MAC")
69-
timeout = int(get_setting_value("ADGUARDIMP_RUN_TIMEOUT") or 5)
69+
timeout = int(get_setting_value("ADGUARDIMP_RUN_TIMEOUT") or 30)
7070

7171
auth = (user, pw) if user or pw else None
7272

‎server/plugins/nbtscan_scan/nbtscan.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@
3636
def main():
3737
mylog('verbose', [f'[{pluginName}] In script'])
3838

39-
# timeout = get_setting_value('NBLOOKUP_RUN_TIMEOUT')
40-
timeout = 20
39+
# The "ips" param in config.json is marked timeoutMultiplier: true, so the
40+
# framework already scales the outer subprocess kill-timeout by device
41+
# count - use the real per-device budget here instead of a hardcoded
42+
# value that could exceed what the multiplier actually grants.
43+
timeout = int(get_setting_value('NBTSCAN_RUN_TIMEOUT') or 10)
4144

4245
# Initialize the Plugin obj output file
4346
plugin_objects = Plugin_Objects(RESULT_FILE)

‎server/plugins/nmap_dev_scan/config.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"name": "subnets",
4545
"type": "setting",
4646
"value": "SCAN_SUBNETS",
47-
"base64": true
47+
"base64": true,
48+
"timeoutMultiplier": true
4849
}
4950
],
5051
"settings": [

‎server/plugins/plugin_helper.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,28 @@ def normalize_mac(mac):
264264
return ':'.join(normalized_parts)
265265

266266

267+
# -------------------------------------------------------------------
268+
def per_item_timeout(run_timeout, item_count, floor=1):
269+
"""
270+
Divide a RUN_TIMEOUT budget evenly across `item_count` sequential
271+
operations (e.g. one HTTP call per queued notification) so no single
272+
item can consume the whole script's kill-timeout - the core plugin
273+
runner (server/plugin.py) enforces RUN_TIMEOUT as the entire
274+
subprocess's hard timeout, not a per-call one.
275+
276+
Returns run_timeout unchanged when there's 0 or 1 items, so the common
277+
single-item case sees no behavior change. For a config-declared,
278+
known-length list (e.g. a subnets/IPs setting), prefer the config.json
279+
"timeoutMultiplier" mechanism instead - it scales the outer timeout up
280+
rather than dividing the inner one down. Use this helper for
281+
runtime-variable-length loops (e.g. a notification queue) where
282+
timeoutMultiplier doesn't apply.
283+
"""
284+
if item_count <= 1:
285+
return run_timeout
286+
return max(floor, run_timeout // item_count)
287+
288+
267289
# -------------------------------------------------------------------
268290
class Plugin_Object:
269291
"""

0 commit comments

Comments
 (0)