Compare commits

..

57 Commits

Author SHA1 Message Date
Jokob @NetAlertX
a95aaa48f8 Merge pull request #1653 from netalertx/next_release
Refactor sync data processing to handle PUSH and PULL modes with impr…
2026-05-24 12:22:32 +10:00
Jokob @NetAlertX
b5d280644e Update code standards to prohibit inline imports and ensure all imports are at the top of the file 2026-05-24 02:21:56 +00:00
Jokob @NetAlertX
b7cffe8c07 Refactor sync data processing to handle PUSH and PULL modes with improved error handling for JSON payloads 2026-05-24 02:14:11 +00:00
Jokob @NetAlertX
0387d74e9e Merge pull request #1634 from netalertx/next_release
Next release
2026-05-24 11:55:15 +10:00
Jokob @NetAlertX
1108534540 Fix formatting inconsistencies in comments for sync endpoint tests 2026-05-24 01:30:33 +00:00
Jokob @NetAlertX
e9879c47a9 Update sync POST test to use encrypted payload string 2026-05-24 01:20:21 +00:00
Jokob @NetAlertX
5910a7c2a1 BE: Refactor sync endpoint to accept JSON payloads and update related tests #1652 2026-05-24 01:11:00 +00:00
Jokob @NetAlertX
0cb7ad6332 BE:Fix push mode for plugins #1652 2026-05-24 00:36:23 +00:00
Jokob @NetAlertX
87a89f3a28 Add active_labels parameter to resolve_devices for selective strategy execution 2026-05-22 23:00:25 +00:00
Jokob @NetAlertX
88231d97c8 Enhance documentation and implement SET_ALWAYS functionality for device name resolution #1650 2026-05-22 22:50:33 +00:00
Jokob @NetAlertX
821594f617 Refine documentation and enhance XSS prevention measures in device details and test scripts 2026-05-21 00:36:59 +00:00
Jokob @NetAlertX
5f62d25e23 Implement XSS prevention by encoding special characters in device names across multiple files 2026-05-21 00:09:07 +00:00
Jokob @NetAlertX
6580c4a953 Merge branch 'next_release' of https://github.com/netalertx/NetAlertX into next_release 2026-05-18 02:45:51 +00:00
Jokob @NetAlertX
bc87e39843 Enhance documentation and improve formatting across multiple files 2026-05-18 02:45:23 +00:00
Jokob @NetAlertX
9eaaf50caf Merge pull request #1647 from npt-1707/fix_CVE-2022-24785
Fix potentially vulnerable cloned function
2026-05-18 12:18:45 +10:00
Jokob @NetAlertX
f0684c66c2 Merge pull request #1646 from npt-1707/fix_CVE-2023-22467
Fix potentially vulnerable cloned function
2026-05-18 12:16:43 +10:00
Jokob @NetAlertX
328e591fcd Merge pull request #1645 from npt-1707/fix_CVE-2016-10735
Fix potentially vulnerable cloned function
2026-05-18 12:15:14 +10:00
npt-1707
80c8a66396 front/lib/datatables/datatables.js: Fix XSS in Alert, Carousel, Collapse, Dropdown and Modal 2026-05-18 06:19:59 +08:00
npt-1707
0517da2405 front/lib/moment/moment.js: fix redos using local backtracking regex 2026-05-18 06:17:10 +08:00
npt-1707
35dc9f9fa0 front/lib/moment/moment.js: Avoid loading path-looking locales from fs 2026-05-18 06:15:43 +08:00
jokob-sk
af14dea40c Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-16 14:21:50 +10:00
jokob-sk
292223e062 BE+FE: timestamps for sessions and emails corrected #1639 2026-05-16 14:21:39 +10:00
jokob-sk
47a559197f Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-16 14:14:09 +10:00
jokob-sk
bf4e0b4a7c BE+FE: timestamps for sessions and emails corrected #1639 2026-05-16 14:13:59 +10:00
jokob-sk
92adaddabb Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-16 10:07:49 +10:00
jokob-sk
7725697b87 BE: ICMP fping with multiple interfaces #1642 2026-05-16 10:07:37 +10:00
jokob-sk
09744f3acd Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-16 09:42:55 +10:00
jokob-sk
f6b34845e0 BE: ICMP fping with multiple interfaces #1642 2026-05-16 09:42:44 +10:00
Safeguard
198ca5d410 Translated using Weblate (Russian)
Currently translated at 100.0% (809 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/
2026-05-15 12:20:33 +02:00
Jokob @NetAlertX
4761a688fc Merge pull request #1643 from netalertx/main
Translated using Weblate (Portuguese (Portugal))
2026-05-15 06:40:15 +10:00
jokob-sk
6e149f8ad3 Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-15 06:37:58 +10:00
jokob-sk
4cac81b5ec PLG: ICMP fping fix for multiple interfaces #1642 2026-05-15 06:37:47 +10:00
António Oliveira
03938d8c28 Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (809 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_PT/
2026-05-14 16:11:33 +00:00
jokob-sk
0127194816 Merge branch 'next_release' of github.com:netalertx/NetAlertX into next_release 2026-05-14 20:36:43 +10:00
jokob-sk
13f8858319 BE: Less verbose SYNC plugin #1164 2026-05-14 20:36:23 +10:00
jokob-sk
f11a63bb7f BE: DIGSCAN could not be disbaled due to default RUN set in config.json #1631 2026-05-13 08:44:29 +10:00
Jokob @NetAlertX
556d104bbb Merge pull request #1638 from netalertx/main
sync
2026-05-12 21:39:10 +00:00
Jokob @NetAlertX
eafbcd52f8 Merge pull request #1636 from void-spark/main
First attempt at kea dhcp support
2026-05-12 21:22:28 +00:00
anton garcias
2fac875792 Translated using Weblate (Catalan)
Currently translated at 99.7% (807 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ca/
2026-05-12 18:11:32 +02:00
void-spark
6d661dd12c I did add a ';' from habit, removed again, must please the bunny :) 2026-05-11 22:29:37 +02:00
void-spark
39068e9824 And 'notes' to README.md 2026-05-11 22:21:10 +02:00
void-spark
957c779cb5 Add KEALSS to plugin list 2026-05-11 22:16:51 +02:00
void-spark
76612e5d0e Change default timeout to 10 seconds,
match request timeout to script timeout -1 second.
Always use at least 1 sec. timeout for request.
2026-05-11 22:09:07 +02:00
void-spark
7273899e3e Use single quote consistently
Use 'l' instead of 'lease' (I see you little rabbit)
Use [] over get in most places, if expected fields are missing an error is an acceptable outcome.
Include 'text' field in logging.
Add logging for result '3'
2026-05-11 22:03:29 +02:00
void-spark
608686e4bd Add list with settings to readme 2026-05-11 21:51:32 +02:00
void-spark
b290d3c3d2 First attempt at kea dhcp support 2026-05-10 18:15:05 +02:00
Jokob @NetAlertX
781ae2f91c Merge pull request #1635 from Neutronlul/patch-1
Fix typo in docker installation documentation
2026-05-09 23:57:06 +00:00
Jokob @NetAlertX
f428f45ad2 Update README to include Domotz as a network monitoring option and refine device management logic in deviceDetails.php 2026-05-09 23:53:06 +00:00
Tanner Snow
b7ebc8206f Fix typo in docker installation documentation 2026-05-09 16:47:32 -07:00
Jokob @NetAlertX
9575692a39 Fix unsupported object format handling in UpdateFieldAction and DeleteObjectAction 2026-05-09 23:39:47 +00:00
Jokob @NetAlertX
1def218db5 Update README and enhance device management logic
- Updated Trendshift repository link in README.md.
- Improved error handling and user feedback in deviceDetails.php:
  - Added warnings for device not found scenarios.
  - Refactored device name and owner retrieval logic with caching and REST API fallback.
2026-05-09 23:35:21 +00:00
Jokob @NetAlertX
300820e6bd Merge pull request #1633 from netalertx/main
sync
2026-05-09 22:48:12 +00:00
jokob-sk
8f7f7eaed7 BE: Trigger failing if object non-existent + emoji removal in upgrade message
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
2026-05-10 08:45:52 +10:00
António Oliveira
190262a730 Translated using Weblate (Portuguese (Portugal))
Currently translated at 96.2% (779 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_PT/
2026-05-08 15:11:37 +02:00
大王叫我来巡山
5ba202c6a1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 97.2% (787 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/
2026-05-08 15:11:32 +02:00
IntEx
e262f915e2 Translated using Weblate (Czech)
Currently translated at 57.8% (468 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/cs/
2026-05-07 03:11:35 +02:00
António Oliveira
b8c4e62ad5 Translated using Weblate (Portuguese (Portugal))
Currently translated at 85.2% (690 of 809 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_PT/
2026-05-07 03:11:33 +02:00
57 changed files with 2074 additions and 506 deletions

View File

@@ -8,12 +8,23 @@ description: NetAlertX coding standards and conventions. Use this when writing c
- ask me to review before going to each next step (mention n step out of x) (AI only) - ask me to review before going to each next step (mention n step out of x) (AI only)
- before starting, prepare implementation plan (AI only) - before starting, prepare implementation plan (AI only)
- ask me to review it and ask any clarifying questions first - ask me to review it and ask any clarifying questions first
- add test creation as last step - follow repo architecture patterns - do not place in the root of /test - add test creation as last step - follow repo architecture patterns - do not place in the root of `/test`
- code has to be maintainable, no duplicate code - code has to be maintainable, no duplicate code
- follow DRY principle - maintainability of code is more important than speed of implementation - follow DRY principle - maintainability of code is more important than speed of implementation
- code files should be less than 500 LOC for better maintainability - code files should be less than 500 LOC for better maintainability
- DB columns must not contain underscores, use camelCase instead (e.g., deviceInstanceId, not device_instance_id) - DB columns must not contain underscores, use camelCase instead (e.g., deviceInstanceId, not device_instance_id)
- treat DB as temporary storage for stats, long term configuration should be stored in the /config folder, the /config folder should allow you to restore most of your functionality (excluding historical data) - treat DB as temporary storage for stats, long-term configuration should be stored in the `/config` folder, the `/config` folder should allow you to restore most of your functionality (excluding historical data)
- never access DB directly from application layers, always use helper functions in `server/db/db_helper.py` and implement new functionality in handlers (e.g., `DeviceInstance` in `server/models/device_instance.py`)
- always validate and normalize MAC addresses before writing to DB (use `normalize_mac` from `plugin_helper.py`)
- all subprocess calls must set explicit timeouts
- use `timeNowUTC` from `utils.datetime_utils` for all time-related operations and DB timestamps (store all timestamps in UTC)
- use sanitizers from `server/helper.py` for user input before storing in DB
- reuse shared mocks and factories from `test/db_test_helpers.py` for tests, never redefine them locally
- use environment variables for runtime paths, never hardcode paths or use relative paths
- follow existing code style and structure, and ensure backward compatibility with existing installations when submitting PRs
- all code needs to be scalable to handle large networks with thousands of devices (10k+) without performance degradation
- no inline imports, all imports must be at the top of the file
## File Length ## File Length

View File

@@ -157,7 +157,7 @@ Check the [GitHub Issues](https://github.com/netalertx/NetAlertX/issues) for the
## Everything else ## Everything else
<!--- --------------------------------------------------------------------- ---> <!--- --------------------------------------------------------------------- --->
<a href="https://trendshift.io/repositories/12670" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12670" alt="jokob-sk%2FNetAlertX | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/19712" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19712" alt="jokob-sk%2FNetAlertX | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
### 📧 Get notified what's new ### 📧 Get notified what's new
@@ -170,6 +170,7 @@ Get notified about a new release, what new functionality you can use and about b
- [Fing](https://www.fing.com/) - Network scanner app for your Internet security (Commercial, Phone App, Proprietary hardware) - [Fing](https://www.fing.com/) - Network scanner app for your Internet security (Commercial, Phone App, Proprietary hardware)
- [NetBox](https://netboxlabs.com/) - The gold standard for Network Source of Truth (NSoT) and IPAM. - [NetBox](https://netboxlabs.com/) - The gold standard for Network Source of Truth (NSoT) and IPAM.
- [Zabbix](https://www.zabbix.com/) or [Nagios](https://www.nagios.org/) - Strong focus on infrastructure monitoring. - [Zabbix](https://www.zabbix.com/) or [Nagios](https://www.nagios.org/) - Strong focus on infrastructure monitoring.
- [Domotz](https://www.domotz.com/) - Commercial network monitoring and remote management platform aimed at MSPs, IT teams, and multi-site environments.
- [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused choice for real-time asset intelligence and noise-free alerting. - [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused choice for real-time asset intelligence and noise-free alerting.
### 💙 Donations ### 💙 Donations

View File

@@ -30,6 +30,7 @@ REPORT_DASHBOARD_URL='update_REPORT_DASHBOARD_URL_setting'
INTRNT_RUN='schedule' INTRNT_RUN='schedule'
ARPSCAN_RUN='schedule' ARPSCAN_RUN='schedule'
NSLOOKUP_RUN='before_name_updates' NSLOOKUP_RUN='before_name_updates'
DIGSCAN_RUN='before_name_updates'
AVAHISCAN_RUN='before_name_updates' AVAHISCAN_RUN='before_name_updates'
NBTSCAN_RUN='before_name_updates' NBTSCAN_RUN='before_name_updates'

View File

@@ -4,6 +4,9 @@ For Managed Service Providers (MSPs) and Network Operations Centers (NOC), "Eyes
![filters](./img/ADVISORIES/filters.png) ![filters](./img/ADVISORIES/filters.png)
> [!TIP]
> If you are using Grafana check the `/metrics` endpoint that exposes **Prometheus-compatible metrics** for NetAlertX, including aggregate device counts and per-device status. See the [Metrics API endpoint](./API_METRICS.md) documentation for details.
--- ---
### 1. Configure Auto-Refresh for Live Monitoring ### 1. Configure Auto-Refresh for Live Monitoring

View File

@@ -12,20 +12,25 @@ Effective multi-network monitoring starts with understanding how NetAlertX "sees
* **Manual Entry:** For static assets where only ICMP (ping) status is needed. * **Manual Entry:** For static assets where only ICMP (ping) status is needed.
> [!TIP] > [!TIP]
> Explore the [remote networks](./REMOTE_NETWORKS.md) documentation for more details on how to set up the approaches menationed above. > Explore the [remote networks](./REMOTE_NETWORKS.md) documentation for more details on how to set up the approaches mentioned above.
--- ---
### 2. Automating IT Asset Inventory with Workflows ### 2. Automating IT Asset Inventory with Workflows
[Workflows](./WORKFLOWS.md) are the "engine" of NetAlertX, reducing manual overhead as your device list grows. [Workflows](./WORKFLOWS.md) are the "engine" of NetAlertX, reducing manual overhead as your device list grows. See some examples below:
#### A. Logical Ownership & VLAN Tagging
Create a workflow triggered on **Device Creation** to:
* **A. Logical Ownership & VLAN Tagging:** Create a workflow triggered on **Device Creation** to:
1. Inspect the IP/Subnet. 1. Inspect the IP/Subnet.
2. Set `devVlan` or `devOwner` custom fields automatically. 2. Set `devVlan` or `devOwner` custom fields automatically.
#### B. Auto-Grouping:
Use conditional logic to categorize devices.
* **B. Auto-Grouping:** Use conditional logic to categorize devices.
* *Example:* If `devLastIP == 10.10.20.*`, then `Set devLocation = "BranchOffice"`. * *Example:* If `devLastIP == 10.10.20.*`, then `Set devLocation = "BranchOffice"`.
```json ```json
@@ -56,9 +61,9 @@ Effective multi-network monitoring starts with understanding how NetAlertX "sees
] ]
} }
``` ```
#### C. Sync Node Tracking
When using multiple instances, ensure all sync hub nodes have a descriptive `SYNC_node_name` name to distinguish between sites.
* **C. Sync Node Tracking:** When using multiple instances, ensure all synchub nodes have a descriptive `SYNC_node_name` name to distinguish between sites.
> [!TIP] > [!TIP]
> Always test new workflows in a "Staging" instance. A misconfigured workflow can trigger thousands of unintended updates across your database. > Always test new workflows in a "Staging" instance. A misconfigured workflow can trigger thousands of unintended updates across your database.
@@ -107,6 +112,7 @@ As your environment grows, tuning the underlying engine is vital to maintain a s
* **Plugin Scheduling:** Avoid "Scan Storms" by staggering plugin execution. Running intensive tasks like `NMAP` or `MASS_DNS` simultaneously can spike CPU and cause database locks. * **Plugin Scheduling:** Avoid "Scan Storms" by staggering plugin execution. Running intensive tasks like `NMAP` or `MASS_DNS` simultaneously can spike CPU and cause database locks.
* **Database Health:** Large-scale monitoring generates massive event logs. Use the **[DBCLNP (Database Cleanup)](https://www.google.com/search?q=https://docs.netalertx.com/PLUGINS/%23dbclnp)** plugin to prune old records and keep the SQLite database performant. * **Database Health:** Large-scale monitoring generates massive event logs. Use the **[DBCLNP (Database Cleanup)](https://www.google.com/search?q=https://docs.netalertx.com/PLUGINS/%23dbclnp)** plugin to prune old records and keep the SQLite database performant.
* **Resource Management:** For high-device counts, consider increasing the memory limit for the container and utilizing `tmpfs` for temporary files to reduce SD card/disk I/O bottlenecks. * **Resource Management:** For high-device counts, consider increasing the memory limit for the container and utilizing `tmpfs` for temporary files to reduce SD card/disk I/O bottlenecks.
* Enable the `DEEP_SLEEP` setting.
> [!IMPORTANT] > [!IMPORTANT]
> For a deep dive into hardware requirements, database vacuuming, and specific environment variables for high-load instances, refer to the full **[Performance Optimization Guide](https://docs.netalertx.com/PERFORMANCE/)**. > For a deep dive into hardware requirements, database vacuuming, and specific environment variables for high-load instances, refer to the full **[Performance Optimization Guide](https://docs.netalertx.com/PERFORMANCE/)**.

View File

@@ -1,5 +1,7 @@
# Quick Reference Guide - Device Field Lock/Unlock System # Quick Reference Guide - Device Field Lock/Unlock System
> For how scan overwrite rules (`SET_ALWAYS`, `SET_EMPTY`) and source tracking work under the hood, see [Device Source Fields](./DEVICE_SOURCE_FIELDS.md).
## Overview ## Overview
![Field source and locks](./img/DEVICE_MANAGEMENT/field_sources_and_locks.png) ![Field source and locks](./img/DEVICE_MANAGEMENT/field_sources_and_locks.png)

View File

@@ -1,5 +1,7 @@
# Understanding Device Source Fields and Field Updates # Understanding Device Source Fields and Field Updates
> For the UI guide on locking and unlocking individual fields, see [Device Field Lock/Unlock](./DEVICE_FIELD_LOCK.md).
When the system scans a network, it finds various details about devices (like names, IP addresses, and manufacturers). To ensure the data remains accurate without accidentally overwriting manual changes, the system uses a set of "Source Rules." When the system scans a network, it finds various details about devices (like names, IP addresses, and manufacturers). To ensure the data remains accurate without accidentally overwriting manual changes, the system uses a set of "Source Rules."
![Field source and locks](./img/DEVICE_MANAGEMENT/field_sources_and_locks.png) ![Field source and locks](./img/DEVICE_MANAGEMENT/field_sources_and_locks.png)
@@ -17,6 +19,8 @@ Every piece of information for a device has a **Source**. This source determines
| **NEWDEV** | This value was initialized from `NEWDEV` plugin settings. | **Always** | | **NEWDEV** | This value was initialized from `NEWDEV` plugin settings. | **Always** |
| **(Plugin Name)** | The value was found by a specific scanner (e.g., `NBTSCAN`). | **Only if specific rules are met** | | **(Plugin Name)** | The value was found by a specific scanner (e.g., `NBTSCAN`). | **Only if specific rules are met** |
> For how `USER` and `LOCKED` sources are set through the UI (lock/unlock buttons), see [Device Field Lock/Unlock](./DEVICE_FIELD_LOCK.md).
--- ---
## How Scans Update Information ## How Scans Update Information
@@ -34,6 +38,8 @@ Some plugins are configured to be "authoritative." If a field is in the **SET_AL
* The scanner will **always** overwrite the current value with the new one. * The scanner will **always** overwrite the current value with the new one.
* *Note: It will still never overwrite a `USER` or `LOCKED` field.* * *Note: It will still never overwrite a `USER` or `LOCKED` field.*
> On large networks, enabling `SET_ALWAYS` on name-resolution fields (e.g., `devName`) widens the resolution scope and increases DNS query volume. See [Performance — Plugin Field Authority](./PERFORMANCE.md#plugin-field-authority-set_always-and-set_empty) for details.
### 3. SET_EMPTY ### 3. SET_EMPTY
If a field is in the **SET_EMPTY** list: If a field is in the **SET_EMPTY** list:

View File

@@ -36,7 +36,7 @@ docker run -d --rm --network=host \
> Runtime UID/GID: The image defaults to a service user `netalertx` (UID/GID 20211). A separate readonly lock owner also uses UID/GID 20211 for 004/005 immutability. You can override the runtime UID/GID at build (ARG) or run (`--user` / compose `user:`) but must align writable mounts (`/data`, `/tmp*`) and tmpfs `uid/gid` to that choice. > Runtime UID/GID: The image defaults to a service user `netalertx` (UID/GID 20211). A separate readonly lock owner also uses UID/GID 20211 for 004/005 immutability. You can override the runtime UID/GID at build (ARG) or run (`--user` / compose `user:`) but must align writable mounts (`/data`, `/tmp*`) and tmpfs `uid/gid` to that choice.
See alternative [docked-compose examples](https://docs.netalertx.com/DOCKER_COMPOSE). See alternative [docker-compose examples](https://docs.netalertx.com/DOCKER_COMPOSE).
### Default ports ### Default ports

View File

@@ -95,6 +95,41 @@ For example, the **ICMP plugin** allows scanning only IPs that match a specific
--- ---
## Plugin Field Authority: `SET_ALWAYS` and `SET_EMPTY`
Plugins can be configured to control how aggressively they overwrite existing device field values via two settings:
| Setting | Behaviour |
|---|---|
| `<PLUGIN>_SET_ALWAYS` | Plugin always overwrites the field, as long as it can resolve a value and the field is not `USER`/`LOCKED` |
| `<PLUGIN>_SET_EMPTY` | Plugin only writes when the field is currently empty |
Both settings accept a list of field names (e.g., `devName`, `devFQDN`). See [Name resolution](./NAME_RESOLUTION.md) and [Field locking](./DEVICE_SOURCE_FIELDS.md) docs for details.
### Performance Impact of `SET_ALWAYS` on Name Resolution
By default, name resolution (DIGSCAN, NBTSCAN, NSLOOKUP, AVAHISCAN) only runs against **devices that have no name yet**. This keeps DNS query volume proportional to new devices discovered, not the total inventory.
When **any** name-resolution plugin has `devName` in its `SET_ALWAYS` list, the system additionally runs a second resolution pass against **all devices whose name is not `USER`/`LOCKED` protected**. This allows a higher-priority plugin (e.g., DIGSCAN) to overwrite names previously set by a lower-priority one (e.g., NBTSCAN).
**Cost:** one DNS query per unprotected, already-named device per name-resolution cycle.
| Scenario | Devices resolved per cycle |
|---|---|
| No `SET_ALWAYS` on `devName` | Only new/unknown devices |
| `SET_ALWAYS: devName` on any plugin | New/unknown devices **+** all unprotected named devices |
> [!WARNING]
> On large installations (thousands of devices), enabling `SET_ALWAYS: devName` significantly increases DNS query volume and cycle duration. To mitigate:
>
> * Increase the scan interval of name-resolution plugins (`DIGSCAN_RUN_SCHD`, `NBTSCAN_RUN_SCHD`, etc.).
> * Mark devices whose name should never change as `USER` or `LOCKED` — they are excluded from the re-resolve pass entirely.
> * Use `SET_ALWAYS` only on the highest-priority plugin; leave lower-priority plugins without it.
The actual number of DB rows updated is logged at `verbose` level under `[Update Device Name] SET_ALWAYS re-resolve - DB rows updated`.
---
## Storing Temporary Files in Memory ## Storing Temporary Files in Memory
On devices with slower I/O, you can improve performance by storing temporary files (and optionally the database) in memory using `tmpfs`. On devices with slower I/O, you can improve performance by storing temporary files (and optionally the database) in memory using `tmpfs`.

View File

@@ -62,6 +62,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
| `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | | | `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | |
| `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | | | `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | |
| `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | | | `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | |
| `KEALSS` | [kea_api](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/kea_api/) | 🔍/🆎 | Pull lease data from the Kea DHCP API | | |
| `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | | | `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | |
| `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | | | `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | |
| `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | | | `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | |

View File

@@ -7,6 +7,7 @@ Configure how your plugin's data is displayed in the NetAlertX web interface.
Plugin results are displayed in the UI via the **Plugins page** and **Device details tabs**. You control the appearance and functionality of these displays by defining `database_column_definitions` in your plugin's `config.json`. Plugin results are displayed in the UI via the **Plugins page** and **Device details tabs**. You control the appearance and functionality of these displays by defining `database_column_definitions` in your plugin's `config.json`.
Each column definition specifies: Each column definition specifies:
- Which data field to display - Which data field to display
- How to render it (label, link, color-coded badge, etc.) - How to render it (label, link, color-coded badge, etc.)
- What CSS classes to apply - What CSS classes to apply
@@ -275,6 +276,7 @@ Replaces specific values with display strings or HTML.
``` ```
**Output Examples:** **Output Examples:**
- `"online"` → 🟢 Online - `"online"` → 🟢 Online
- `"offline"` → 🔴 Offline - `"offline"` → 🔴 Offline
- `"idle"` → 🟡 Idle - `"idle"` → 🟡 Idle

View File

@@ -45,23 +45,23 @@ Sometimes devices are manually archived (e.g., no longer expected on the network
], ],
"enabled": "Yes" "enabled": "Yes"
} }
``` ````
### 🔍 Explanation ### 🔍 Explanation
- Trigger: Listens for updates to device records. * **Trigger**: Listens for updates to device records.
- Conditions: * **Conditions**:
- `devIsArchived` is `1` (archived).
- `devPresentLastScan` is `1` (device was detected in the latest scan). * `devIsArchived` is `1` (archived).
- Action: Updates the device to set `devIsArchived` to `0` (unarchived). * `devPresentLastScan` is `1` (device was detected in the latest scan).
* **Action**:
* Updates the device to set `devIsArchived` to `0` (unarchived).
### Result ### Result
Whenever a previously archived device shows up during a network scan, it will be automatically unarchived allowing it to reappear in your device lists and dashboards. Whenever a previously archived device shows up during a network scan, it will be automatically unarchived allowing it to reappear in your device lists and dashboards.
Here is your updated version of **Example 2** and **Example 3**, fully aligned with the format and structure of **Example 1** for consistency and professionalism:
--- ---
## Example 2: Assign Device to Network Node Based on IP ## Example 2: Assign Device to Network Node Based on IP
@@ -107,7 +107,7 @@ When new devices join your network, assigning them to the correct network node i
### 🔍 Explanation ### 🔍 Explanation
* **Trigger**: Activates when a new device is added. * **Trigger**: Activates when a new device is added.
* **Condition**: * **Conditions**:
* `devLastIP` contains `192.168.1.` (matches subnet). * `devLastIP` contains `192.168.1.` (matches subnet).
* **Action**: * **Action**:
@@ -173,12 +173,12 @@ You may want to automatically clear out newly detected Google devices (such as C
* **Trigger**: Runs on device updates. * **Trigger**: Runs on device updates.
* **Conditions**: * **Conditions**:
* Vendor contains `Google`. * `devVendor` contains `Google`.
* Device is marked as new (`devIsNew` is `1`). * `devIsNew` is `1` (device marked as new).
* **Actions**: * **Actions**:
1. Set `devIsNew` to `0` (mark as not new). 1. Sets `devIsNew` to `0` (mark as not new).
2. Delete the device. 2. Deletes the device.
### ✅ Result ### ✅ Result

View File

@@ -1417,6 +1417,7 @@ textarea[readonly],
grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
gap: 0.75em; gap: 0.75em;
padding: 0.5em 0; padding: 0.5em 0;
padding-top: 0;
} }
#columnFilters::before, #columnFilters::before,

View File

@@ -307,7 +307,10 @@ function updateChevrons(currentMac) {
pos = refreshedList.findIndex(item => item.devMac === currentMac); pos = refreshedList.findIndex(item => item.devMac === currentMac);
if (pos === -1) { if (pos === -1) {
console.error('Still not found after re-cache:', currentMac); console.warn('Device not found in device list after re-cache — hiding navigation controls:', currentMac);
$('#txtRecord').hide();
$('#btnPrevious').hide();
$('#btnNext').hide();
return; return;
} }
@@ -499,26 +502,9 @@ async function renderSmallBoxes() {
} }
} }
function updateDevicePageName(mac) { // ----------------------------------------
let name = getDevDataByMac(mac, "devName"); // Write device name/owner into page title DOM. Pure DOM side-effect, no data fetching.
let owner = getDevDataByMac(mac, "devOwner"); function applyDevicePageTitle(mac, name, owner) {
// If data is missing, re-cache and retry once
if (mac != 'new' && (name === null|| owner === null)) {
console.warn("Device not found in cache, retrying after re-cache:", mac);
showSpinner();
cacheDevices(true).then(() => {
hideSpinner();
// Retry after successful cache
updateDevicePageName(mac);
}).catch((err) => {
hideSpinner();
console.error("Failed to refresh devices:", err);
});
return; // Exit early to avoid showing bad data
}
// Page title - Name
let pageTitleText; let pageTitleText;
if (mac === "new") { if (mac === "new") {
@@ -530,12 +516,12 @@ function updateDevicePageName(mac) {
`<i class="fa fa-circle-info"></i> ` + getString("Gen_create_new_device_info") `<i class="fa fa-circle-info"></i> ` + getString("Gen_create_new_device_info")
); );
$('#devicePageInfoPlc').show(); $('#devicePageInfoPlc').show();
} else if (!owner || name.toString().includes(owner)) { } else if (!owner || (name && name.toString().includes(owner))) {
pageTitleText = name; pageTitleText = encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"));
$('#pageTitle').html(pageTitleText); $('#pageTitle').html(pageTitleText);
$('#devicePageInfoPlc').hide(); $('#devicePageInfoPlc').hide();
} else { } else {
pageTitleText = `${name} (${owner})`; pageTitleText = `${encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"))} (${encodeSpecialChars(owner)})`;
$('#pageTitle').html(pageTitleText); $('#pageTitle').html(pageTitleText);
$('#devicePageInfoPlc').hide(); $('#devicePageInfoPlc').hide();
} }
@@ -544,6 +530,53 @@ function updateDevicePageName(mac) {
$('title').html(pageTitleText + ' - ' + $('title').html()); $('title').html(pageTitleText + ' - ' + $('title').html());
} }
// ----------------------------------------
// Resolve device name/owner for the page title.
// Stage 1: localStorage cache (synchronous, fast path).
// Stage 2: one forced re-cache from table_devices.json.
// Stage 3: REST API fallback so a direct-link visit never loops.
async function updateDevicePageName(mac) {
let name = getDevDataByMac(mac, "devName");
let owner = getDevDataByMac(mac, "devOwner");
// Stage 2: one re-cache attempt
if (mac !== 'new' && name === null) {
console.warn("Device not in cache, attempting re-cache:", mac);
showSpinner();
try {
await cacheDevices(true);
} catch (err) {
console.error("Re-cache failed:", err);
} finally {
hideSpinner();
}
name = getDevDataByMac(mac, "devName");
owner = getDevDataByMac(mac, "devOwner");
}
// Stage 3: REST fallback — same endpoint renderSmallBoxes uses, always DB-direct
if (mac !== 'new' && name === null) {
console.warn("Device not found in cache after re-cache, falling back to REST API:", mac);
try {
const { apiBase, authHeader } = getAuthContext();
const res = await fetch(`${apiBase}/device/${encodeURIComponent(mac)}`, {
headers: authHeader
});
if (res.ok) {
const data = await res.json();
name = data.devName ?? null;
owner = data.devOwner ?? null;
} else {
console.error("REST fallback for device name returned:", res.status);
}
} catch (err) {
console.error("REST fallback error:", err);
}
}
applyDevicePageTitle(mac, name, owner);
}
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------

View File

@@ -236,6 +236,7 @@ function getDeviceData() {
// console.log(setting.setKey); // console.log(setting.setKey);
// console.log(fieldData); // console.log(fieldData);
// Additional form elements like the random MAC address button for devMac // Additional form elements like the random MAC address button for devMac
let inlineControl = ""; let inlineControl = "";
// handle random mac // handle random mac
@@ -329,6 +330,11 @@ function getDeviceData() {
fieldOptionsOverride = fieldDataNew; fieldOptionsOverride = fieldDataNew;
} }
// XSS prevention - encode special characters for string fields, but not for arrays (like children dynamic)
// Don't move above the handle devChildrenDynamic block because it relies on the original fieldData to generate options
fieldData = encodeSpecialChars(fieldData);
// console.log(fieldData);
// Generate the input field HTML // Generate the input field HTML
const inputFormHtml = `<div class="form-group col-xs-12"> const inputFormHtml = `<div class="form-group col-xs-12">
<label id="${setting.setKey}_label" class="${obj.labelClasses}" > ${setting.setName} <label id="${setting.setKey}_label" class="${obj.labelClasses}" > ${setting.setName}

View File

@@ -56,9 +56,6 @@ function initializeSessionsDatatable (sessionsRows) {
if (!cellData.includes("missing event") && !cellData.includes("...")) if (!cellData.includes("missing event") && !cellData.includes("..."))
{ {
if (cellData.includes("+")) { // Check if timezone offset is present
cellData = cellData.split('+')[0]; // Remove timezone offset
}
// console.log(cellData); // console.log(cellData);
result = localizeTimestamp(cellData); result = localizeTimestamp(cellData);
} else } else

View File

@@ -883,40 +883,41 @@ function initializeDatatable (status) {
{orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) }, {orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) },
// Device Name and FQDN // Device Name and FQDN
// Use `render` (not `createdCell`) so the HTML is built before DataTables
// sets td.innerHTML preventing raw cellData from being parsed as HTML.
{targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)], {targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)],
'createdCell': function (td, cellData, rowData, row, col) { 'render': function (data, type, row) {
if (type !== 'display') {
// console.log(cellData) return data; // raw value for sort / filter / type detection
var displayedValue = cellData;
if(isEmpty(displayedValue))
{
displayedValue = "N/A"
} }
$(td).html (
`<b class="anonymizeDev " var displayedValue = encodeSpecialChars(data);
>
<a href="deviceDetails.php?mac=${rowData[mapIndx(COL.devMac)]}" class="hover-node-info" if (isEmpty(displayedValue)) {
data-name="${displayedValue}" displayedValue = "N/A";
data-ip="${rowData[mapIndx(COL.devLastIP)]}" }
data-mac="${rowData[mapIndx(COL.devMac)]}"
data-vendor="${rowData[mapIndx(COL.devVendor)]}" return (
data-type="${rowData[mapIndx(COL.devType)]}" `<b class="anonymizeDev ">` +
data-firstseen="${rowData[mapIndx(COL.devFirstConnection)]}" `<a href="deviceDetails.php?mac=${row[mapIndx(COL.devMac)]}" class="hover-node-info"` +
data-lastseen="${rowData[mapIndx(COL.devLastConnection)]}" ` data-name="${displayedValue}"` +
data-relationship="${rowData[mapIndx(COL.devParentRelType)]}" ` data-ip="${row[mapIndx(COL.devLastIP)]}"` +
data-status="${rowData[mapIndx(COL.devStatus)]}" ` data-mac="${row[mapIndx(COL.devMac)]}"` +
data-present="${rowData[mapIndx(COL.devPresentLastScan)]}" ` data-vendor="${row[mapIndx(COL.devVendor)]}"` +
data-alertdown="${rowData[mapIndx(COL.devAlertDown)]}" ` data-type="${row[mapIndx(COL.devType)]}"` +
data-flapping="${rowData[mapIndx(COL.devFlapping)]}" ` data-firstseen="${row[mapIndx(COL.devFirstConnection)]}"` +
data-sleeping="${rowData[COL_EXTRA.devIsSleeping] || 0}" ` data-lastseen="${row[mapIndx(COL.devLastConnection)]}"` +
data-archived="${rowData[COL_EXTRA.devIsArchived] || 0}" ` data-relationship="${row[mapIndx(COL.devParentRelType)]}"` +
data-isnew="${rowData[COL_EXTRA.devIsNew] || 0}" ` data-status="${row[mapIndx(COL.devStatus)]}"` +
data-icon="${rowData[mapIndx(COL.devIcon)]}"> ` data-present="${row[mapIndx(COL.devPresentLastScan)]}"` +
${displayedValue} ` data-alertdown="${row[mapIndx(COL.devAlertDown)]}"` +
</a> ` data-flapping="${row[mapIndx(COL.devFlapping)]}"` +
</b>` ` data-sleeping="${row[COL_EXTRA.devIsSleeping] || 0}"` +
` data-archived="${row[COL_EXTRA.devIsArchived] || 0}"` +
` data-isnew="${row[COL_EXTRA.devIsNew] || 0}"` +
` data-icon="${row[mapIndx(COL.devIcon)]}"` +
`>${displayedValue}</a>` +
`</b>`
); );
} }, } },

View File

@@ -167,9 +167,11 @@ function initializeDatatable() {
{ targets: [0,5,6,7,8,10,11,12,13], visible: false }, { targets: [0,5,6,7,8,10,11,12,13], visible: false },
{ targets: [7], orderData: [8] }, { targets: [7], orderData: [8] },
{ targets: [9], orderData: [10] }, { targets: [9], orderData: [10] },
{ targets: [1], createdCell: (td, cellData, rowData) => { // Use `render` (not `createdCell`) so encodeSpecialChars runs before
// Device column as link // DataTables sets td.innerHTML, preventing devName XSS execution.
$(td).html(`<b><a href="deviceDetails.php?mac=${rowData[13]}">${cellData}</a></b>`); { targets: [1], render: function (data, type, row) {
if (type !== 'display') { return data; }
return `<b><a href="deviceDetails.php?mac=${row[13]}">${encodeSpecialChars(data)}</a></b>`;
}}, }},
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) }, { targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) } { targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }

View File

@@ -357,23 +357,67 @@ function isValidJSON(jsonString) {
} }
// ---------------------------------------------------- // ----------------------------------------------------
// method to sanitize input so that HTML and other things don't break /**
* Encode special HTML characters into HTML entities.
*
* Prevents HTML injection/XSS when displaying untrusted text
* inside HTML content contexts.
*
* Example:
* <script> -> &lt;script&gt;
*
* Note:
* - Intended for HTML text contexts only
* - Prefer using textContent or jQuery .text() when possible
* - Do NOT use as the sole protection for inline JS, URLs, CSS,
* or unsafe innerHTML usage
*
* @param {*} str - Value to encode
* @returns {string} Encoded safe HTML string
*/
function encodeSpecialChars(str) { function encodeSpecialChars(str) {
return str
.replace(/&/g, '&amp;') if (str === null || str === undefined) {
.replace(/</g, '&lt;') return '';
.replace(/>/g, '&gt;') }
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;'); str = String(str);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
} }
// ---------------------------------------------------- // ----------------------------------------------------
/**
* Decode HTML entities back into normal characters.
*
* Example:
* &lt;script&gt; -> <script>
*
* Warning:
* Decoding untrusted content and later inserting it into
* innerHTML or other HTML contexts can reintroduce XSS risks.
*
* @param {*} str - Value to decode
* @returns {string} Decoded string
*/
function decodeSpecialChars(str) { function decodeSpecialChars(str) {
return str
.replace(/&amp;/g, '&') if (str === null || str === undefined) {
.replace(/&lt;/g, '<') return '';
.replace(/&gt;/g, '>') }
.replace(/&quot;/g, '"')
.replace(/&#039;/g, '\''); str = String(str);
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, '\'');
} }
// ---------------------------------------------------- // ----------------------------------------------------
@@ -473,7 +517,7 @@ function createDeviceLink(input)
{ {
if(checkMacOrInternet(input)) if(checkMacOrInternet(input))
{ {
return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${getDevDataByMac(input, "devName")}</a><span>` return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${encodeSpecialChars(getDevDataByMac(input, "devName"))}</a><span>`
} }
return input; return input;

View File

@@ -134,7 +134,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null,
width: '15%', width: '15%',
render: function (name, type, device) { render: function (name, type, device) {
return `<a href="./deviceDetails.php?mac=${device.devMac}" target="_blank"> return `<a href="./deviceDetails.php?mac=${device.devMac}" target="_blank">
<b class="anonymize">${name || '-'}</b> <b class="anonymize">${encodeSpecialChars(name || '-')}</b>
</a>`; </a>`;
} }
}, },

View File

@@ -18,9 +18,9 @@ function renderNetworkTabs(nodes) {
html += ` html += `
<li class="networkNodeTabHeaders ${i === 0 ? 'active' : ''}"> <li class="networkNodeTabHeaders ${i === 0 ? 'active' : ''}">
<a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${node.devName}"> <a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${encodeSpecialChars(node.devName)}">
<div class="icon ${iconClass}">${icon}</div> <div class="icon ${iconClass}">${icon}</div>
<span class="node-name">${node.devName}</span>${portLabel} <span class="node-name">${encodeSpecialChars(node.devName)}</span>${portLabel}
</a> </a>
</li>`; </li>`;
}); });
@@ -66,7 +66,7 @@ function renderNetworkTabContent(nodes) {
<div class="mb-3 row"> <div class="mb-3 row">
<label class="col-sm-3 col-form-label fw-bold">${getString('DevDetail_Tab_Details')}</label> <label class="col-sm-3 col-form-label fw-bold">${getString('DevDetail_Tab_Details')}</label>
<div class="col-sm-9"> <div class="col-sm-9">
<a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${node.devName}</a> <a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${encodeSpecialChars(node.devName)}</a>
</div> </div>
</div> </div>
@@ -90,7 +90,7 @@ function renderNetworkTabContent(nodes) {
<div class="col-sm-9"> <div class="col-sm-9">
${isRootNode ? '' : `<a class="anonymize" href="#">`} ${isRootNode ? '' : `<a class="anonymize" href="#">`}
<span my-data-mac="${node.devParentMAC}" data-mac="${node.devParentMAC}" data-devIsNetworkNodeDynamic="1" onclick="handleNodeClick(this)"> <span my-data-mac="${node.devParentMAC}" data-mac="${node.devParentMAC}" data-devIsNetworkNodeDynamic="1" onclick="handleNodeClick(this)">
${isRootNode ? getString('Network_Root') : getDevDataByMac(node.devParentMAC, "devName")} ${isRootNode ? getString('Network_Root') : encodeSpecialChars(getDevDataByMac(node.devParentMAC, "devName"))}
</span> </span>
${isRootNode ? '' : `</a>`} ${isRootNode ? '' : `</a>`}
</div> </div>

View File

@@ -278,7 +278,7 @@ function initTree(myHierarchy)
onclick="handleNodeClick(this)" onclick="handleNodeClick(this)"
data-mac="${nodeData.data.devMac}" data-mac="${nodeData.data.devMac}"
data-parentMac="${nodeData.data.devParentMAC}" data-parentMac="${nodeData.data.devParentMAC}"
data-name="${nodeData.data.devName}" data-name="${encodeSpecialChars(nodeData.data.devName)}"
data-ip="${nodeData.data.devLastIP}" data-ip="${nodeData.data.devLastIP}"
data-mac="${nodeData.data.devMac}" data-mac="${nodeData.data.devMac}"
data-vendor="${nodeData.data.devVendor}" data-vendor="${nodeData.data.devVendor}"
@@ -298,7 +298,7 @@ function initTree(myHierarchy)
> >
<div class="netNodeText"> <div class="netNodeText">
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span> <strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${nodeData.data.devName}</span> <span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${encodeSpecialChars(nodeData.data.devName)}</span>
${networkHardwareIcon} ${networkHardwareIcon}
</strong> </strong>
</div> </div>

View File

@@ -770,7 +770,7 @@ function reverseTransformers(val, transformers) {
break; break;
case "deviceChip": case "deviceChip":
mac = val // value is mac mac = val // value is mac
val = `${getDevDataByMac(mac, "devName")}` val = encodeSpecialChars(getDevDataByMac(mac, "devName"))
break; break;
case "deviceRelType": case "deviceRelType":
val = val; // nothing to do val = val; // nothing to do
@@ -961,7 +961,7 @@ function generateOptions(options, valuesArray, targetField, transformers, placeh
// Always include selected if options are used as a source // Always include selected if options are used as a source
let selected = options.length !== 0 && valuesArray.includes(item.id) ? 'selected' : ''; let selected = options.length !== 0 && valuesArray.includes(item.id) ? 'selected' : '';
optionsHtml += `<option class="${cssClass}" value="${item.id}" ${selected}>${labelName}</option>`; optionsHtml += `<option class="${cssClass}" value="${encodeSpecialChars(item.id)}" ${selected}>${encodeSpecialChars(labelName)}</option>`;
}); });

View File

@@ -997,7 +997,7 @@ function renderDeviceLink(data, container, useName = false) {
<a href="${badge.url}" target="_blank"> <a href="${badge.url}" target="_blank">
<span class="custom-chip"> <span class="custom-chip">
<span class="iconPreview">${atob(device.devIcon)}</span> <span class="iconPreview">${atob(device.devIcon)}</span>
${useName ? device.devName : data.text} ${useName ? encodeSpecialChars(device.devName) : data.text}
<span> <span>
(${badge.iconHtml}) (${badge.iconHtml})
</span> </span>
@@ -1063,7 +1063,7 @@ function initHoverNodeInfo() {
const html = ` const html = `
<div> <div>
<b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${name}</b><br> <b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${encodeSpecialChars(name)}</b><br>
</div> </div>
<hr/> <hr/>
<div class="line"> <div class="line">

View File

@@ -10832,7 +10832,8 @@ if (typeof jQuery === 'undefined') {
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
} }
var $parent = $(selector === '#' ? [] : selector) selector = selector === '#' ? [] : selector
var $parent = $(document).find(selector)
if (e) e.preventDefault() if (e) e.preventDefault()
@@ -11228,9 +11229,15 @@ if (typeof jQuery === 'undefined') {
// ================= // =================
var clickHandler = function (e) { var clickHandler = function (e) {
var href
var $this = $(this) var $this = $(this)
var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 var href = $this.attr('href')
if (href) {
href = href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
}
var target = $this.attr('data-target') || href
var $target = $(document).find(target)
if (!$target.hasClass('carousel')) return if (!$target.hasClass('carousel')) return
var options = $.extend({}, $target.data(), $this.data()) var options = $.extend({}, $target.data(), $this.data())
var slideIndex = $this.attr('data-slide-to') var slideIndex = $this.attr('data-slide-to')
@@ -11420,7 +11427,7 @@ if (typeof jQuery === 'undefined') {
var target = $trigger.attr('data-target') var target = $trigger.attr('data-target')
|| (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
return $(target) return $(document).find(target)
} }
@@ -11502,7 +11509,7 @@ if (typeof jQuery === 'undefined') {
selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
} }
var $parent = selector && $(selector) var $parent = selector && $(document).find(selector)
return $parent && $parent.length ? $parent : $this.parent() return $parent && $parent.length ? $parent : $this.parent()
} }
@@ -11961,7 +11968,10 @@ if (typeof jQuery === 'undefined') {
$(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
var $this = $(this) var $this = $(this)
var href = $this.attr('href') var href = $this.attr('href')
var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 var target = $this.attr('data-target') ||
(href && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
var $target = $(document).find(target)
var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
if ($this.is('a')) e.preventDefault() if ($this.is('a')) e.preventDefault()

View File

@@ -1842,11 +1842,16 @@
return globalLocale; return globalLocale;
} }
function isLocaleNameSane(name) {
// Prevent names that look like filesystem paths, i.e contain '/' or '\'
return name.match('^[^/\\\\]*$') != null;
}
function loadLocale(name) { function loadLocale(name) {
var oldLocale = null; var oldLocale = null;
// TODO: Find a better way to register and load all the locales in Node // TODO: Find a better way to register and load all the locales in Node
if (!locales[name] && (typeof module !== 'undefined') && if (!locales[name] && (typeof module !== 'undefined') &&
module && module.exports) { module && module.exports && isLocaleNameSane(name)) {
try { try {
oldLocale = globalLocale._abbr; oldLocale = globalLocale._abbr;
var aliasedRequire = require; var aliasedRequire = require;
@@ -2294,7 +2299,7 @@
function preprocessRFC2822(s) { function preprocessRFC2822(s) {
// Remove comments and folding whitespace and replace multiple-spaces with a single space // Remove comments and folding whitespace and replace multiple-spaces with a single space
return s.replace(/\([^)]*\)|[\n\t]/g, ' ').replace(/(\s\s+)/g, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, ''); return s.replace(/\((?:(?!\().)*\)|[\n\t]/gs, ' ').replace(/(\s\s+)/g, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, '');
} }
function checkWeekday(weekdayStr, parsedInput, config) { function checkWeekday(weekdayStr, parsedInput, config) {

View File

@@ -251,7 +251,7 @@
$select.append( $select.append(
devicesList devicesList
.filter(d => d.devMac && d.devName) .filter(d => d.devMac && d.devName)
.map(d => `<option value="${d.devMac}">${d.devName}</option>`) .map(d => `<option value="${d.devMac}">${encodeSpecialChars(d.devName)}</option>`)
.join('') .join('')
).trigger('change'); ).trigger('change');
} }

View File

@@ -66,8 +66,8 @@
"CustProps_cant_remove": "No es pot eliminar, es necessita una propietat mínim.", "CustProps_cant_remove": "No es pot eliminar, es necessita una propietat mínim.",
"DAYS_TO_KEEP_EVENTS_description": "Això és una configuració de manteniment. Especifica el nombre de dies que es conservaran els esdeveniments. Els esdeveniments antics s'esborraran periòdicament. També aplica als esdeveniments dels Connectors (Plugins).", "DAYS_TO_KEEP_EVENTS_description": "Això és una configuració de manteniment. Especifica el nombre de dies que es conservaran els esdeveniments. Els esdeveniments antics s'esborraran periòdicament. També aplica als esdeveniments dels Connectors (Plugins).",
"DAYS_TO_KEEP_EVENTS_name": "Esborrar esdeveniments més vells de", "DAYS_TO_KEEP_EVENTS_name": "Esborrar esdeveniments més vells de",
"DEEP_SLEEP_description": "", "DEEP_SLEEP_description": "Redueix l'ús de la CPU ampliant els temps d'espera inactiu entre els cicles de processament. Quan està activat, les exploracions es poden retardar fins a 1 minut i la interfície d'usuari pot ser menys sensible.",
"DEEP_SLEEP_name": "", "DEEP_SLEEP_name": "Son profund",
"DISCOVER_PLUGINS_description": "Desactiva aquesta opció per accelerar la inicialització i l'estalvi de configuració. Quan està desactivat, els connectors no es descobreixen, i no podeu afegir nous connectors a la configuració <code>LOADED_PLUGINS</code>.", "DISCOVER_PLUGINS_description": "Desactiva aquesta opció per accelerar la inicialització i l'estalvi de configuració. Quan està desactivat, els connectors no es descobreixen, i no podeu afegir nous connectors a la configuració <code>LOADED_PLUGINS</code>.",
"DISCOVER_PLUGINS_name": "Descobreix els plugins", "DISCOVER_PLUGINS_name": "Descobreix els plugins",
"DevDetail_Children_Title": "Relacions filles", "DevDetail_Children_Title": "Relacions filles",
@@ -141,7 +141,7 @@
"DevDetail_SessionTable_Duration": "Durada", "DevDetail_SessionTable_Duration": "Durada",
"DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_IP": "IP",
"DevDetail_SessionTable_Order": "Ordre", "DevDetail_SessionTable_Order": "Ordre",
"DevDetail_Shortcut_CurrentStatus": "Estat actual", "DevDetail_Shortcut_CurrentStatus": "Estat",
"DevDetail_Shortcut_DownAlerts": "Aturar alertes", "DevDetail_Shortcut_DownAlerts": "Aturar alertes",
"DevDetail_Shortcut_Presence": "Presència", "DevDetail_Shortcut_Presence": "Presència",
"DevDetail_Shortcut_Sessions": "Sessions", "DevDetail_Shortcut_Sessions": "Sessions",
@@ -250,7 +250,7 @@
"Device_TableHead_NetworkSite": "Network Site", "Device_TableHead_NetworkSite": "Network Site",
"Device_TableHead_Owner": "Propietari", "Device_TableHead_Owner": "Propietari",
"Device_TableHead_ParentRelType": "Tipus de relació", "Device_TableHead_ParentRelType": "Tipus de relació",
"Device_TableHead_Parent_MAC": "Node pare de xarxa", "Device_TableHead_Parent_MAC": "Node pare",
"Device_TableHead_Port": "Port", "Device_TableHead_Port": "Port",
"Device_TableHead_PresentLastScan": "Presència", "Device_TableHead_PresentLastScan": "Presència",
"Device_TableHead_ReqNicsOnline": "Requereix NICs En línia", "Device_TableHead_ReqNicsOnline": "Requereix NICs En línia",
@@ -346,7 +346,7 @@
"Gen_LockedDB": "ERROR - DB podria estar bloquejada - Fes servir F12 Eines desenvolupament -> Consola o provar-ho més tard.", "Gen_LockedDB": "ERROR - DB podria estar bloquejada - Fes servir F12 Eines desenvolupament -> Consola o provar-ho més tard.",
"Gen_NetworkMask": "Màscara de xarxa", "Gen_NetworkMask": "Màscara de xarxa",
"Gen_New": "Nou", "Gen_New": "Nou",
"Gen_No_Data": "", "Gen_No_Data": "Sense dades",
"Gen_Offline": "Fora de línia", "Gen_Offline": "Fora de línia",
"Gen_Okay": "Ok", "Gen_Okay": "Ok",
"Gen_Online": "En línia", "Gen_Online": "En línia",

View File

@@ -382,13 +382,13 @@
"HRS_TO_KEEP_NEWDEV_name": "Odstranit nová zařízení po", "HRS_TO_KEEP_NEWDEV_name": "Odstranit nová zařízení po",
"HRS_TO_KEEP_OFFDEV_description": "Toto je údržbářské nastavení <b>ODSTRANĚNÍ zařízení</b>. Pokud je povoleno (<code>0</code> zakázáno), zařízení <b>Offline</b> s jejich datumem <b>Posledního připojení</b> starším, než uvedené hodiny v tomto nastavení, budou odstraněna. Použijte toto nastavení, pokud chcete automaticky mazat <b>Offline zařízení</b> po uplynutí <code>X</code> hodin offline.", "HRS_TO_KEEP_OFFDEV_description": "Toto je údržbářské nastavení <b>ODSTRANĚNÍ zařízení</b>. Pokud je povoleno (<code>0</code> zakázáno), zařízení <b>Offline</b> s jejich datumem <b>Posledního připojení</b> starším, než uvedené hodiny v tomto nastavení, budou odstraněna. Použijte toto nastavení, pokud chcete automaticky mazat <b>Offline zařízení</b> po uplynutí <code>X</code> hodin offline.",
"HRS_TO_KEEP_OFFDEV_name": "Odstranit offline zařízení po", "HRS_TO_KEEP_OFFDEV_name": "Odstranit offline zařízení po",
"LOADED_PLUGINS_description": "", "LOADED_PLUGINS_description": "Které pluginy načíst. Přidávání pluginů může aplikaci zpomalit. Přečtěte si více o pluginech, které musí být povoleny, o typech, nebo o scannovacích moźnostech v <a target=\"_blank\" href=\"https://docs.netalertx.com/PLUGINS\">dokumentaci pluginů</a>. Odpojené pluginy ztratí vaše nastavení. Pouze <code>deaktivované</code> pluginy mohou být odpojeny.",
"LOADED_PLUGINS_name": "Načtené pluginy", "LOADED_PLUGINS_name": "Načtené pluginy",
"LOG_LEVEL_description": "Toto nastavení způsobí více detailní logování. To je užitečné pro ladění událostí zapisujících do databáze.", "LOG_LEVEL_description": "Toto nastavení způsobí více detailní logování. To je užitečné pro ladění událostí zapisujících do databáze.",
"LOG_LEVEL_name": "Vypisovat dodatečné logování", "LOG_LEVEL_name": "Vypisovat dodatečné logování",
"Loading": "Načítání…", "Loading": "Načítání…",
"Login_Box": "Zadejte vaše heslo", "Login_Box": "Zadejte vaše heslo",
"Login_Default_PWD": "", "Login_Default_PWD": "Výchozí heslo \"123456\" je stále aktivní.",
"Login_Info": "Hesla jsou nastavována přes Set Password plugin. Zkontrolujte <a target=\"_blank\" href=\"https://github.com/netalertx/NetAlertX/tree/main/front/plugins/set_password\">dokumentaci SETPWD</a>, pokud máte potíže s přihlášením.", "Login_Info": "Hesla jsou nastavována přes Set Password plugin. Zkontrolujte <a target=\"_blank\" href=\"https://github.com/netalertx/NetAlertX/tree/main/front/plugins/set_password\">dokumentaci SETPWD</a>, pokud máte potíže s přihlášením.",
"Login_Psw-box": "Heslo", "Login_Psw-box": "Heslo",
"Login_Psw_alert": "Upozornění na heslo!", "Login_Psw_alert": "Upozornění na heslo!",
@@ -437,32 +437,32 @@
"Maintenance_Tool_UnlockFields_text": "Tento nástroj odstraní všechny zdrojové hodnoty ze všech sledovaných polí pro všechna zařízení, čímž efektivně odemkne všechna pole pro pluginy a uživatele. Používejte jej opatrně, protože to ovlivní celý inventář vašich zařízení.", "Maintenance_Tool_UnlockFields_text": "Tento nástroj odstraní všechny zdrojové hodnoty ze všech sledovaných polí pro všechna zařízení, čímž efektivně odemkne všechna pole pro pluginy a uživatele. Používejte jej opatrně, protože to ovlivní celý inventář vašich zařízení.",
"Maintenance_Tool_arpscansw": "Přepnout ARP sken (zapnuto/vypnuto)", "Maintenance_Tool_arpscansw": "Přepnout ARP sken (zapnuto/vypnuto)",
"Maintenance_Tool_arpscansw_noti": "Přepne ARP sken na zapnuto nebo vypnuto", "Maintenance_Tool_arpscansw_noti": "Přepne ARP sken na zapnuto nebo vypnuto",
"Maintenance_Tool_arpscansw_noti_text": "Pokud je scan vypnut, zůstává vypnutý do opětovné aktivace.", "Maintenance_Tool_arpscansw_noti_text": "Pokud je sken vypnut, zůstává vypnutý do opětovné aktivace.",
"Maintenance_Tool_arpscansw_text": "", "Maintenance_Tool_arpscansw_text": "Přepíná ARP-SCAN na zapnuto, nebo vypnuto. Jakmile je skenování vypnuto, zůstává vypnuto, dokud není znovu aktivováno. Aktivní skenování nejsou přerušena.",
"Maintenance_Tool_backup": "", "Maintenance_Tool_backup": "Záloha DB",
"Maintenance_Tool_backup_noti": "", "Maintenance_Tool_backup_noti": "Záloha DB",
"Maintenance_Tool_backup_noti_text": "", "Maintenance_Tool_backup_noti_text": "Opravdu chcete spustit zálohu DB? Ujistěte se, že neběží žádný sken.",
"Maintenance_Tool_backup_text": "", "Maintenance_Tool_backup_text": "Databázové zálohy jsou umístěny v databázovém adresáři jako ZIP archiv pojmenovaný podle jeho data vytvoření. Počet backupů není limitován.",
"Maintenance_Tool_check_visible": "", "Maintenance_Tool_check_visible": "Odšrktněte pro skrytí sloupce.",
"Maintenance_Tool_clearSourceFields_selected": "", "Maintenance_Tool_clearSourceFields_selected": "Vymazat zrojové položky",
"Maintenance_Tool_clearSourceFields_selected_noti": "", "Maintenance_Tool_clearSourceFields_selected_noti": "Vymazat zdroje",
"Maintenance_Tool_clearSourceFields_selected_text": "", "Maintenance_Tool_clearSourceFields_selected_text": "Toto vymaže všechny zdrojové položky zvolených zařízení. Tuto akci nelze vrátit zpět.",
"Maintenance_Tool_darkmode": "", "Maintenance_Tool_darkmode": "Přepnutí režimů (Tmavý/Světlý)",
"Maintenance_Tool_darkmode_noti": "", "Maintenance_Tool_darkmode_noti": "Přepnutí režimů",
"Maintenance_Tool_darkmode_noti_text": "", "Maintenance_Tool_darkmode_noti_text": "Po změně tématu se stránka pokusí znovu načíst pro aktivaci změn. Pokud bude potřeba, musí být vymazána cache.",
"Maintenance_Tool_darkmode_text": "", "Maintenance_Tool_darkmode_text": "Přepíná mezi tmavým a světlým režimem. Pokud přepínač nefunguje správně, zkuste vymazat cache prohlížeče. Změny jsou provedeny na straně serveru, takže ovlivňují všechna použitá zařízení.",
"Maintenance_Tool_del_ActHistory": "", "Maintenance_Tool_del_ActHistory": "Odstranění síťové aktivity",
"Maintenance_Tool_del_ActHistory_noti": "", "Maintenance_Tool_del_ActHistory_noti": "Odstranit síťovou aktivitu",
"Maintenance_Tool_del_ActHistory_noti_text": "", "Maintenance_Tool_del_ActHistory_noti_text": "Opravdu chcete resetovat síťovou aktivitu?",
"Maintenance_Tool_del_ActHistory_text": "", "Maintenance_Tool_del_ActHistory_text": "Grafy síťové aktivity byly resetovány. Toto neovlivní události.",
"Maintenance_Tool_del_alldev": "", "Maintenance_Tool_del_alldev": "Odstanit všechna zařízení",
"Maintenance_Tool_del_alldev_noti": "", "Maintenance_Tool_del_alldev_noti": "Odstranit zařízení",
"Maintenance_Tool_del_alldev_noti_text": "", "Maintenance_Tool_del_alldev_noti_text": "Opravdu chcete odstranit všechna zařízení?",
"Maintenance_Tool_del_alldev_text": "", "Maintenance_Tool_del_alldev_text": "Před použitím této funkce prosím proveďte zálohu. Odstranění nelze vrátit zpět. Všechna zařízení budou z databáze odstraněna.",
"Maintenance_Tool_del_allevents": "", "Maintenance_Tool_del_allevents": "Odstranit události (reset přítomnosti)",
"Maintenance_Tool_del_allevents30": "", "Maintenance_Tool_del_allevents30": "Odstranit všechny události staří 30 dní",
"Maintenance_Tool_del_allevents30_noti": "", "Maintenance_Tool_del_allevents30_noti": "Odstranit události",
"Maintenance_Tool_del_allevents30_noti_text": "", "Maintenance_Tool_del_allevents30_noti_text": "Opravdu chcete odstranit všechny události starší 30 dní? Toto vyresetuje přítomnost všech zařízení.",
"Maintenance_Tool_del_allevents30_text": "", "Maintenance_Tool_del_allevents30_text": "",
"Maintenance_Tool_del_allevents_noti": "", "Maintenance_Tool_del_allevents_noti": "",
"Maintenance_Tool_del_allevents_noti_text": "", "Maintenance_Tool_del_allevents_noti_text": "",

View File

@@ -37,7 +37,7 @@
"BackDevDetail_Tools_WOL_error": "O comando NÃO foi executado.", "BackDevDetail_Tools_WOL_error": "O comando NÃO foi executado.",
"BackDevDetail_Tools_WOL_okay": "O comando foi executado.", "BackDevDetail_Tools_WOL_okay": "O comando foi executado.",
"BackDevices_Arpscan_disabled": "Análise Arp Desativada", "BackDevices_Arpscan_disabled": "Análise Arp Desativada",
"BackDevices_Arpscan_enabled": "Análise ARP Ativada", "BackDevices_Arpscan_enabled": "Análise Arp Ativada",
"BackDevices_Backup_CopError": "A base da dados original não pode ser gravada.", "BackDevices_Backup_CopError": "A base da dados original não pode ser gravada.",
"BackDevices_Backup_Failed": "A copia de segurança foi parcialmente executada. O arquivo não pode ser criado ou está vazio.", "BackDevices_Backup_Failed": "A copia de segurança foi parcialmente executada. O arquivo não pode ser criado ou está vazio.",
"BackDevices_Backup_okay": "A copia de segurança foi feita executado corretamente com o novo arquivo", "BackDevices_Backup_okay": "A copia de segurança foi feita executado corretamente com o novo arquivo",
@@ -61,14 +61,14 @@
"BackDevices_Restore_okay": "Restauração executada com sucesso.", "BackDevices_Restore_okay": "Restauração executada com sucesso.",
"BackDevices_darkmode_disabled": "Modo Noturno Desativado", "BackDevices_darkmode_disabled": "Modo Noturno Desativado",
"BackDevices_darkmode_enabled": "Modo Noturno Ativado", "BackDevices_darkmode_enabled": "Modo Noturno Ativado",
"CLEAR_NEW_FLAG_description": "Se ativado (<code>0</code> está desativado), dispositivos marcados como<b>Novo Dispositivo</b> serão desmarcados se o limite (especificado em horas) exceder o tempo da <b>Primeira Sessão </b>.", "CLEAR_NEW_FLAG_description": "Se ativado (<code>0</code> está desativado), dispositivos marcados como <b>Novo Dispositivo</b> serão desmarcados se o limite (especificado em horas) exceder o tempo da <b>Primeira Sessão</b>.",
"CLEAR_NEW_FLAG_name": "Limpar a flag nova", "CLEAR_NEW_FLAG_name": "Limpar a flag nova",
"CustProps_cant_remove": "Não é possível remover, é necessária pelo menos uma propriedade.", "CustProps_cant_remove": "Não é possível remover, é necessária pelo menos uma propriedade.",
"DAYS_TO_KEEP_EVENTS_description": "Esta é uma definição de manutenção. Especifica o número de dias de entradas de eventos que serão mantidas. Todos os eventos mais antigos serão apagados periodicamente. Também se aplica ao Histórico de eventos do plug-in.", "DAYS_TO_KEEP_EVENTS_description": "Esta é uma definição de manutenção. Especifica o número de dias de entradas de eventos que serão mantidas. Todos os eventos mais antigos serão apagados periodicamente. Também se aplica ao Histórico de eventos do plug-in.",
"DAYS_TO_KEEP_EVENTS_name": "Apagar eventos mais antigos que", "DAYS_TO_KEEP_EVENTS_name": "Apagar eventos mais antigos que",
"DEEP_SLEEP_description": "", "DEEP_SLEEP_description": "Diminui a utilização do CPU ao prolongar tempos de espera ociosos entre ciclos de processamento. Quando ativo, análises podem ser atrasadas por até 1 minuto e o UI pode ficar menos responsivo.",
"DEEP_SLEEP_name": "", "DEEP_SLEEP_name": "Sleep profundo",
"DISCOVER_PLUGINS_description": "Desative esta opção para acelerar a inicialização e a gravação de definições. Quando desativada, os plug-ins não são descobertos e não é possível adicionar novos plug-ins à definição<code>LOADED_PLUGINS</code>.", "DISCOVER_PLUGINS_description": "Desative esta opção para acelerar a inicialização e a gravação de definições. Quando desativada, os plug-ins não são descobertos e não é possível adicionar novos plug-ins à definição <code>LOADED_PLUGINS</code>.",
"DISCOVER_PLUGINS_name": "Descobrir plugins", "DISCOVER_PLUGINS_name": "Descobrir plugins",
"DevDetail_Children_Title": "Relacionamentos de crianças", "DevDetail_Children_Title": "Relacionamentos de crianças",
"DevDetail_Copy_Device_Title": "Copiar pormenores do dispositivo", "DevDetail_Copy_Device_Title": "Copiar pormenores do dispositivo",
@@ -98,7 +98,7 @@
"DevDetail_MainInfo_Location": "Localização", "DevDetail_MainInfo_Location": "Localização",
"DevDetail_MainInfo_Name": "Nome", "DevDetail_MainInfo_Name": "Nome",
"DevDetail_MainInfo_Network": "<i class=\"fa fa-server\"> </i> Node (MAC)", "DevDetail_MainInfo_Network": "<i class=\"fa fa-server\"> </i> Node (MAC)",
"DevDetail_MainInfo_Network_Port": "<i class=\"fa fa-ethernet\"></i>Porta", "DevDetail_MainInfo_Network_Port": "<i class=\"fa fa-ethernet\"></i> Porta",
"DevDetail_MainInfo_Network_Site": "Site", "DevDetail_MainInfo_Network_Site": "Site",
"DevDetail_MainInfo_Network_Title": "Detalhes de Rede", "DevDetail_MainInfo_Network_Title": "Detalhes de Rede",
"DevDetail_MainInfo_Owner": "Proprietário", "DevDetail_MainInfo_Owner": "Proprietário",
@@ -249,8 +249,8 @@
"Device_TableHead_Name": "Nome", "Device_TableHead_Name": "Nome",
"Device_TableHead_NetworkSite": "Site da rede", "Device_TableHead_NetworkSite": "Site da rede",
"Device_TableHead_Owner": "Proprietário", "Device_TableHead_Owner": "Proprietário",
"Device_TableHead_ParentRelType": "Tipo de relação", "Device_TableHead_ParentRelType": "Relação",
"Device_TableHead_Parent_MAC": "Node de rede anterior", "Device_TableHead_Parent_MAC": "Nó parente",
"Device_TableHead_Port": "Porta", "Device_TableHead_Port": "Porta",
"Device_TableHead_PresentLastScan": "Presença", "Device_TableHead_PresentLastScan": "Presença",
"Device_TableHead_ReqNicsOnline": "Exigir NICs online", "Device_TableHead_ReqNicsOnline": "Exigir NICs online",
@@ -341,7 +341,7 @@
"Gen_Filter": "Filtro", "Gen_Filter": "Filtro",
"Gen_Flapping": "Flapping", "Gen_Flapping": "Flapping",
"Gen_Generate": "Gerar", "Gen_Generate": "Gerar",
"Gen_InvalidMac": "Endereço MAC Inválido.", "Gen_InvalidMac": "Endereço Mac inválido.",
"Gen_Invalid_Value": "Um valor inválido foi inserido", "Gen_Invalid_Value": "Um valor inválido foi inserido",
"Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.", "Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.",
"Gen_NetworkMask": "Máscara de Rede", "Gen_NetworkMask": "Máscara de Rede",
@@ -350,7 +350,7 @@
"Gen_Offline": "Offline", "Gen_Offline": "Offline",
"Gen_Okay": "Ok", "Gen_Okay": "Ok",
"Gen_Online": "Online", "Gen_Online": "Online",
"Gen_Purge": "Purge", "Gen_Purge": "Purgar",
"Gen_ReadDocs": "Leia mais em documentos.", "Gen_ReadDocs": "Leia mais em documentos.",
"Gen_Remove_All": "Remover tudo", "Gen_Remove_All": "Remover tudo",
"Gen_Remove_Last": "Remover o último", "Gen_Remove_Last": "Remover o último",
@@ -403,7 +403,7 @@
"Login_Toggle_Info_headline": "Informações sobre a palavra-passe", "Login_Toggle_Info_headline": "Informações sobre a palavra-passe",
"Maint_PurgeLog": "Limpar o registo", "Maint_PurgeLog": "Limpar o registo",
"Maint_RestartServer": "Reiniciar o servidor", "Maint_RestartServer": "Reiniciar o servidor",
"Maint_Restart_Server_noti_text": "Tem certeza de que deseja reiniciar o servidor backend? Isto pode causar inconsistência na app. Faça primeiro um backup da sua configuração. <br/> <br/> Nota: Isto pode levar alguns minutos.", "Maint_Restart_Server_noti_text": "Tem a certeza que quer reiniciar o servidor backend? Isto pode causar inconsistências na aplicação. Crie uma cópia de segurança primeiro. <br/><br/> Nota: isto pode demorar alguns minutos.",
"Maintenance_InitCheck": "Verificação inicial", "Maintenance_InitCheck": "Verificação inicial",
"Maintenance_InitCheck_Checking": "A verificar…", "Maintenance_InitCheck_Checking": "A verificar…",
"Maintenance_InitCheck_QuickSetupGuide": "Certifique-se de que seguiu o <a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">guia de configuração rápida</a>.", "Maintenance_InitCheck_QuickSetupGuide": "Certifique-se de que seguiu o <a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">guia de configuração rápida</a>.",
@@ -495,7 +495,7 @@
"Maintenance_Tool_upgrade_database_noti_text": "Tem certeza de que deseja atualizar a base de dados?<br>(talvez prefira arquivá-la)", "Maintenance_Tool_upgrade_database_noti_text": "Tem certeza de que deseja atualizar a base de dados?<br>(talvez prefira arquivá-la)",
"Maintenance_Tool_upgrade_database_text": "Este botão atualizará a base de dados para ativar o gráfico Atividade de rede nas últimas 12 horas. Faça uma cópia de segurança da sua base de dados em caso de problemas.", "Maintenance_Tool_upgrade_database_text": "Este botão atualizará a base de dados para ativar o gráfico Atividade de rede nas últimas 12 horas. Faça uma cópia de segurança da sua base de dados em caso de problemas.",
"Maintenance_Tools_Tab_BackupRestore": "Backup / Restauração", "Maintenance_Tools_Tab_BackupRestore": "Backup / Restauração",
"Maintenance_Tools_Tab_Logging": "Logs", "Maintenance_Tools_Tab_Logging": "Registos",
"Maintenance_Tools_Tab_Settings": "Configurações", "Maintenance_Tools_Tab_Settings": "Configurações",
"Maintenance_Tools_Tab_Tools": "Ferramentas", "Maintenance_Tools_Tab_Tools": "Ferramentas",
"Maintenance_Tools_Tab_UISettings": "Configurações de interface", "Maintenance_Tools_Tab_UISettings": "Configurações de interface",
@@ -503,7 +503,7 @@
"Maintenance_arp_status_off": "está atualmente desativado", "Maintenance_arp_status_off": "está atualmente desativado",
"Maintenance_arp_status_on": "Scan em curso", "Maintenance_arp_status_on": "Scan em curso",
"Maintenance_built_on": "Construído em", "Maintenance_built_on": "Construído em",
"Maintenance_current_version": "Você está atualizado. Confira o que <a href=\"https://github.com/netalertx/NetAlertX/issues/138\" target=\"_blank\"> estou a trabalhar em</a>.", "Maintenance_current_version": "Você está atualizado. Confira no que é que <a href=\"https://github.com/netalertx/NetAlertX/issues/138\" target=\"_blank\">estou a trabalhar em</a>.",
"Maintenance_database_backup": "Backups DB", "Maintenance_database_backup": "Backups DB",
"Maintenance_database_backup_found": "foram encontrados backups", "Maintenance_database_backup_found": "foram encontrados backups",
"Maintenance_database_backup_total": "uso total do disco", "Maintenance_database_backup_total": "uso total do disco",
@@ -538,7 +538,7 @@
"Navigation_Report": "Reports enviados", "Navigation_Report": "Reports enviados",
"Navigation_Settings": "Definições", "Navigation_Settings": "Definições",
"Navigation_SystemInfo": "Informação de sistema", "Navigation_SystemInfo": "Informação de sistema",
"Navigation_Workflows": "Workflows", "Navigation_Workflows": "Fluxos de Trabalho",
"Network_Assign": "Conectar ao nodo de network <i class=\"fa fa-server\"></i> em cima", "Network_Assign": "Conectar ao nodo de network <i class=\"fa fa-server\"></i> em cima",
"Network_Cant_Assign": "Não é possível atribuir o node raiz da Internet como um node folha filho.", "Network_Cant_Assign": "Não é possível atribuir o node raiz da Internet como um node folha filho.",
"Network_Cant_Assign_No_Node_Selected": "Não é possível atribuir, nenhum node pai selecionado.", "Network_Cant_Assign_No_Node_Selected": "Não é possível atribuir, nenhum node pai selecionado.",
@@ -565,13 +565,13 @@
"Network_ManageEdit_Name": "Novo nome de dispositivo", "Network_ManageEdit_Name": "Novo nome de dispositivo",
"Network_ManageEdit_Name_text": "Nome sem caracteres especiais", "Network_ManageEdit_Name_text": "Nome sem caracteres especiais",
"Network_ManageEdit_Port": " Nova contagem de portas", "Network_ManageEdit_Port": " Nova contagem de portas",
"Network_ManageEdit_Port_text": "Deixe em branco para Wi-Fi e Powerline.", "Network_ManageEdit_Port_text": "Deixe em branco para Wi-Fi e Powerline",
"Network_ManageEdit_Submit": "Guardar Alterações", "Network_ManageEdit_Submit": "Guardar Alterações",
"Network_ManageEdit_Type": "Novo tipo de dispositivo", "Network_ManageEdit_Type": "Novo tipo de dispositivo",
"Network_ManageEdit_Type_text": "-- Selecionar tipo --", "Network_ManageEdit_Type_text": "-- Selecionar tipo --",
"Network_ManageLeaf": "Gerir atribuição", "Network_ManageLeaf": "Gerir atribuição",
"Network_ManageUnassign": "Cancelar Atribuição", "Network_ManageUnassign": "Cancelar Atribuição",
"Network_NoAssignedDevices": "Este nó de rede não tem quaisquer dispositivos atribuídos (nós folha). Atribua um abaixo ou vá ao separador <b><i class=\"fa fa-info-circle\"> Detalhes</b> em qualquer dispositivo em <a href=\"devices.php\"><b><i class=\"fa fa-laptop\"></i> Dispositivos</b></a>, e atribua-o a um <i <b><i class=\"fa fa-server\"></i> Nó de rede (MAC)</b> e <b><i class=\"fa fa-ethernet\"></i> Porta</b> lá.", "Network_NoAssignedDevices": "Este nó de rede não tem quaisquer dispositivos atribuídos (nós folha). Atribua um abaixo ou vá à aba <b><i class=\"fa fa-info-circle\"></i> Detalhes</b> de qualquer dispositivo em <a href=\"devices.php\"><b> <i class=\"fa fa-laptop\"></i> Dispositivos</b></a>, e atribua os mesmos a uma rede <b><i class=\"fa fa-server\"></i> Nó (MAC)</b> e <b><i class=\"fa fa-ethernet\"></i> Porta</b> lá.",
"Network_NoDevices": "Sem dispositivos para configurar", "Network_NoDevices": "Sem dispositivos para configurar",
"Network_Node": "Nó de rede", "Network_Node": "Nó de rede",
"Network_Node_Name": "Nome do nó", "Network_Node_Name": "Nome do nó",
@@ -629,183 +629,183 @@
"REFRESH_FQDN_description": "Reanalisa todos os dispositivos e atualiza o seu Nome de Domínio Qualificado Completo (FQDN). Se estiver desativado, apenas dispositivos sem um nome conhecido serão analisados para melhorar o desempenho. Neste caso, FQDN é atualizado apenas durante a descoberta de dispositivos inicial.", "REFRESH_FQDN_description": "Reanalisa todos os dispositivos e atualiza o seu Nome de Domínio Qualificado Completo (FQDN). Se estiver desativado, apenas dispositivos sem um nome conhecido serão analisados para melhorar o desempenho. Neste caso, FQDN é atualizado apenas durante a descoberta de dispositivos inicial.",
"REFRESH_FQDN_name": "Atualizar FQDN", "REFRESH_FQDN_name": "Atualizar FQDN",
"REPORT_DASHBOARD_URL_description": "Este URL é usado como base para gerar links nos relatórios HTML (p.ex.: emails). Introduza o URL começado com <code>http://</code> incluindo o número da porta (sem barra final <code>/</code>).", "REPORT_DASHBOARD_URL_description": "Este URL é usado como base para gerar links nos relatórios HTML (p.ex.: emails). Introduza o URL começado com <code>http://</code> incluindo o número da porta (sem barra final <code>/</code>).",
"REPORT_DASHBOARD_URL_name": "", "REPORT_DASHBOARD_URL_name": "URL NetAlertX",
"REPORT_ERROR": "", "REPORT_ERROR": "A página que procura está indisponível temporariamente, por favor tente outra vez após alguns segundos",
"REPORT_MAIL_description": "", "REPORT_MAIL_description": "Se ativo, um email é enviado com uma lista de mudanças às quais subscreveu. Por favor preencha também todas as definições relacionadas com a configuração SMTP abaixo. Se está a encontrar problemas, defina <code>LOG_LEVEL</code> para <code>debug</code> e verifique o <a href=\"/maintenance.php#tab_Logging\">registo de erros</a>.",
"REPORT_MAIL_name": "", "REPORT_MAIL_name": "Ativar email",
"REPORT_TITLE": "", "REPORT_TITLE": "Reportar",
"RandomMAC_hover": "", "RandomMAC_hover": "Este dispositivo tem um endereço MAC aleatório",
"Reports_Sent_Log": "", "Reports_Sent_Log": "Registo de relatórios enviado",
"SCAN_SUBNETS_description": "", "SCAN_SUBNETS_description": "A maior parte dos scanners on-network (ARP-SCAN, NMAP, NSLOOKUP, DIG) baseiam-se em scanear interfaces de rede específicas e subredes. Veja a <a href=\"https://docs.netalertx.com/SUBNETS\" target=\"_blank\">documentação de subredes</a> para ajudar com esta definição, especialmente VLANs, quais VLANs são suportadas, ou como descobrir a máscara de rede e a sua interface. <br/> <br/> Uma alternativa a scanners on-network é ativar outro scanner de dispositivos/importadores que não dependam do NetAlert<sup>X</sup> tenha acesso à rede (UNIFI, dhcp.leases, PiHole, etc.). <br/> <br/> Nota: O tempo de scaneamento em si depende do número de endereços de IP a verificar, por isso configure isto com cuidado com a máscara e interface de rede apropriadas.",
"SCAN_SUBNETS_name": "", "SCAN_SUBNETS_name": "Redes a scanear",
"SYSTEM_TITLE": "", "SYSTEM_TITLE": "Informação de Sistema",
"Setting_Override": "", "Setting_Override": "Sobrescrever valor",
"Setting_Override_Description": "", "Setting_Override_Description": "Ativar esta opção irá sobrescrever o valor predefinido pela App com o valor especificado acima.",
"Settings_Metadata_Toggle": "", "Settings_Metadata_Toggle": "Mostrar/esconder metadados para definição especificada.",
"Settings_Show_Description": "", "Settings_Show_Description": "Mostrar descrição",
"Settings_device_Scanners_desync": "", "Settings_device_Scanners_desync": "⚠ Os horários de procura de dispositivos estão dessincronizados.",
"Settings_device_Scanners_desync_popup": "", "Settings_device_Scanners_desync_popup": "Horários de scanners de dispositivos (<code>*_RUN_SCHD</code>) não são o mesmo. Isto resultará em notificações de dispositivo online/offline inconsistentes. A menos que isto seja intencional, por favor use o mesmo horário para todos os <b>🔍scanners de dispositivos</b> ativos.",
"Speedtest_Results": "", "Speedtest_Results": "Resultados do Teste de Velocidade",
"Systeminfo_AvailableIps": "", "Systeminfo_AvailableIps": "IPs Disponíveis",
"Systeminfo_CPU": "", "Systeminfo_CPU": "CPU",
"Systeminfo_CPU_Cores": "", "Systeminfo_CPU_Cores": "Cores CPU:",
"Systeminfo_CPU_Name": "", "Systeminfo_CPU_Name": "Nome do CPU:",
"Systeminfo_CPU_Speed": "", "Systeminfo_CPU_Speed": "Velocidade do CPU:",
"Systeminfo_CPU_Temp": "", "Systeminfo_CPU_Temp": "Temperatura do CPU:",
"Systeminfo_CPU_Vendor": "", "Systeminfo_CPU_Vendor": "Fornecedor do CPU:",
"Systeminfo_Client_Resolution": "", "Systeminfo_Client_Resolution": "Resolução do Browser:",
"Systeminfo_Client_User_Agent": "", "Systeminfo_Client_User_Agent": "Agente do Utilizador:",
"Systeminfo_General": "", "Systeminfo_General": "Geral",
"Systeminfo_General_Date": "", "Systeminfo_General_Date": "Data:",
"Systeminfo_General_Date2": "", "Systeminfo_General_Date2": "Data2:",
"Systeminfo_General_Full_Date": "", "Systeminfo_General_Full_Date": "Data Completa:",
"Systeminfo_General_TimeZone": "", "Systeminfo_General_TimeZone": "Fuso Horário:",
"Systeminfo_Memory": "", "Systeminfo_Memory": "Memória",
"Systeminfo_Memory_Total_Memory": "", "Systeminfo_Memory_Total_Memory": "Memória total:",
"Systeminfo_Memory_Usage": "", "Systeminfo_Memory_Usage": "Utilização de memória:",
"Systeminfo_Memory_Usage_Percent": "", "Systeminfo_Memory_Usage_Percent": "Memória %:",
"Systeminfo_Motherboard": "", "Systeminfo_Motherboard": "Motherboard",
"Systeminfo_Motherboard_BIOS": "", "Systeminfo_Motherboard_BIOS": "BIOS:",
"Systeminfo_Motherboard_BIOS_Date": "", "Systeminfo_Motherboard_BIOS_Date": "Data da BIOS:",
"Systeminfo_Motherboard_BIOS_Vendor": "", "Systeminfo_Motherboard_BIOS_Vendor": "Fabricante da BIOS:",
"Systeminfo_Motherboard_Manufactured": "", "Systeminfo_Motherboard_Manufactured": "Fabricado por:",
"Systeminfo_Motherboard_Name": "", "Systeminfo_Motherboard_Name": "Nome:",
"Systeminfo_Motherboard_Revision": "", "Systeminfo_Motherboard_Revision": "Revisão:",
"Systeminfo_Network": "", "Systeminfo_Network": "Rede",
"Systeminfo_Network_Accept_Encoding": "", "Systeminfo_Network_Accept_Encoding": "Aceitar codificação:",
"Systeminfo_Network_Accept_Language": "", "Systeminfo_Network_Accept_Language": "Aceitar idioma:",
"Systeminfo_Network_Connection_Port": "", "Systeminfo_Network_Connection_Port": "Porta de conexão:",
"Systeminfo_Network_HTTP_Host": "", "Systeminfo_Network_HTTP_Host": "Anfitrião HTTP:",
"Systeminfo_Network_HTTP_Referer": "", "Systeminfo_Network_HTTP_Referer": "Referenciador HTTP:",
"Systeminfo_Network_HTTP_Referer_String": "", "Systeminfo_Network_HTTP_Referer_String": "Nenhum referenciador HTTP",
"Systeminfo_Network_Hardware": "", "Systeminfo_Network_Hardware": "Hardware de Rede",
"Systeminfo_Network_Hardware_Interface_Mask": "", "Systeminfo_Network_Hardware_Interface_Mask": "Máscara de Rede",
"Systeminfo_Network_Hardware_Interface_Name": "", "Systeminfo_Network_Hardware_Interface_Name": "Nome da Interface",
"Systeminfo_Network_Hardware_Interface_RX": "", "Systeminfo_Network_Hardware_Interface_RX": "Recebido",
"Systeminfo_Network_Hardware_Interface_TX": "", "Systeminfo_Network_Hardware_Interface_TX": "Transmitido",
"Systeminfo_Network_IP": "", "Systeminfo_Network_IP": "Internet IP:",
"Systeminfo_Network_IP_Connection": "", "Systeminfo_Network_IP_Connection": "Conexão IP:",
"Systeminfo_Network_IP_Server": "", "Systeminfo_Network_IP_Server": "IP do Servidor:",
"Systeminfo_Network_MIME": "", "Systeminfo_Network_MIME": "MIME:",
"Systeminfo_Network_Request_Method": "", "Systeminfo_Network_Request_Method": "Método do Pedido:",
"Systeminfo_Network_Request_Time": "", "Systeminfo_Network_Request_Time": "Tempo do pedido:",
"Systeminfo_Network_Request_URI": "", "Systeminfo_Network_Request_URI": "URI do Pedido:",
"Systeminfo_Network_Secure_Connection": "", "Systeminfo_Network_Secure_Connection": "Ligação segura:",
"Systeminfo_Network_Secure_Connection_String": "", "Systeminfo_Network_Secure_Connection_String": "Sem (HTTP)",
"Systeminfo_Network_Server_Name": "", "Systeminfo_Network_Server_Name": "Nome do servidor:",
"Systeminfo_Network_Server_Name_String": "", "Systeminfo_Network_Server_Name_String": "Nome de servidor não encontrado",
"Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query": "Consulta de servidor:",
"Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Query_String": "Nenhuma string de consulta",
"Systeminfo_Network_Server_Version": "", "Systeminfo_Network_Server_Version": "Versão do Servidor:",
"Systeminfo_Services": "", "Systeminfo_Services": "Serviços",
"Systeminfo_Services_Description": "", "Systeminfo_Services_Description": "Descrição do Serviço",
"Systeminfo_Services_Name": "", "Systeminfo_Services_Name": "Nome do Serviço",
"Systeminfo_Storage": "", "Systeminfo_Storage": "Armazenamento",
"Systeminfo_Storage_Device": "", "Systeminfo_Storage_Device": "Dispositivo:",
"Systeminfo_Storage_Mount": "", "Systeminfo_Storage_Mount": "Ponto de montagem:",
"Systeminfo_Storage_Size": "", "Systeminfo_Storage_Size": "Tamanho:",
"Systeminfo_Storage_Type": "", "Systeminfo_Storage_Type": "Tipo:",
"Systeminfo_Storage_Usage": "", "Systeminfo_Storage_Usage": "Utilização de armazenamento",
"Systeminfo_Storage_Usage_Free": "", "Systeminfo_Storage_Usage_Free": "Livre:",
"Systeminfo_Storage_Usage_Mount": "", "Systeminfo_Storage_Usage_Mount": "Ponto de montagem:",
"Systeminfo_Storage_Usage_Total": "", "Systeminfo_Storage_Usage_Total": "Total:",
"Systeminfo_Storage_Usage_Used": "", "Systeminfo_Storage_Usage_Used": "Usado:",
"Systeminfo_System": "", "Systeminfo_System": "Sistema",
"Systeminfo_System_AVG": "", "Systeminfo_System_AVG": "Média de carregamento:",
"Systeminfo_System_Architecture": "", "Systeminfo_System_Architecture": "Arquitetura:",
"Systeminfo_System_Kernel": "", "Systeminfo_System_Kernel": "Kernel:",
"Systeminfo_System_OSVersion": "", "Systeminfo_System_OSVersion": "Sistema Operativo:",
"Systeminfo_System_Running_Processes": "", "Systeminfo_System_Running_Processes": "Processos em execução:",
"Systeminfo_System_System": "", "Systeminfo_System_System": "Sistema:",
"Systeminfo_System_Uname": "", "Systeminfo_System_Uname": "Uname:",
"Systeminfo_System_Uptime": "", "Systeminfo_System_Uptime": "Tempo de atividade:",
"Systeminfo_This_Client": "", "Systeminfo_This_Client": "Este Cliente",
"Systeminfo_USB_Devices": "", "Systeminfo_USB_Devices": "Dispositivos USB",
"TICKER_MIGRATE_TO_NETALERTX": "", "TICKER_MIGRATE_TO_NETALERTX": "⚠ Antigos locais de montagem detetados. Siga <a href=\"https://docs.netalertx.com/MIGRATION\" target=\"_blank\">este guia</a> para migrar as novas pastas <code>/data/config</code> e <code>/data/db</code> e o contentor <code>netalertx</code>.",
"TIMEZONE_description": "", "TIMEZONE_description": "Fuso horário para mostras estatísticas corretamente. Encontre o seu fuso horário <a target=\"_blank\" href=\"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\" rel=\"nofollow\">aqui</a>.",
"TIMEZONE_name": "", "TIMEZONE_name": "Fuso horário",
"UI_DEV_SECTIONS_description": "", "UI_DEV_SECTIONS_description": "Selecione quais elementos UI a esconder na página de dispositivos.",
"UI_DEV_SECTIONS_name": "", "UI_DEV_SECTIONS_name": "Esconder secções de dispositivos",
"UI_ICONS_description": "", "UI_ICONS_description": "Uma lista de ícones pré-definidos. Proceda com cautela, a maneira preferível de adicionar ícones é descrita na <a href=\"https://docs.netalertx.com/ICONS\" target=\"_blank\">Documentação de ícones</a>. Pode adicionar uma etiqueta codificada em base64 SVG HTML ou Font-awesome HTML.",
"UI_ICONS_name": "", "UI_ICONS_name": "Ícones pré-definidos",
"UI_LANG_description": "", "UI_LANG_description": "Selecione o idioma de UI preferido. Ajude a traduzir ou sugira idiomas no portal online do <a href=\"https://hosted.weblate.org/projects/pialert/core/\" target=\"_blank\">Weblate</a>.",
"UI_LANG_name": "", "UI_LANG_name": "Idioma do UI",
"UI_MY_DEVICES_description": "", "UI_MY_DEVICES_description": "Dispositivos cujo estado devem ser mostrados na vista predefinida <b>Os meus dispositivos</b>.",
"UI_MY_DEVICES_name": "", "UI_MY_DEVICES_name": "Mostrar na vista Os meus dispositivos",
"UI_NOT_RANDOM_MAC_description": "", "UI_NOT_RANDOM_MAC_description": "Prefixos mac que não devem ser marcados como dispositivos Aleatórios. Introduza, por exemplo, <code>52</code> para excluir dispositivos que começam com <code>52:xx:xx:xx:xx:xx</code> de serem marcados como dispositivos com um endereço MAC aleatório.",
"UI_NOT_RANDOM_MAC_name": "", "UI_NOT_RANDOM_MAC_name": "Não marcar como Aleatório",
"UI_PRESENCE_description": "", "UI_PRESENCE_description": "Selecione quais estatutos devem ser mostrados na <b>Presença de dispositivo</b> gráfico na página de <a href=\"/devices.php\" target=\"_blank\">Dispositivos</a>.",
"UI_PRESENCE_name": "", "UI_PRESENCE_name": "Mostrar em gráfico de presença",
"UI_REFRESH_description": "", "UI_REFRESH_description": "Introduza o número de segundo após os quais o UI atualiza. Defina para <code>0</code> para desativar.",
"UI_REFRESH_name": "", "UI_REFRESH_name": "Auto-atualizar UI",
"VERSION_description": "", "VERSION_description": "Valor auxiliar de versão ou data e hora para verificar se a aplicação foi atualizada.",
"VERSION_name": "", "VERSION_name": "Versão ou data e hora",
"WF_Action_Add": "", "WF_Action_Add": "Adicionar Ação",
"WF_Action_field": "", "WF_Action_field": "Campo",
"WF_Action_type": "", "WF_Action_type": "Tipo",
"WF_Action_value": "", "WF_Action_value": "Valor",
"WF_Actions": "", "WF_Actions": "Ações",
"WF_Add": "", "WF_Add": "Adicionar Fluxo de Trabalho",
"WF_Add_Condition": "", "WF_Add_Condition": "Adicionar Condição",
"WF_Add_Group": "", "WF_Add_Group": "Adicionar Grupo",
"WF_Condition_field": "", "WF_Condition_field": "Campo",
"WF_Condition_operator": "", "WF_Condition_operator": "Operador",
"WF_Condition_value": "", "WF_Condition_value": "Valor",
"WF_Conditions": "", "WF_Conditions": "Condições",
"WF_Conditions_logic_rules": "", "WF_Conditions_logic_rules": "Regras de lógica",
"WF_Duplicate": "", "WF_Duplicate": "Duplicar Fluxo de Trabalho",
"WF_Enabled": "", "WF_Enabled": "Fluxo de trabalho ativo",
"WF_Export": "", "WF_Export": "Exportar Fluxo de Trabalho",
"WF_Export_Copy": "", "WF_Export_Copy": "Copiar para o fluxo de trabalho abaixo e importar para onde for preciso.",
"WF_Import": "", "WF_Import": "Importar Fluxo de Trabalho",
"WF_Import_Copy": "", "WF_Import_Copy": "Colar no fluxo de trabalho que copiou anteriormente.",
"WF_Name": "", "WF_Name": "Nome do fluxo de trabalho",
"WF_Remove": "", "WF_Remove": "Remover Fluxo de Trabalho",
"WF_Remove_Copy": "", "WF_Remove_Copy": "Quer remover este fluxo de trabalho?",
"WF_Save": "", "WF_Save": "Guardar Fluxos de Trabalho",
"WF_Trigger": "", "WF_Trigger": "Acionador",
"WF_Trigger_event_type": "", "WF_Trigger_event_type": "Tipo de evento",
"WF_Trigger_type": "", "WF_Trigger_type": "Tipo de acionador",
"add_icon_event_tooltip": "", "add_icon_event_tooltip": "Adicionar novo ícone",
"add_option_event_tooltip": "", "add_option_event_tooltip": "Adicionar novo valor",
"copy_icons_event_tooltip": "", "copy_icons_event_tooltip": "Sobrescrever ícones de todos os dispositivos com o mesmo tipo de dispositivo",
"devices_old": "", "devices_old": "A atualizar…",
"general_event_description": "", "general_event_description": "O evento que ativou pode demorar algum tempo até que os processos de planos de fundo terminem. A execução acabou uma vez que a lista de execução abaixo fica vazia (Verifique o <a href='/maintenance.php#tab_Logging'>registo de erros</a> se encontrar problemas). <br/> <br/> Lista de execução:",
"general_event_title": "", "general_event_title": "A executar um evento ad-hoc",
"go_to_device_event_tooltip": "", "go_to_device_event_tooltip": "Navegar para o dispositivo",
"go_to_node_event_tooltip": "", "go_to_node_event_tooltip": "Navegar para a página de Rede do nó em questão",
"new_version_available": "", "new_version_available": "Uma versão nova está disponível.",
"report_guid": "", "report_guid": "Guid de Notificação:",
"report_guid_missing": "", "report_guid_missing": "Notificação associada não foi encontrada. Há um pequeno atraso entre notificações recentemente enviadas e as mesmas estarem disponíveis. Atualize a sua página e cache após alguns segundos. Também é possível que a notificação selecionada tenha sido eliminada durante a manutenção como especificado na definição <code>DBCLNP_NOTIFI_HIST</code>. <br/> <br/>Em vez disso, a última notificação é mostrada. A notificação em falta tem o seguinte GUID:",
"report_select_format": "", "report_select_format": "Selecionar Formato:",
"report_time": "", "report_time": "Tempo de notificação:",
"run_event_tooltip": "", "run_event_tooltip": "Ative a definição e guarde as suas mudanças primeiro antes de corrê-lo.",
"select_icon_event_tooltip": "", "select_icon_event_tooltip": "Selecionar ícone",
"settings_core_icon": "", "settings_core_icon": "fa-solid fa-gem",
"settings_core_label": "", "settings_core_label": "Core",
"settings_device_scanners": "", "settings_device_scanners": "Scaneadores de dispositivos usados para descobrir dispositivos que escrevem para a tabela da base de dados CurrentScan.",
"settings_device_scanners_icon": "", "settings_device_scanners_icon": "fa-solid fa-magnifying-glass-plus",
"settings_device_scanners_info": "", "settings_device_scanners_info": "Carregar mais scaneadores de dispositivos com a definição <a href=\"/settings.php#LOADED_PLUGINS\">LOADED_PLUGINS</a>",
"settings_device_scanners_label": "", "settings_device_scanners_label": "Scaneadores de dispositivos",
"settings_enabled": "", "settings_enabled": "Definições ativas",
"settings_enabled_icon": "", "settings_enabled_icon": "fa-solid fa-toggle-on",
"settings_expand_all": "", "settings_expand_all": "Expandir todos",
"settings_imported": "", "settings_imported": "Na última vez, as definições foram importadas a partir do ficheiro app.conf",
"settings_imported_label": "", "settings_imported_label": "Definições importadas",
"settings_missing": "", "settings_missing": "Nem todas as configurações foram carregadas! Carga elevada na base de dados ou na sequência de começo da aplicação. Clique no botão 🔄 de atualizar no topo.",
"settings_missing_block": "", "settings_missing_block": "Erro: Definições não carregadas corretamente. Clique no botão 🔄 de atualizar no topo ou, alternativamente, verifique o registo do navegador para detalhes (F12).",
"settings_old": "", "settings_old": "A importar definições e a reinicializar…",
"settings_other_scanners": "", "settings_other_scanners": "Outros plugins de scaneadores que não são do dispositivo estão atualmente ativos.",
"settings_other_scanners_icon": "", "settings_other_scanners_icon": "fa-solid fa-recycle",
"settings_other_scanners_label": "", "settings_other_scanners_label": "Outros scaneadores",
"settings_publishers": "", "settings_publishers": "Gateways de notificação ativados - editores que enviarão uma notificação de acordo com as suas definições.",
"settings_publishers_icon": "", "settings_publishers_icon": "fa-solid fa-paper-plane",
"settings_publishers_info": "", "settings_publishers_info": "Carregar mais Editores com a definição <a href=\"/settings.php#LOADED_PLUGINS\">LOADED_PLUGINS</a>",
"settings_publishers_label": "", "settings_publishers_label": "Editores",
"settings_readonly": "", "settings_readonly": "Não foi possível LÊR ou ESCREVER na <code>app.conf</code>. Tente reiniciar o contentoer e ler a <a href=\"https://docs.netalertx.com/FILE_PERMISSIONS\" target=\"_blank\">documentação de permissões de ficheiro</a>",
"settings_saved": "", "settings_saved": "<br/>Definições guardadas.<br/> A recarregar...<br/><i class=\"ion ion-ios-loop-strong fa-spin fa-2x fa-fw\"></i><br/>",
"settings_system_icon": "", "settings_system_icon": "fa-solid fa-gear",
"settings_system_label": "", "settings_system_label": "Sistema",
"settings_update_item_warning": "", "settings_update_item_warning": "Atualize o valor abaixo. Tenha cuidado em seguir o formato anterior. <b>Validação não é efetuada.</b>",
"test_event_tooltip": "Guarde as alterações antes de testar as definições." "test_event_tooltip": "Guarde as alterações antes de testar as definições."
} }

View File

@@ -27,7 +27,7 @@
"AppEvents_ObjectType": "Тип объекта", "AppEvents_ObjectType": "Тип объекта",
"AppEvents_Plugin": "Плагин", "AppEvents_Plugin": "Плагин",
"AppEvents_Type": "Тип", "AppEvents_Type": "Тип",
"BACKEND_API_URL_description": "Используется для обеспечения связи между фронтендом и бэкендом. По умолчанию это значение установлено на <code>/server</code> и, как правило, не должно изменяться.", "BACKEND_API_URL_description": "Используется для обеспечения связи между фронтендом и бэкендом. По умолчанию это значение установлено на <code>/server</code> и, как правило, не должно изменяться.",
"BACKEND_API_URL_name": "URL-адрес серверного API", "BACKEND_API_URL_name": "URL-адрес серверного API",
"BackDevDetail_Actions_Ask_Run": "Вы хотите выполнить действие?", "BackDevDetail_Actions_Ask_Run": "Вы хотите выполнить действие?",
"BackDevDetail_Actions_Not_Registered": "Действие не зарегистрировано:· ", "BackDevDetail_Actions_Not_Registered": "Действие не зарегистрировано:· ",

View File

@@ -249,8 +249,8 @@
"Device_TableHead_Name": "名字", "Device_TableHead_Name": "名字",
"Device_TableHead_NetworkSite": "网络站点", "Device_TableHead_NetworkSite": "网络站点",
"Device_TableHead_Owner": "所有者", "Device_TableHead_Owner": "所有者",
"Device_TableHead_ParentRelType": "关系类型", "Device_TableHead_ParentRelType": "关系",
"Device_TableHead_Parent_MAC": "父网络节点", "Device_TableHead_Parent_MAC": "父节点",
"Device_TableHead_Port": "端口", "Device_TableHead_Port": "端口",
"Device_TableHead_PresentLastScan": "检测", "Device_TableHead_PresentLastScan": "检测",
"Device_TableHead_ReqNicsOnline": "需要网卡在线", "Device_TableHead_ReqNicsOnline": "需要网卡在线",
@@ -407,7 +407,7 @@
"Maintenance_InitCheck": "初步检查", "Maintenance_InitCheck": "初步检查",
"Maintenance_InitCheck_Checking": "查看中…", "Maintenance_InitCheck_Checking": "查看中…",
"Maintenance_InitCheck_QuickSetupGuide": "确保您遵循<a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">快速设置指南</a>。", "Maintenance_InitCheck_QuickSetupGuide": "确保您遵循<a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">快速设置指南</a>。",
"Maintenance_InitCheck_Success": "应用程序启动成功!", "Maintenance_InitCheck_Success": "应用程序初始化成功!",
"Maintenance_ReCheck": "重试检查", "Maintenance_ReCheck": "重试检查",
"Maintenance_Running_Version": "安装版本", "Maintenance_Running_Version": "安装版本",
"Maintenance_Status": "状态", "Maintenance_Status": "状态",

View File

@@ -52,7 +52,7 @@
{ "elementType": "select", "elementOptions": [], "transformers": [] } { "elementType": "select", "elementOptions": [], "transformers": [] }
] ]
}, },
"default_value": "before_name_updates", "default_value": "disabled",
"options": [ "options": [
"disabled", "disabled",
"before_name_updates", "before_name_updates",

View File

@@ -209,53 +209,83 @@ def execute_fping(timeout, args, all_devices, plugin_objects, subnets, interface
def run_fping(targets): def run_fping(targets):
targets = expand_subnets(targets) targets = expand_subnets(targets)
if not targets: if not targets:
return [] return []
is_ipv6 = any(':' in t for t in targets) ipv4_targets = [t for t in targets if ':' not in t]
cmd = ["fping", "-a"] + args.split() + targets ipv6_targets = [t for t in targets if ':' in t]
if is_ipv6:
cmd.insert(1, "-6") # insert -6 after "fping"
if interfaces: def run_family(family_targets, ipv6=False):
cmd += ["-I", ",".join(interfaces)] if not family_targets:
return []
mylog("verbose", [f"[{pluginName}] fping cmd: {' '.join(cmd)}"]) interface_list = interfaces if interfaces else [None]
try: all_results = []
output = subprocess.check_output( seen_ips = set()
cmd,
stderr=subprocess.DEVNULL,
timeout=timeout,
text=True
)
except subprocess.CalledProcessError as e:
output = e.output
mylog("none", [f"[{pluginName}] fping returned non-zero exit code, reading alive hosts anyway"])
except subprocess.TimeoutExpired:
mylog("none", [f"[{pluginName}] fping timeout"])
return []
results = [] for interface in interface_list:
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Skip unreachable, timed out, or 100% packet loss cmd = ["fping", "-a"]
if "unreachable" in line.lower() or "timed out" in line.lower() or "100% loss" in line.lower():
mylog("debug", [f"[{pluginName}] fping skipping {line}"])
continue
match = ip_regex.search(line) if ipv6:
if match: cmd.append("-6")
ip = match.group(0)
mylog("debug", [f"[{pluginName}] adding {ip} from {line}"])
results.append((ip, line))
else:
mylog("verbose", [f"[{pluginName}] fping non-parseable {line}"])
return results cmd += args.split()
if interface:
cmd += ["-I", interface]
cmd += family_targets
mylog("verbose", [f"[{pluginName}] fping cmd: {' '.join(cmd)}"])
try:
output = subprocess.check_output(
cmd,
stderr=subprocess.DEVNULL,
timeout=timeout,
text=True
)
except subprocess.CalledProcessError as e:
output = e.output
mylog("none", [
f"[{pluginName}] fping returned non-zero exit code "
f"on interface {interface}, reading alive hosts anyway"
])
except subprocess.TimeoutExpired:
mylog("none", [
f"[{pluginName}] fping timeout on interface {interface}"
])
continue
for line in output.splitlines():
line = line.strip()
if not line:
continue
lower_line = line.lower()
if "unreachable" in lower_line or "timed out" in lower_line or "100% loss" in lower_line:
continue
match = ip_regex.search(line)
if match:
ip = match.group(0)
if ip in seen_ips:
continue
seen_ips.add(ip)
all_results.append((ip, line))
return all_results
return run_family(ipv4_targets, ipv6=False) + run_family(ipv6_targets, ipv6=True)
# Scan subnets # Scan subnets
mylog("verbose", [f"[{pluginName}] run_fping: subnets {subnets}"]) mylog("verbose", [f"[{pluginName}] run_fping: subnets {subnets}"])

99
front/plugins/kea_api/README.md Executable file
View File

@@ -0,0 +1,99 @@
## Overview
A plugin allowing for importing devices from the Kea DHCP API.
https://www.isc.org/kea/
And specifically:
https://kea.readthedocs.io/en/kea-2.6.3/api.html#lease4-get-all
### Usage
To enable the API, first you want to add something like this to your main kea configuration (this is for debian 13):
```json
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"hooks-libraries": [
{
"library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so"
}
],
```
And you need to install kea-ctrl-agent, with a config that looks something like this:
```json
{
"Control-agent": {
"http-host": "127.0.0.1",
"http-port": 8000,
"authentication": {
"type": "basic",
"realm": "Kea Control Agent",
"directory": "/etc/kea",
"clients": [
{
"user": "kea-api",
"password-file": "kea-api-password"
}
]
},
"control-sockets": {
"dhcp4": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
}
},
"loggers": [
{
"name": "kea-ctrl-agent",
"output-options": [
{
"output": "stdout",
"pattern": "%-5p %m\n"
}
],
"severity": "INFO",
"debuglevel": 0
}
]
}
}
```
You will need to configure the plugin with the URL to the API, and the username and password configured above (from kea-api-password file in the example)
#### Required Settings
These settings are required, besides the common device scanner settings:
- **Kea Control Agent URL** (`KEALSS_URL`): The full URL, including port number, to the Kea API.
- Default: `http://127.0.0.1:8000`
- This mirrors what you set up in the kea-ctrl-agent configuration.
- **Basic Auth Username** (`KEALSS_USER`): The user to use for authenticating with the Kea API.
- Default: `kea-api`
- This mirrors what you set up in the kea-ctrl-agent configuration.
- **Basic Auth Password** (`KEALSS_PASS`): The password to use for authenticating with the Kea API.
- This mirrors what you set up in the kea-ctrl-agent configuration.
- When using a password file, it should be the content of the password file.
### Notes
- This was tested on a basic Debian 13 install.
- When you install kea-ctrl-agent, it should ask you about creating a password.
- It's possible to run kea-ctrl-agent without password, but it's not recommended and at the moment we don't support that.
- I may provide some minimal support, if you ask nicely :)
- Version: 1.0.0
- Author: `void-spark`
- Release Date: `11/05/2026`

View File

@@ -0,0 +1,455 @@
{
"code_name": "kea_api",
"unique_prefix": "KEALSS",
"plugin_type": "device_scanner",
"execution_order" : "Layer_3",
"enabled": true,
"data_source": "script",
"data_filters": [
{
"compare_column": "objectPrimaryId",
"compare_operator": "==",
"compare_field_id": "txtMacFilter",
"compare_js_template": "'{value}'.toString()",
"compare_use_quotes": true
}
],
"show_ui": true,
"localized": ["display_name", "description", "icon"],
"mapped_to_table": "CurrentScan",
"display_name": [{"language_code": "en_us", "string": "Kea DHCP API"}],
"icon": [{"language_code": "en_us", "string": "<i class=\"fa-solid fa-hourglass-half\"></i>"}],
"description": [{"language_code": "en_us", "string": "Imports leases via Kea Control Agent REST API"}],
"database_column_definitions": [
{
"column": "index",
"css_classes": "col-sm-2",
"show": true,
"type": "none",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Index"}]
},
{
"column": "objectPrimaryId",
"mapped_to_column": "scanMac",
"css_classes": "col-sm-2",
"show": true,
"type": "device_mac",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "MAC address"}]
},
{
"column": "objectSecondaryId",
"mapped_to_column": "scanLastIP",
"css_classes": "col-sm-2",
"show": true,
"type": "device_ip",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "IP" }]
},
{
"column": "dateTimeCreated",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Created"}]
},
{
"column": "dateTimeChanged",
"mapped_to_column": "scanLastConnection",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Changed"}]
},
{
"column": "watchedValue1",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Is active"}]
},
{
"column": "watchedValue2",
"mapped_to_column": "scanName",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Hostname"}]
},
{
"column": "watchedValue4",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "State"}]
},
{
"column": "userData",
"css_classes": "col-sm-2",
"show": false,
"type": "textbox_save",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Comments"}]
},
{
"column": "Dummy",
"mapped_to_column": "scanSourcePlugin",
"mapped_to_column_data": {
"value": "KEALSS"
},
"css_classes": "col-sm-2",
"show": false,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Scan method"}]
},
{
"column": "status",
"css_classes": "col-sm-1",
"show": true,
"type": "replace",
"default_value": "",
"options": [
{
"equals": "watched-not-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
},
{
"equals": "watched-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
},
{
"equals": "new",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
},
{
"equals": "missing-in-last-scan",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
}
],
"localized": ["name"],
"name": [{"language_code": "en_us", "string": "Status"}]
}
],
"settings": [
{
"function": "RUN",
"events": ["run"],
"type": {
"dataType": "string",
"elements": [{"elementType": "select", "elementOptions": [], "transformers": []}]
},
"default_value": "disabled",
"options": [
"disabled",
"once",
"schedule",
"always_after_scan",
"on_new_device"
],
"localized": ["name", "description"],
"name": [{"language_code": "en_us", "string": "When to run"}],
"description": [
{
"language_code": "en_us",
"string": "Enable import of devices from <code>Kea API</code>. If you select <code>schedule</code> the scheduling settings from below are applied. If you select <code>once</code> the scan is run only once on start of the application (container) or after you update your settings. ⚠ Use the same schedule if you have multiple <i class=\"fa-solid fa-magnifying-glass-plus\"></i> Device scanners enabled."
}
]
},
{
"function": "CMD",
"type": {
"dataType": "string",
"elements": [
{ "elementType": "input", "elementOptions": [], "transformers": [] }
]
},
"default_value": "python3 /app/front/plugins/kea_api/script.py",
"options": [],
"localized": ["name", "description"],
"name": [{"language_code": "en_us", "string": "Command"}],
"description": [{"language_code": "en_us", "string": "Command to run"}]
},
{
"function": "URL",
"localized": ["name", "description"],
"name": [{"language_code": "en_us", "string": "API URL"}],
"description": [{"language_code": "en_us", "string": "Kea Control Agent URL"}],
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [],
"transformers": []
}
]
},
"default_value": "http://127.0.0.1:8000"
},
{
"function": "USER",
"localized": ["name", "description"],
"name": [{"language_code": "en_us", "string": "API User"}],
"description": [{"language_code": "en_us", "string": "Basic Auth Username"}],
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [],
"transformers": []
}
]
},
"default_value": "kea-api"
},
{
"function": "PASS",
"localized": ["name", "description"],
"name": [{"language_code": "en_us", "string": "API Password"}],
"description": [{"language_code": "en_us", "string": "Basic Auth Password"}],
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [{"type": "password"}],
"transformers": []
}
]
},
"default_value": ""
},
{
"function": "RUN_SCHD",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "span",
"elementOptions": [
{
"cssClasses": "input-group-addon validityCheck"
},
{
"getStringKey": "Gen_ValidIcon"
}
],
"transformers": []
},
{
"elementType": "input",
"elementOptions": [
{
"focusout": "validateRegex(this)"
},
{
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ="
}
],
"transformers": []
}
]
},
"default_value": "0 2 * * *",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Schedule"
}
],
"description": [
{
"language_code": "en_us",
"string": "Only enabled if you select <code>schedule</code> in the <a href=\"#KEALSS_RUN\"><code>KEALSS_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. Will be run NEXT time the time passes. <br/> It's recommended to use the same schedule interval for all plugins responsible for discovering new devices."
}
]
},
{
"function": "RUN_TIMEOUT",
"type": {
"dataType": "integer",
"elements": [
{
"elementType": "input",
"elementOptions": [{ "type": "number" }],
"transformers": []
}
]
},
"default_value": 10,
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Run timeout"
}
],
"description": [
{
"language_code": "en_us",
"string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
"transformers": []
}
]
},
"default_value": ["devMac", "devLastIP"],
"options": [
"devMac",
"devLastIP"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set always columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP",
"devName",
"devSourcePlugin"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Set empty columns"
}
],
"description": [
{
"language_code": "en_us",
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
}
]
},
{
"function": "WATCH",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
"transformers": []
}
]
},
"default_value": ["watchedValue1", "watchedValue4"],
"options": [
"watchedValue1",
"watchedValue2",
"watchedValue4"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Watched"
}
],
"description": [
{
"language_code": "en_us",
"string": "Send a notification if selected values change. Use <code>CTRL + Click</code> to select/deselect. <ul> <li><code>watchedValue1</code> is Active </li><li><code>watchedValue2</code> is Hostname </li><li><code>watchedValue4</code> is State</li></ul>"
}
]
},
{
"function": "REPORT_ON",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
"transformers": []
}
]
},
"default_value": ["new", "watched-changed"],
"options": [
"new",
"watched-changed",
"watched-not-changed",
"missing-in-last-scan"
],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Report on"
}
],
"description": [
{
"language_code": "en_us",
"string": "Send a notification only on these statuses. <code>new</code> means a new unique (unique combination of PrimaryId and SecondaryId) object was discovered. <code>watched-changed</code> means that selected <code>watchedValueN</code> columns changed."
}
]
}
]
}

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import os
import sys
import requests
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../server'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../plugins'))
from plugin_helper import Plugin_Objects, mylog, handleEmpty, is_mac
from helper import get_setting_value
from const import logPath
pluginName = 'KEALSS'
LOG_PATH = logPath + '/plugins'
LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log')
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
plugin_objects = Plugin_Objects(RESULT_FILE)
def main():
try:
url = get_setting_value(f'{pluginName}_URL')
user = get_setting_value(f'{pluginName}_USER')
password = get_setting_value(f'{pluginName}_PASS')
timeout = get_setting_value(f'{pluginName}_RUN_TIMEOUT')
mylog('verbose', [f'[{pluginName}] Querying Kea API at {url}'])
payload = {'command': 'lease4-get-all', 'service': ['dhcp4']}
response = requests.post(url, json=payload, auth=(user, password), timeout=max(1, timeout - 1))
response.raise_for_status()
data = response.json()
count = 0
for entry in data:
text = entry.get('text', '[API provided no text]')
# Result: 0 (success), 1 (error), or 3 (empty).
if entry['result'] == 0:
leases = entry['arguments']['leases']
for lease in leases:
mac = lease['hw-address']
state = lease['state']
if is_mac(mac):
plugin_objects.add_object(
primaryId = mac,
secondaryId = lease['ip-address'],
# Active or not, similar to watched1 of DHCPLSS plugin
watched1 = state == 0,
watched2 = lease['hostname'],
watched3 = None,
# Default (or assigned) (0), declined (1), expired-reclaimed (2), released (3), and registered (4)).
watched4 = state,
extra = None,
foreignKey = mac
)
count += 1
plugin_objects.write_result_file()
mylog('verbose', [f'[{pluginName}] Kea API response: {text}'])
mylog('verbose', [f'[{pluginName}] Successfully imported {count} devices reported by Kea API'])
elif entry['result'] == 1:
mylog('none', [f'[{pluginName}] ⚠ ERROR: Kea API indicated error: {text}'])
elif entry['result'] == 3:
mylog('verbose', [f'[{pluginName}] Kea API indicates no entries found: {text}'])
except Exception as e:
mylog('none', [f'[{pluginName}] ⚠ ERROR: {str(e)}'])
if __name__ == '__main__':
main()

View File

@@ -12,6 +12,7 @@ from logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # noqa: E402 [flake8 lint suppression] from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from const import logPath # noqa: E402 [flake8 lint suppression] from const import logPath # noqa: E402 [flake8 lint suppression]
from messaging.in_app import remove_old # noqa: E402 [flake8 lint suppression] from messaging.in_app import remove_old # noqa: E402 [flake8 lint suppression]
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
from pytz import timezone # noqa: E402 [flake8 lint suppression] from pytz import timezone # noqa: E402 [flake8 lint suppression]
@@ -69,6 +70,21 @@ def main():
mylog('verbose', [f'[{pluginName}] Cleaning in-app notification history']) mylog('verbose', [f'[{pluginName}] Cleaning in-app notification history'])
remove_old(MAINT_NOTI_LENGTH) remove_old(MAINT_NOTI_LENGTH)
# Delete processed sync artefact files older than 24 hours.
# These are created by the SYNC plugin (Mode 3) when it renames received
# device JSON files to processed_*.log after processing. They have no value
# once processed and are not cleaned up anywhere else.
_PROCESSED_MAX_AGE_SECS = 24 * 3600
now = timeNowUTC(as_string=False).timestamp()
deleted = 0
for fname in os.listdir(LOG_PATH):
if fname.startswith('processed_') and fname.endswith('.log'):
fpath = os.path.join(LOG_PATH, fname)
if os.path.isfile(fpath) and (now - os.path.getmtime(fpath)) > _PROCESSED_MAX_AGE_SECS:
os.remove(fpath)
deleted += 1
mylog('verbose', [f'[{pluginName}] Deleted {deleted} processed sync artefact file(s) from {LOG_PATH}'])
return 0 return 0

View File

@@ -613,7 +613,8 @@
"options": [ "options": [
"devMac", "devMac",
"devName", "devName",
"devVendor" "devVendor",
"devLastIP"
], ],
"localized": ["name", "description"], "localized": ["name", "description"],
"name": [ "name": [
@@ -810,7 +811,7 @@
"column": "Dummy", "column": "Dummy",
"mapped_to_column": "scanSourcePlugin", "mapped_to_column": "scanSourcePlugin",
"mapped_to_column_data": { "mapped_to_column_data": {
"value": "sync" "value": "SYNC"
}, },
"css_classes": "col-sm-2", "css_classes": "col-sm-2",
"show": false, "show": false,

View File

@@ -175,23 +175,35 @@ def main():
if file_name != 'last_result.log': if file_name != 'last_result.log':
mylog('verbose', [f'[{pluginName}] Processing: "{file_name}"']) mylog('verbose', [f'[{pluginName}] Processing: "{file_name}"'])
# make sure the file has the correct name (e.g last_result.encoded.Node_1.1.log) to skip any otehr plugin files # Only process sync artifacts:
if len(file_name.split('.')) > 2: # PUSH mode (decoded): last_result.PLUGIN.decoded.NodeName.N.log (6 parts)
# Extract node name from either last_result.decoded.Node_1.1.log or last_result.Node_1.log # PULL mode: last_result.NodeName.log (3 parts, valid JSON)
parts = file_name.split('.') # Local plugin result files (last_result.ARPSCAN.log) are also 3 parts but
# If decoded/encoded file, node name is at index 2; otherwise at index 1 # are pipe-delimited — catch and skip them via the JSONDecodeError guard below.
syncHubNodeName = parts[2] if 'decoded' in file_name or 'encoded' in file_name else parts[1] parts = file_name.split('.')
if len(parts) > 2:
# Extract node name:
# decoded/encoded: last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
# pull mode: last_result.NodeName.log → parts[1]
if 'decoded' in file_name or 'encoded' in file_name:
syncHubNodeName = parts[3]
else:
syncHubNodeName = parts[1]
file_path = f"{LOG_PATH}/{file_name}" file_path = f"{LOG_PATH}/{file_name}"
with open(file_path, 'r') as f: try:
data = json.load(f) with open(file_path, 'r') as f:
data = json.load(f)
for device in data['data']: for device in data['data']:
device['devMac'] = str(device['devMac']).lower() device['devMac'] = str(device['devMac']).lower()
if device['devMac'].lower() not in unique_mac_addresses: if device['devMac'].lower() not in unique_mac_addresses:
device['devSyncHubNode'] = syncHubNodeName device['devSyncHubNode'] = syncHubNodeName
unique_mac_addresses.add(device['devMac'].lower()) unique_mac_addresses.add(device['devMac'].lower())
device_data.append(device) device_data.append(device)
except (json.JSONDecodeError, KeyError):
mylog('verbose', [f'[{pluginName}] Skipping "{file_name}" - not a valid sync JSON payload'])
continue
# Rename the file to "processed_" + current name # Rename the file to "processed_" + current name
new_file_name = f"processed_{file_name}" new_file_name = f"processed_{file_name}"
@@ -298,7 +310,7 @@ def send_data(api_token, file_content, encryption_key, file_path, node_name, pre
final_endpoint = hub_url + endpoint final_endpoint = hub_url + endpoint
try: try:
response = requests.post(final_endpoint, data=data, headers=headers, timeout=5) response = requests.post(final_endpoint, json=data, headers=headers, timeout=5)
mylog('verbose', [f'[{pluginName}] Tried endpoint: {final_endpoint}, status: {response.status_code}']) mylog('verbose', [f'[{pluginName}] Tried endpoint: {final_endpoint}, status: {response.status_code}'])
if response.status_code == 200: if response.status_code == 200:

View File

@@ -321,7 +321,7 @@ function initializeCalendar () {
resourceRender: function (resourceObj, labelTds, bodyTds) { resourceRender: function (resourceObj, labelTds, bodyTds) {
labelTds.find('span.fc-cell-text').html ( labelTds.find('span.fc-cell-text').html (
'<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ resourceObj.title +'</a></b>'); '<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ encodeSpecialChars(resourceObj.title) +'</a></b>');
// Resize heihgt // Resize heihgt
// $(".fc-content table tbody tr .fc-widget-content div").addClass('fc-resized-row'); // $(".fc-content table tbody tr .fc-widget-content div").addClass('fc-resized-row');

View File

@@ -79,6 +79,7 @@ nav:
- Device Display Settings: DEVICE_DISPLAY_SETTINGS.md - Device Display Settings: DEVICE_DISPLAY_SETTINGS.md
- Session Info: SESSION_INFO.md - Session Info: SESSION_INFO.md
- Field Lock/Unlock: DEVICE_FIELD_LOCK.md - Field Lock/Unlock: DEVICE_FIELD_LOCK.md
- Device Source Fields: DEVICE_SOURCE_FIELDS.md
- Icons and Topology: - Icons and Topology:
- Icons: ICONS.md - Icons: ICONS.md
- Network Topology: NETWORK_TREE.md - Network Topology: NETWORK_TREE.md

View File

@@ -185,9 +185,6 @@ def is_authorized():
return is_authorized_result return is_authorized_result
@app.route('/mcp/sse', methods=['GET', 'POST', 'OPTIONS']) @app.route('/mcp/sse', methods=['GET', 'POST', 'OPTIONS'])
def api_mcp_sse(): def api_mcp_sse():
if not is_authorized(): if not is_authorized():

View File

@@ -893,9 +893,10 @@ class CreateNotificationRequest(BaseModel):
class SyncPushRequest(BaseModel): class SyncPushRequest(BaseModel):
"""Request to push data to sync.""" """Request to push data to sync."""
data: dict = Field(..., description="Data to sync") data: str = Field(..., description="Encrypted data payload (ciphertext string)")
node_name: str = Field(..., description="Name of the node sending data") node_name: str = Field(..., description="Name of the node sending data")
plugin: str = Field(..., description="Plugin identifier") plugin: str = Field(..., description="Plugin identifier")
file_path: Optional[str] = Field(None, description="Source file path on the node")
class SyncPullResponse(BaseResponse): class SyncPullResponse(BaseResponse):

View File

@@ -1,13 +1,16 @@
import os import os
import base64 import base64
from flask import jsonify, request from flask import jsonify, request
from logger import mylog from logger import mylog, Logger
from helper import get_setting_value from helper import get_setting_value
from utils.datetime_utils import timeNowUTC from utils.datetime_utils import timeNowUTC
from messaging.in_app import write_notification from messaging.in_app import write_notification
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
# Make sure log level is initialized correctly
lggr = Logger(get_setting_value('LOG_LEVEL'))
def handle_sync_get(): def handle_sync_get():
"""Handle GET requests for SYNC (NODE → HUB).""" """Handle GET requests for SYNC (NODE → HUB)."""
@@ -28,7 +31,11 @@ def handle_sync_get():
response_data = base64.b64encode(raw_data).decode("utf-8") response_data = base64.b64encode(raw_data).decode("utf-8")
write_notification("[Plugin: SYNC] Data sent", "info", timeNowUTC()) message = "[Plugin: SYNC] Data sent"
mylog('verbose', [message])
if lggr.isAbove('verbose'):
write_notification(message, 'info', timeNowUTC())
return jsonify({ return jsonify({
"node_name": get_setting_value("SYNC_node_name"), "node_name": get_setting_value("SYNC_node_name"),
"status": 200, "status": 200,
@@ -40,9 +47,10 @@ def handle_sync_get():
def handle_sync_post(): def handle_sync_post():
"""Handle POST requests for SYNC (HUB receiving from NODE).""" """Handle POST requests for SYNC (HUB receiving from NODE)."""
data = request.form.get("data", "") body = request.get_json(silent=True) or {}
node_name = request.form.get("node_name", "") data = body.get("data", "")
plugin = request.form.get("plugin", "") node_name = body.get("node_name", "")
plugin = body.get("plugin", "")
storage_path = INSTALL_PATH + "/log/plugins" storage_path = INSTALL_PATH + "/log/plugins"
os.makedirs(storage_path, exist_ok=True) os.makedirs(storage_path, exist_ok=True)

View File

@@ -488,7 +488,7 @@ def importConfigs(pm, db, all_plugins):
# Plugins START # Plugins START
# ----------------- # -----------------
# necessary_plugins = ['UI', 'CUSTPROP', 'CLOUD' ,'DBCLNP', 'INTRNT','MAINT','NEWDEV', 'SETPWD', 'SYNC', 'VNDRPDT', 'WORKFLOWS'] # necessary_plugins = ['UI', 'CUSTPROP', 'HEARTBEAT' ,'DBCLNP', 'INTRNT','MAINT','NEWDEV', 'SETPWD', 'SYNC', 'VNDRPDT', 'WORKFLOWS']
necessary_plugins = [ necessary_plugins = [
"UI", "UI",
"CUSTPROP", "CUSTPROP",
@@ -724,13 +724,13 @@ def importConfigs(pm, db, all_plugins):
write_notification( write_notification(
f"""[Upgrade]: App upgraded from <code>{prev_version}</code> to \ f"""[Upgrade]: App upgraded from <code>{prev_version}</code> to \
<code>{new_version}</code> 🚀 Please clear the cache: \ <code>{new_version}</code> <i class="fa-solid fa-rocket"></i> Please clear the cache: \
<ol> <li>Click OK below</li> \ <ol> <li>Click OK below</li> \
<li>Clear the browser cache (shift + browser refresh button)</li> \ <li>Clear the browser cache (shift + browser refresh button)</li> \
<li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li>\ <li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li>\
<li>Go to Settings and click Save</li> </ol>\ <li>Go to Settings and click Save</li> </ol>\
Check out new features and what has changed in the \ Check out new features and what has changed in the \
<a href="https://github.com/netalertx/NetAlertX/releases" target="_blank">📓 release notes</a>.""", <a href="https://github.com/netalertx/NetAlertX/releases" target="_blank"><i class="fa-solid fa-file-pen"></i> release notes</a>.""",
'interrupt', 'interrupt',
timeNowUTC() timeNowUTC()
) )

View File

@@ -35,11 +35,10 @@ from messaging.notification_sections import ( # noqa: E402 [flake8 lint suppres
) )
import conf # noqa: E402 [flake8 lint suppression] import conf # noqa: E402 [flake8 lint suppression]
# =============================================================================== # ===============================================================================
# Timezone conversion # Timezone conversion
# =============================================================================== # ===============================================================================
def get_datetime_fields_from_columns(column_names): def get_datetime_fields_from_columns(column_names):
return [ return [
col for col in column_names col for col in column_names
@@ -81,6 +80,7 @@ def apply_timezone(data, fields):
for field in fields: for field in fields:
value = row.get(field) value = row.get(field)
if not value: if not value:
continue continue
@@ -226,11 +226,24 @@ def get_notifications(db):
try: try:
json_obj = db.get_table_as_json(sqlQuery, parameters) json_obj = db.get_table_as_json(sqlQuery, parameters)
data = apply_timezone_to_json(json_obj, section)
except Exception as e: except Exception as e:
mylog("minimal", [f"[Notification] DB error in section {section}: ", e]) mylog("none", [f"[Notification] apply_timezone failed for section {section}: ", e])
# fallback: preserve raw DB payload instead of dropping section
try:
data = json_obj.json.get("data", [])
except Exception:
data = []
final_json[section] = data
final_json[f"{section}_meta"] = {
"title": SECTION_TITLES.get(section, section),
"columnNames": getattr(json_obj, "columnNames", [])
}
continue continue
final_json[section] = json_obj.json.get("data", []) final_json[section] = data
final_json[f"{section}_meta"] = { final_json[f"{section}_meta"] = {
"title": SECTION_TITLES.get(section, section), "title": SECTION_TITLES.get(section, section),
"columnNames": getattr(json_obj, "columnNames", []) "columnNames": getattr(json_obj, "columnNames", [])

View File

@@ -53,6 +53,15 @@ class DeviceInstance:
WHERE devName IN ("(unknown)", "(name not found)", "") WHERE devName IN ("(unknown)", "(name not found)", "")
""") """)
def getResolvable(self):
"""Return devices that have a name already set but are not USER/LOCKED protected.
Used by SET_ALWAYS name-resolution plugins to re-resolve existing names."""
return self._fetchall("""
SELECT * FROM Devices
WHERE devName NOT IN ("(unknown)", "(name not found)", "")
AND COALESCE(devNameSource, '') NOT IN ('USER', 'LOCKED')
""")
def getValueWithMac(self, column_name, devMac): def getValueWithMac(self, column_name, devMac):
row = self._fetchone(f""" row = self._fetchone(f"""
SELECT {column_name} FROM Devices WHERE devMac = ? SELECT {column_name} FROM Devices WHERE devMac = ?

View File

@@ -543,10 +543,12 @@ def execute_plugin(db, all_plugins, plugin):
# Append the final parameters to sqlParams # Append the final parameters to sqlParams
sqlParams.append(tuple(base_params)) sqlParams.append(tuple(base_params))
# keep current instance log file, delete all from other nodes # Delete only files received from other nodes (identified by .encoded. or .decoded. in the name).
if filename != "last_result.log" and os.path.exists(full_path): # Local result files (e.g. last_result.ARPSCAN.log) are overwritten each cycle and must
os.remove(full_path) # DEBUG:TODO uncomment 🐛 # survive so the SYNC plugin can read and forward them to the hub.
mylog("verbose", f"[Plugins] Processed and deleted file: {full_path} ") if (".encoded." in filename or ".decoded." in filename) and os.path.exists(full_path):
os.remove(full_path)
mylog("verbose", f"[Plugins] Processed and deleted node-sync file: {full_path}")
# app-db-query # app-db-query
if plugin["data_source"] == "app-db-query": if plugin["data_source"] == "app-db-query":

View File

@@ -971,7 +971,7 @@ def update_devices_names(pm):
(resolver.resolve_nbtlookup, "NBTSCAN"), (resolver.resolve_nbtlookup, "NBTSCAN"),
] ]
def resolve_devices(devices, resolve_both_name_and_fqdn=True): def resolve_devices(devices, resolve_both_name_and_fqdn=True, active_labels=None):
""" """
Attempts to resolve device names and/or FQDNs using available strategies. Attempts to resolve device names and/or FQDNs using available strategies.
@@ -979,6 +979,10 @@ def update_devices_names(pm):
devices (list): List of devices to resolve. devices (list): List of devices to resolve.
resolve_both_name_and_fqdn (bool): If True, resolves both name and FQDN. resolve_both_name_and_fqdn (bool): If True, resolves both name and FQDN.
If False, resolves only FQDN. If False, resolves only FQDN.
active_labels (set|None): If provided, only strategies whose label is in
this set are tried. Used by Step 1b to prevent
non-SET_ALWAYS plugins from short-circuiting
SET_ALWAYS ones.
Returns: Returns:
recordsToUpdate (list): List of recordsToUpdate (list): List of
@@ -997,6 +1001,8 @@ def update_devices_names(pm):
# Attempt each resolution strategy in order # Attempt each resolution strategy in order
for resolve_fn, label in strategies: for resolve_fn, label in strategies:
if active_labels is not None and label not in active_labels:
continue
resolved = resolve_fn(device["devMac"], device["devLastIP"]) resolved = resolve_fn(device["devMac"], device["devLastIP"])
# Extract values # Extract values
@@ -1090,6 +1096,71 @@ def update_devices_names(pm):
plugin_records, plugin_records,
) )
# --- Step 1b: Re-resolve already-named devices for SET_ALWAYS plugins ---
# If any name-resolution plugin declares devName in SET_ALWAYS, it should be
# able to overwrite names set by lower-priority plugins. Step 1 only covers
# unknown devices, so we run a second pass here limited to devices that:
# - already have a name (not unknown/empty), AND
# - are not USER/LOCKED protected
# recordsNotFound is intentionally discarded: if resolution fails, the
# existing name is kept as-is.
name_resolution_plugins = [label for _, label in strategies]
set_always_plugins = [
p for p in name_resolution_plugins
if "devName" in get_plugin_authoritative_settings(p).get("set_always", [])
]
if not set_always_plugins:
mylog("debug", "[Update Device Name] SET_ALWAYS re-resolve: skipped (no name-resolution plugin has devName in SET_ALWAYS)")
else:
resolvableDevices = device_handler.getResolvable()
mylog("debug", f"[Update Device Name] SET_ALWAYS re-resolve: active plugins={set_always_plugins}, candidate devices={len(resolvableDevices)}")
if resolvableDevices:
recordsToUpdate, _, fs, notFound = resolve_devices(resolvableDevices, active_labels=set(set_always_plugins))
res_string = f"{fs['DIGSCAN']}/{fs['AVAHISCAN']}/{fs['NSLOOKUP']}/{fs['NBTSCAN']}"
mylog("verbose", f"[Update Device Name] SET_ALWAYS re-resolve - Found (DIG/AVAHI/NSL/NBT): {len(recordsToUpdate)} ({res_string}), Not Found: {notFound}")
records_by_plugin = {}
for entry in recordsToUpdate:
records_by_plugin.setdefault(entry[1], []).append(entry)
total_updated = 0
for plugin_label, plugin_records in records_by_plugin.items():
plugin_settings = get_plugin_authoritative_settings(plugin_label)
name_clause = get_overwrite_sql_clause(
"devName", "devNameSource", plugin_settings
)
fqdn_clause = get_overwrite_sql_clause(
"devFQDN", "devFQDNSource", plugin_settings
)
sql.executemany(
f"""UPDATE Devices
SET devName = CASE
WHEN {name_clause} THEN ?
ELSE devName
END,
devNameSource = CASE
WHEN {name_clause} THEN ?
ELSE devNameSource
END,
devFQDN = CASE
WHEN {fqdn_clause} THEN ?
ELSE devFQDN
END,
devFQDNSource = CASE
WHEN {fqdn_clause} THEN ?
ELSE devFQDNSource
END
WHERE devMac = ?""",
plugin_records,
)
total_updated += sql.rowcount
mylog("verbose", f"[Update Device Name] SET_ALWAYS re-resolve - DB rows updated: {total_updated}")
# --- Step 2: Optionally refresh FQDN for all devices --- # --- Step 2: Optionally refresh FQDN for all devices ---
if get_setting_value("REFRESH_FQDN"): if get_setting_value("REFRESH_FQDN"):
allDevices = device_handler.getAll() allDevices = device_handler.getAll()

View File

@@ -206,17 +206,18 @@ def format_date_iso(date_val: str) -> Optional[str]:
else: else:
dt = date_val dt = date_val
# 2. If it has no timezone, assume it's UTC (our DB storage format) # 2. Normalize to UTC first, then convert to target timezone
# then CONVERT to user's configured timezone
if dt.tzinfo is None: if dt.tzinfo is None:
# Mark as UTC first — critical: localize() would label without converting
dt = dt.replace(tzinfo=datetime.UTC) dt = dt.replace(tzinfo=datetime.UTC)
# Resolve target timezone; fall back to UTC if conf.tz is missing/invalid else:
try: dt = dt.astimezone(datetime.UTC)
target_tz = conf.tz if isinstance(conf.tz, datetime.tzinfo) else ZoneInfo(conf.tz)
except (ZoneInfoNotFoundError, ValueError, TypeError): try:
target_tz = datetime.UTC target_tz = conf.tz if isinstance(conf.tz, datetime.tzinfo) else ZoneInfo(str(conf.tz))
dt = dt.astimezone(target_tz) except Exception:
target_tz = datetime.UTC
dt = dt.astimezone(target_tz)
# 3. Return the string. .isoformat() will now include the +11:00 or +10:00 # 3. Return the string. .isoformat() will now include the +11:00 or +10:00
return dt.isoformat() return dt.isoformat()

View File

@@ -14,8 +14,16 @@ class Action:
def __init__(self, trigger): def __init__(self, trigger):
self.trigger = trigger self.trigger = trigger
def execute(self, obj): def get_object(self):
"""Executes the action on the given object.""" """Safely get and normalize the trigger object."""
obj = getattr(self.trigger, "object", None)
if isinstance(obj, sqlite3.Row):
obj = dict(obj)
return obj
def execute(self):
raise NotImplementedError("Subclasses must implement execute()") raise NotImplementedError("Subclasses must implement execute()")
@@ -23,7 +31,7 @@ class UpdateFieldAction(Action):
"""Action to update a specific field of an object.""" """Action to update a specific field of an object."""
def __init__(self, db, field, value, trigger): def __init__(self, db, field, value, trigger):
super().__init__(trigger) # Call the base class constructor super().__init__(trigger)
self.field = field self.field = field
self.value = value self.value = value
self.db = db self.db = db
@@ -31,83 +39,93 @@ class UpdateFieldAction(Action):
def execute(self): def execute(self):
mylog("verbose", f"[WF] Updating field '{self.field}' to '{self.value}' for event object {self.trigger.object_type}") mylog("verbose", f"[WF] Updating field '{self.field}' to '{self.value}' for event object {self.trigger.object_type}")
obj = self.trigger.object obj = self.get_object()
# convert to dict for easeir handling if obj is None:
if isinstance(obj, sqlite3.Row): mylog("none", "[WF] Object no longer exists")
obj = dict(obj) # Convert Row object to a standard dictionary return None
processed = False
# currently unused
if isinstance(obj, dict) and "objectGuid" in obj: if isinstance(obj, dict) and "objectGuid" in obj:
mylog("debug", f"[WF] Updating Object '{obj}' ") mylog("debug", f"[WF] Updating Object '{obj}'")
plugin_instance = PluginObjectInstance()
plugin_instance.updateField(obj["objectGuid"], self.field, self.value)
processed = True
elif isinstance(obj, dict) and "devGUID" in obj: PluginObjectInstance().updateField(
mylog("debug", f"[WF] Updating Device '{obj}' ") obj["objectGuid"],
device_instance = DeviceInstance() self.field,
device_instance.updateField(obj["devGUID"], self.field, self.value) self.value,
processed = True )
if not processed: return obj
mylog("none", f"[WF] Could not process action for object: {obj}")
return obj if isinstance(obj, dict) and "devGUID" in obj:
mylog("debug", f"[WF] Updating Device '{obj}'")
DeviceInstance().updateField(
obj["devGUID"],
self.field,
self.value,
)
return obj
mylog("none", f"[WF] Unsupported object format: {obj}")
return None
class DeleteObjectAction(Action): class DeleteObjectAction(Action):
"""Action to delete an object.""" """Action to delete an object."""
def __init__(self, db, trigger): def __init__(self, db, trigger):
super().__init__(trigger) # Call the base class constructor super().__init__(trigger)
self.db = db self.db = db
def execute(self): def execute(self):
mylog("verbose", f"[WF] Deleting event object {self.trigger.object_type}") mylog("verbose", f"[WF] Deleting event object {self.trigger.object_type}")
obj = self.trigger.object obj = self.get_object()
# convert to dict for easeir handling if obj is None:
if isinstance(obj, sqlite3.Row): mylog("none", "[WF] Object no longer exists")
obj = dict(obj) # Convert Row object to a standard dictionary return None
processed = False
# currently unused
if isinstance(obj, dict) and "objectGuid" in obj: if isinstance(obj, dict) and "objectGuid" in obj:
mylog("debug", f"[WF] Updating Object '{obj}' ") mylog("debug", f"[WF] Deleting Object '{obj}'")
plugin_instance = PluginObjectInstance()
plugin_instance.delete(obj["objectGuid"])
processed = True
elif isinstance(obj, dict) and "devGUID" in obj: PluginObjectInstance().delete(obj["objectGuid"])
mylog("debug", f"[WF] Updating Device '{obj}' ")
device_instance = DeviceInstance()
device_instance.delete(obj["devGUID"])
processed = True
if not processed: return obj
mylog("none", f"[WF] Could not process action for object: {obj}")
return obj if isinstance(obj, dict) and "devGUID" in obj:
mylog("debug", f"[WF] Deleting Device '{obj}'")
DeviceInstance().delete(obj["devGUID"])
return obj
mylog("none", f"[WF] Unsupported object format: {obj}")
return None
class RunPluginAction(Action): class RunPluginAction(Action):
"""Action to run a specific plugin.""" """Action to run a specific plugin."""
def __init__(self, plugin_name, params, trigger): # Add trigger def __init__(self, plugin_name, params, trigger):
super().__init__(trigger) # Call parent constructor super().__init__(trigger)
self.plugin_name = plugin_name self.plugin_name = plugin_name
self.params = params self.params = params
def execute(self): def execute(self):
obj = self.trigger.object obj = self.get_object()
if obj is None:
mylog("none", "[WF] Object no longer exists")
return None
mylog("verbose", f"[WF] Executing plugin '{self.plugin_name}' with parameters {self.params} for object {obj}")
# PluginManager.run(self.plugin_name, self.params)
mylog("verbose", f"Executing plugin '{self.plugin_name}' with parameters {self.params} for object {obj}")
# PluginManager.run(self.plugin_name, self.parameters)
return obj return obj
@@ -115,14 +133,21 @@ class SendNotificationAction(Action):
"""Action to send a notification.""" """Action to send a notification."""
def __init__(self, method, message, trigger): def __init__(self, method, message, trigger):
super().__init__(trigger) # Call parent constructor super().__init__(trigger)
self.method = method # Fix attribute name self.method = method
self.message = message self.message = message
def execute(self): def execute(self):
obj = self.trigger.object obj = self.get_object()
mylog("verbose", f"Sending notification via '{self.method}': {self.message} for object {obj}")
if obj is None:
mylog("none", "[WF] Object no longer exists")
return None
mylog("verbose", f"[WF] Sending notification via '{self.method}': {self.message} for object {obj}")
# NotificationManager.send(self.method, self.message) # NotificationManager.send(self.method, self.message)
return obj return obj
@@ -132,7 +157,6 @@ class ActionGroup:
def __init__(self, actions): def __init__(self, actions):
self.actions = actions self.actions = actions
def execute(self, obj): def execute(self):
for action in self.actions: for action in self.actions:
action.execute(obj) action.execute()
return obj

View File

@@ -48,7 +48,11 @@ class Trigger:
mylog("debug", [query]) mylog("debug", [query])
result = db.sql.execute(query).fetchall() result = db.sql.execute(query).fetchall()
self.object = result[0]
if len(result) > 0:
self.object = result[0]
else:
self.object = None
else: else:
self.object = None self.object = None

View File

@@ -440,7 +440,7 @@ def test_sync_get(mock_handle, client, api_token):
def test_sync_post(mock_handle, client, api_token): def test_sync_post(mock_handle, client, api_token):
"""Test POST /sync.""" """Test POST /sync."""
mock_handle.return_value = ({"success": True}, 200) mock_handle.return_value = ({"success": True}, 200)
payload = {"data": {}, "node_name": "node1", "plugin": "test"} payload = {"data": "encrypted_payload_string", "node_name": "node1", "plugin": "test"}
response = client.post('/sync', response = client.post('/sync',
json=payload, json=payload,
headers=auth_headers(api_token)) headers=auth_headers(api_token))

View File

@@ -0,0 +1,124 @@
"""Tests for the /sync POST and GET endpoints.
Covers:
- Authentication enforcement (403 on missing/invalid token)
- Content-type enforcement on POST (regression for data= vs json= bug)
- Happy-path POST returns 200
- GET auth enforcement
"""
import os
import sys
from unittest.mock import patch
import pytest
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402
from api_server.api_server_start import app # noqa: E402
import api_server.sync_endpoint as sync_endpoint # noqa: E402
@pytest.fixture(scope="session")
def api_token():
"""Load API token from system settings."""
return get_setting_value("API_TOKEN")
@pytest.fixture
def client():
"""Flask test client."""
with app.test_client() as client:
yield client
def auth_headers(token):
"""Helper to construct Authorization header."""
return {"Authorization": f"Bearer {token}"}
# ========================================================================
# POST /sync - authentication
# ========================================================================
def test_sync_post_no_token_is_forbidden(client):
resp = client.post("/sync")
assert resp.status_code == 403
def test_sync_post_invalid_token_is_forbidden(client):
resp = client.post("/sync", headers=auth_headers("INVALID-TOKEN"))
assert resp.status_code == 403
# ========================================================================
# POST /sync - content-type enforcement
# Regression: node used to send data= (form-encoded); validation rejects it.
# ========================================================================
def test_sync_post_form_encoded_returns_415(client, api_token):
"""Form-encoded body must be rejected with 415 Unsupported Media Type.
Regression test: before the fix sync.py used ``requests.post(data=…)``
which sends application/x-www-form-urlencoded. The validate_request
middleware requires application/json — this test ensures that contract
is enforced so the node can never silently regress to form encoding.
"""
resp = client.post(
"/sync",
headers=auth_headers(api_token),
data={"data": "payload", "plugin": "ARPSCAN", "node_name": "Node1"},
content_type="application/x-www-form-urlencoded",
)
assert resp.status_code == 415
def test_sync_post_json_body_is_accepted(client, api_token, tmp_path):
"""JSON body must pass validation and return 200."""
plugins_dir = tmp_path / "log" / "plugins"
plugins_dir.mkdir(parents=True)
with patch.object(sync_endpoint, "INSTALL_PATH", str(tmp_path)):
resp = client.post(
"/sync",
headers=auth_headers(api_token),
json={"data": "test_payload", "plugin": "TESTPLUGIN", "node_name": "TestNode"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data is not None
assert "message" in data
def test_sync_post_json_body_writes_encoded_file(client, api_token, tmp_path):
"""A successful POST must persist an encoded file in the plugins log dir."""
plugins_dir = tmp_path / "log" / "plugins"
plugins_dir.mkdir(parents=True)
with patch.object(sync_endpoint, "INSTALL_PATH", str(tmp_path)):
client.post(
"/sync",
headers=auth_headers(api_token),
json={"data": "encrypted_blob", "plugin": "ARPSCAN", "node_name": "Node1"},
)
written = list(plugins_dir.glob("last_result.ARPSCAN.encoded.Node1.*.log"))
assert len(written) == 1
assert written[0].read_text() == "encrypted_blob"
# ========================================================================
# GET /sync - authentication
# ========================================================================
def test_sync_get_no_token_is_forbidden(client):
resp = client.get("/sync")
assert resp.status_code == 403
def test_sync_get_invalid_token_is_forbidden(client):
resp = client.get("/sync", headers=auth_headers("INVALID-TOKEN"))
assert resp.status_code == 403

View File

@@ -46,7 +46,7 @@ def _send_data(api_token, file_content, encryption_key, file_path, node_name, pr
} }
headers = {"Authorization": f"Bearer {api_token}"} headers = {"Authorization": f"Bearer {api_token}"}
try: try:
response = requests.post(hub_url + API_ENDPOINT, data=data, headers=headers, timeout=5) response = requests.post(hub_url + API_ENDPOINT, json=data, headers=headers, timeout=5)
return response.status_code == 200 return response.status_code == 200
except requests.RequestException: except requests.RequestException:
return False return False
@@ -68,9 +68,24 @@ def _get_data(api_token, node_url):
def _node_name_from_filename(file_name: str) -> str: def _node_name_from_filename(file_name: str) -> str:
"""Mirror of the node-name extraction in sync.main().""" """Mirror of the node-name extraction in sync.main() (Mode 3).
Real file formats produced by the system:
PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
PULL: last_result.NodeName.log → parts[1]
"""
parts = file_name.split(".") parts = file_name.split(".")
return parts[2] if ("decoded" in file_name or "encoded" in file_name) else parts[1] return parts[3] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
def _should_delete_after_process(filename: str) -> bool:
"""Mirror of the delete-after-process condition in execute_plugin() (server/plugin.py).
Only node-sync intermediary files (.encoded. / .decoded.) are removed after
processing. Local plugin result files (last_result.ARPSCAN.log etc.) must
survive so SYNC Mode 1 can read and forward them to the hub.
"""
return ".encoded." in filename or ".decoded." in filename
def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list): def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list):
@@ -205,7 +220,7 @@ class TestSendData:
with patch("requests.post", return_value=resp) as mock_post: with patch("requests.post", return_value=resp) as mock_post:
_send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY,
"/tmp/file.log", "node1", "SYNC", HUB_URL) "/tmp/file.log", "node1", "SYNC", HUB_URL)
payload = mock_post.call_args[1]["data"] payload = mock_post.call_args[1]["json"]
assert "data" in payload # encrypted blob assert "data" in payload # encrypted blob
assert payload["file_path"] == "/tmp/file.log" assert payload["file_path"] == "/tmp/file.log"
assert payload["plugin"] == "SYNC" assert payload["plugin"] == "SYNC"
@@ -219,7 +234,7 @@ class TestSendData:
with patch("requests.post", return_value=resp) as mock_post: with patch("requests.post", return_value=resp) as mock_post:
_send_data(API_TOKEN, plaintext, ENCRYPTION_KEY, _send_data(API_TOKEN, plaintext, ENCRYPTION_KEY,
"/tmp/file.log", "node1", "SYNC", HUB_URL) "/tmp/file.log", "node1", "SYNC", HUB_URL)
transmitted = mock_post.call_args[1]["data"]["data"] transmitted = mock_post.call_args[1]["json"]["data"]
assert transmitted != plaintext assert transmitted != plaintext
# Verify it round-trips correctly # Verify it round-trips correctly
assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext
@@ -296,23 +311,33 @@ class TestGetData:
class TestNodeNameExtraction: class TestNodeNameExtraction:
def test_simple_filename(self): def test_pull_mode_filename(self):
# last_result.MyNode.log → "MyNode" # PULL mode: last_result.MyNode.log → "MyNode"
assert _node_name_from_filename("last_result.MyNode.log") == "MyNode" assert _node_name_from_filename("last_result.MyNode.log") == "MyNode"
def test_decoded_filename(self): def test_push_decoded_filename(self):
# last_result.decoded.MyNode.1.log → "MyNode" # PUSH mode (post-decode): last_result.ARPSCAN.decoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.decoded.MyNode.1.log") == "MyNode" assert _node_name_from_filename("last_result.ARPSCAN.decoded.MyNode.1.log") == "MyNode"
def test_encoded_filename(self): def test_push_encoded_filename(self):
# last_result.encoded.MyNode.1.log → "MyNode" # PUSH mode (pre-decode): last_result.ARPSCAN.encoded.MyNode.1.log → "MyNode"
assert _node_name_from_filename("last_result.encoded.MyNode.1.log") == "MyNode" assert _node_name_from_filename("last_result.ARPSCAN.encoded.MyNode.1.log") == "MyNode"
def test_node_name_with_underscores(self): def test_pull_node_name_with_underscores(self):
assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site" assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site"
def test_decoded_node_name_with_underscores(self): def test_push_decoded_node_name_with_underscores(self):
assert _node_name_from_filename("last_result.decoded.Wladek_Site.1.log") == "Wladek_Site" assert _node_name_from_filename("last_result.ARPSCAN.decoded.Wladek_Site.1.log") == "Wladek_Site"
def test_push_decoded_node_name_with_counter_gt_1(self):
# Counter increments when multiple pushes arrive before SYNC runs
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node_Vlan01.3.log") == "Node_Vlan01"
def test_push_decoded_different_plugins(self):
for plugin in ("NMAP", "PIHOLE", "DHCPLEASES"):
fname = f"last_result.{plugin}.decoded.HubNode.1.log"
assert _node_name_from_filename(fname) == "HubNode", \
f"Expected 'HubNode' from {fname}"
# =========================================================================== # ===========================================================================
@@ -409,5 +434,103 @@ class TestReceiveInsert:
inserted = sync_insert_devices(conn, [device], existing_macs=set()) inserted = sync_insert_devices(conn, [device], existing_macs=set())
assert inserted == 1 assert inserted == 1
# ===========================================================================
# Plugin result file retention (regression for execute_plugin delete bug)
# ===========================================================================
class TestPluginFileRetention:
"""Regression for the execute_plugin() delete-condition bug (server/plugin.py).
Before the fix the condition was ``filename != "last_result.log"``. No
plugin ever writes to that literal name — all write ``last_result.ARPSCAN.log``
etc. — so every local result file was deleted immediately after processing,
before SYNC Mode 1 had a chance to read and forward it to the hub.
The corrected condition deletes ONLY ``.encoded.`` / ``.decoded.``
node-sync intermediary files. Local plugin result files must survive.
"""
def test_local_result_file_not_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.log") is False
def test_local_result_files_for_common_plugins_not_flagged(self):
for plugin in ("NMAP", "PIHOLE", "SYNC", "DHCPLEASES", "ARPSCAN"):
fname = f"last_result.{plugin}.log"
assert _should_delete_after_process(fname) is False, \
f"{fname} must NOT be deleted — SYNC Mode 1 still needs it"
def test_encoded_node_sync_file_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.encoded.Node1.1.log") is True
def test_decoded_node_sync_file_flagged_for_deletion(self):
assert _should_delete_after_process("last_result.ARPSCAN.decoded.Node1.1.log") is True
def test_encoded_files_with_various_node_names_flagged(self):
for node in ("Node1", "Home_Hub", "Site_B", "OfficeNode"):
fname = f"last_result.ARPSCAN.encoded.{node}.1.log"
assert _should_delete_after_process(fname) is True, \
f"{fname} should be deleted after processing"
def test_decoded_files_with_various_node_names_flagged(self):
for node in ("Node1", "Home_Hub", "Site_B"):
fname = f"last_result.ARPSCAN.decoded.{node}.2.log"
assert _should_delete_after_process(fname) is True, \
f"{fname} should be deleted after processing"
def test_empty_device_list_returns_zero(self, conn): def test_empty_device_list_returns_zero(self, conn):
assert sync_insert_devices(conn, [], existing_macs=set()) == 0 assert sync_insert_devices(conn, [], existing_macs=set()) == 0
# ===========================================================================
# Mode 3 JSON-skip behaviour
# Regression: local plugin result files (pipe-delimited) must not crash Mode 3.
# ===========================================================================
def _parse_sync_payload(file_path: str) -> list:
"""Mirror of the json.load + data['data'] block in sync.main() Mode 3.
Returns the list of device dicts on success, or raises nothing on invalid
input — callers should catch JSONDecodeError / KeyError and skip the file.
"""
with open(file_path, "r") as f:
data = json.load(f)
return data["data"]
class TestMode3JsonSkip:
"""Regression for the crash when Mode 3 encountered pipe-delimited plugin files.
Before the fix, sync.py called json.load() on every last_result.*.log file
returned by decode_and_rename_files(), including local plugin result files
(e.g. last_result.DIGSCAN.log) which are pipe-delimited and not JSON. The
fix wraps the load in try/except(JSONDecodeError, KeyError) and continues.
"""
def test_valid_sync_payload_is_parsed(self, tmp_path):
payload = {"data": [{"devMac": "aa:bb:cc:dd:ee:01", "devName": "TestDevice"}]}
f = tmp_path / "last_result.ARPSCAN.decoded.Node1.1.log"
f.write_text(json.dumps(payload))
result = _parse_sync_payload(str(f))
assert len(result) == 1
assert result[0]["devMac"] == "aa:bb:cc:dd:ee:01"
def test_pipe_delimited_file_raises_json_error(self, tmp_path):
"""Pipe-delimited plugin file must raise JSONDecodeError so callers can skip it."""
f = tmp_path / "last_result.DIGSCAN.log"
f.write_text("aa:bb:cc:dd:ee:01|192.168.1.1|2026-01-01 00:00:00|hostname||subnet||DIGSCAN|||||\n")
with pytest.raises(json.JSONDecodeError):
_parse_sync_payload(str(f))
def test_json_without_data_key_raises_key_error(self, tmp_path):
"""JSON that lacks the 'data' key must raise KeyError so callers can skip it."""
f = tmp_path / "last_result.UNKNOWN.log"
f.write_text(json.dumps({"result": []}))
with pytest.raises(KeyError):
_parse_sync_payload(str(f))
def test_empty_file_raises_json_error(self, tmp_path):
f = tmp_path / "last_result.EMPTY.log"
f.write_text("")
with pytest.raises(json.JSONDecodeError):
_parse_sync_payload(str(f))

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
Stored-XSS regression tests for devName / devFQDN rendering.
Scenario
--------
A LAN-controlled scanner (e.g. DHCPLSS / nmap) can supply arbitrary hostnames
that end up stored as `devName` or `devFQDN` in the database. If these values
are injected into jQuery `.html()` calls or template-literal HTML strings
without HTML-entity escaping, a stored XSS payload executes in the
authenticated operator's browser.
These tests verify that no page listed in the "affected surfaces" table renders
the raw payload as executable HTML.
Canary mechanism
----------------
The XSS payload sets `window.__xss_canary = true` if executed:
<img src=x onerror="window.__xss_canary=true">
Before each page navigation we reset the canary to `false`. After the page
fully loads (and after any async device-list fetches have had time to run) we
assert that the canary is still `false`.
Note: these tests require a running NetAlertX backend and frontend (use the
devcontainer startup tasks or the Docker compose stack). They are Selenium-
based (headless Chromium) and are skipped when a browser is unavailable.
"""
import time
import requests
import pytest
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from selenium.webdriver.common.by import By # noqa: E402
from .test_helpers import ( # noqa: E402
BASE_URL, API_BASE_URL,
get_driver, get_api_token,
wait_for_page_load,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# XSS payload: sets the window canary if the string is parsed as HTML.
# We keep the payload simple and deterministic.
XSS_PAYLOAD = '<img src=x onerror="window.__xss_canary=true">'
# A second, attribute-break variant that escapes unquoted attribute contexts
XSS_ATTR_BREAK = '" onmouseover="window.__xss_canary=true" data-x="'
# The fake MAC used for our synthetic test device (FA:CE prefix = recognised as
# "fake" by isFakeMac() in ui_components.js, so it won't affect real scanning).
XSS_TEST_MAC = "fa:ce:00:00:00:01"
# Seconds to wait after page load for async device-list fetches to complete.
ASYNC_WAIT_S = 4
# Pages to exercise (relative URLs; all are authenticated but devcontainer
# skips auth by default).
PAGES_UNDER_TEST = [
("/devices.php", "Device list table (devName / devFQDN columns)"),
("/network.php", "Network tabs and tree"),
(f"/deviceDetails.php?mac={XSS_TEST_MAC}", "Device detail page title"),
("/presence.php", "FullCalendar presence view"),
("/multiEditCore.php", "Multi-edit device selector"),
("/events.php", "Events table device name column"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def _create_xss_device(token: str):
"""Create a synthetic device whose devName is the XSS payload."""
payload = {
"createNew": True,
"devName": XSS_PAYLOAD,
"devFQDN": XSS_PAYLOAD,
"devOwner": "XSS-test",
"devType": "Other",
"devVendor": "XSS-test",
"devLastIP": "192.168.99.99",
}
resp = requests.post(
f"{API_BASE_URL}/device/{XSS_TEST_MAC}",
json=payload,
headers=_auth_headers(token),
timeout=10,
)
return resp
def _create_xss_event(token: str):
"""Create an event for the XSS test device so events.php renders its name."""
requests.post(
f"{API_BASE_URL}/events/create/{XSS_TEST_MAC}",
json={"event_type": "Device Down", "ip": "192.168.99.99", "additional_info": "xss-test"},
headers=_auth_headers(token),
timeout=10,
)
def _delete_xss_device(token: str):
"""Delete the synthetic XSS test device and its events."""
requests.delete(
f"{API_BASE_URL}/events/{XSS_TEST_MAC}",
headers=_auth_headers(token),
timeout=10,
)
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [XSS_TEST_MAC]},
headers=_auth_headers(token),
timeout=10,
)
def _read_canary(driver) -> bool:
"""Return True if the XSS canary was tripped."""
return bool(driver.execute_script("return window.__xss_canary || false;"))
def _navigate_with_canary(driver, url, timeout=15):
"""Navigate to *url* with window.__xss_canary pre-initialised to false.
Uses CDP Page.addScriptToEvaluateOnNewDocument so the canary is set
*before* any page script runs. If we reset it after driver.get() instead,
a payload that fires during page load would set the canary and we would
immediately clear the evidence.
"""
result = driver.execute_cdp_cmd(
"Page.addScriptToEvaluateOnNewDocument",
{"source": "window.__xss_canary = false;"},
)
script_id = result.get("identifier")
try:
driver.get(url)
wait_for_page_load(driver, timeout=timeout)
finally:
if script_id:
driver.execute_cdp_cmd(
"Page.removeScriptToEvaluateOnNewDocument",
{"identifier": script_id},
)
def _force_cache_refresh(driver):
"""Clear localStorage to force a fresh device-list fetch on next page load."""
driver.execute_script("if(window.localStorage){ localStorage.clear(); }")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def xss_token():
"""Retrieve the API token; skip the module if unavailable."""
token = get_api_token()
if not token:
pytest.skip("API token not available is the backend running?")
return token
@pytest.fixture(scope="module")
def xss_device(xss_token):
"""Create the XSS test device (and one event) before the module; clean up after."""
resp = _create_xss_device(xss_token)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create XSS test device (HTTP {resp.status_code}). "
"Is the backend running?")
# Create an event so events.php actually renders this device's name.
_create_xss_event(xss_token)
yield XSS_TEST_MAC
_delete_xss_device(xss_token)
@pytest.fixture(scope="module")
def xss_driver(xss_device): # depend on xss_device so device exists before browser starts
"""Single headless browser instance shared across all XSS tests in this module."""
driver = get_driver()
if not driver:
pytest.skip("Headless browser (Chromium) not available")
# Warm the localStorage device cache so later pages can render the device.
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S) # let async API fetch populate localStorage
yield driver
driver.quit()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_xss_not_executed(xss_driver, path, description):
"""
Verify that the XSS canary is NOT tripped when a page renders the XSS
device name.
Pass criterion: window.__xss_canary remains false after the page loads
and the async device list fetch has had time to complete.
"""
driver = xss_driver
# Pre-initialise canary via CDP so it is false before any page JS runs.
# Resetting after driver.get() would erase evidence of early-firing payloads.
_navigate_with_canary(driver, f"{BASE_URL}{path}")
# Give async JS (DataTables, FullCalendar, network tree) time to render
time.sleep(ASYNC_WAIT_S)
# Final canary check
fired = _read_canary(driver)
assert not fired, (
f"XSS canary was tripped on {description} ({path}). "
f"The raw payload '{XSS_PAYLOAD}' was executed as HTML. "
"Check that encodeSpecialChars() is applied at all render sites."
)
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
def test_devname_raw_payload_not_in_html_source(xss_driver, path, description):
"""
Verify that the XSS payload was NOT injected as a real HTML element.
A properly escaped devName ends up as text content in the DOM, e.g.:
&lt;img src=x onerror="..."&gt;
The browser's DOM serialiser encodes < and > but NOT ", so page_source
still contains the literal string onerror="..." — that is expected and
safe. What must NOT appear is an actual unescaped opening tag:
<img src=x
which would indicate the payload was inserted as real HTML.
"""
driver = xss_driver
driver.get(f"{BASE_URL}{path}")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
page_source = driver.page_source
# An actual injected <img src=x tag would appear as-is in the serialised DOM.
# Escaped text content would appear as &lt;img src=x — no literal "<img src=x".
assert "<img src=x" not in page_source, (
f"XSS payload injected as real HTML on {description} ({path}): "
"'<img src=x' found literally in page source. "
"Ensure encodeSpecialChars() wraps all devName/devFQDN render sites."
)
def test_devname_xss_device_shows_as_escaped_text(xss_driver):
"""
On devices.php, confirm the XSS payload is displayed as visible escaped text
('&lt;img' or just '<img' as textContent) rather than as an injected element.
We check the page text (what the user reads) includes part of the payload as
literal characters, and that NO <img> element with src=x was injected.
"""
driver = xss_driver
driver.get(f"{BASE_URL}/devices.php")
wait_for_page_load(driver, timeout=15)
time.sleep(ASYNC_WAIT_S)
# The body text should contain the literal payload characters as plain text
body_text = driver.find_element(By.TAG_NAME, "body").text
if "onerror" in body_text:
# Payload is visible as literal text — confirm it was not also executed
assert not driver.execute_script("return window.__xss_canary || false"), (
"XSS canary fired even though payload appeared as visible text on devices.php"
)
# No <img> with src=x should have been injected by the XSS payload
injected_imgs = driver.find_elements(By.CSS_SELECTOR, "img[src='x']")
assert len(injected_imgs) == 0, (
f"XSS payload created an <img src=x> element — payload was not escaped. "
f"Found {len(injected_imgs)} injected image(s)."
)
def test_devname_attribute_break_xss_not_executed(xss_driver, xss_token):
"""
Verify that the attribute-break variant of XSS payload is also handled.
This checks that encodeSpecialChars() encodes double-quotes in devName,
preventing an attacker from breaking out of an HTML attribute context.
"""
attr_break_mac = "fa:ce:00:00:00:02"
attr_payload = {
"createNew": True,
"devName": XSS_ATTR_BREAK,
"devOwner": "XSS-attr-test",
"devLastIP": "192.168.99.100",
}
try:
resp = requests.post(
f"{API_BASE_URL}/device/{attr_break_mac}",
json=attr_payload,
headers=_auth_headers(xss_token),
timeout=10,
)
if resp.status_code not in (200, 201):
pytest.skip(f"Could not create attribute-break XSS device (HTTP {resp.status_code})")
# Pre-set canary via CDP, then navigate — same pattern as main tests.
_navigate_with_canary(xss_driver, f"{BASE_URL}/devices.php")
time.sleep(ASYNC_WAIT_S)
fired = _read_canary(xss_driver)
assert not fired, (
f"Attribute-break XSS canary was tripped on devices.php. "
f"Payload: {XSS_ATTR_BREAK!r}. "
"Ensure double-quotes are encoded by encodeSpecialChars()."
)
finally:
requests.delete(
f"{API_BASE_URL}/devices",
json={"macs": [attr_break_mac]},
headers=_auth_headers(xss_token),
timeout=10,
)