feat: Enhance plugin configurations and improve MAC normalization

This commit is contained in:
Jokob @NetAlertX
2026-01-21 01:58:52 +00:00
parent 3ee21ac830
commit 478b018fa5
34 changed files with 1117 additions and 63 deletions

View File

@@ -333,6 +333,68 @@
"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", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -307,6 +307,68 @@
"string": "Some devices don't have a MAC assigned. Enabling the FAKE_MAC setting generates a fake MAC address from the IP address to track devices, but it may cause inconsistencies if IPs change or devices are re-discovered with a different MAC. Static IPs are recommended. Device type and icon might not be detected correctly and some plugins might fail if they depend on a valid MAC address. When unchecked, devices with empty MAC addresses are skipped."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -274,7 +274,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -305,7 +305,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -135,7 +135,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -166,7 +166,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]

View File

@@ -89,7 +89,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -119,7 +119,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]

View File

@@ -689,7 +689,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -720,7 +720,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -90,7 +90,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -121,7 +121,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -303,6 +303,68 @@
"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", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -296,6 +296,68 @@
"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", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -410,6 +410,68 @@
"string": "Benachrichtige nur bei diesen Status. <code>new</code> bedeutet ein neues eindeutiges (einzigartige Kombination aus PrimaryId und SecondaryId) Objekt wurde erkennt. <code>watched-changed</code> bedeutet eine ausgewählte <code>Watched_ValueN</code>-Spalte hat sich geändert."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -138,7 +138,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -169,7 +169,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -391,7 +391,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -422,7 +422,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -267,6 +267,68 @@
"string": "Password for Mikrotik Router"
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -89,7 +89,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -119,7 +119,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -451,6 +451,68 @@
"string": "When scanning remote networks, NMAP can only retrieve the IP address, not the MAC address. Enabling the FAKE_MAC setting generates a fake MAC address from the IP address to track devices, but it may cause inconsistencies if IPs change or devices are re-discovered with a different MAC. Static IPs are recommended. Device type and icon might not be detected correctly and some plugins might fail if they depend on a valid MAC address. When unchecked, devices with empty MAC addresses are skipped."
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -89,7 +89,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -120,7 +120,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]

View File

@@ -467,6 +467,68 @@
}
]
}
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -440,6 +440,68 @@
}
]
}
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
],
"database_column_definitions": [

View File

@@ -121,7 +121,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -154,7 +154,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -223,7 +223,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]
@@ -253,7 +253,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true"}],
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
"transformers": []
}
]

View File

@@ -184,7 +184,12 @@ def normalize_mac(mac):
:param mac: The MAC address to normalize.
:return: The normalized MAC address.
"""
s = str(mac).upper().strip()
s = str(mac).strip()
if s.lower() == "internet":
return "Internet"
s = s.upper()
# Determine separator if present, prefer colon, then hyphen
if ':' in s:

View File

@@ -593,7 +593,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -624,7 +624,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -859,6 +859,68 @@
"string": "Status"
}
]
},
{
"function": "SET_ALWAYS",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "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 always overwritten by this plugin, unless the user locks or overwrites the value."
}
]
},
{
"function": "SET_EMPTY",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
},
"default_value": [],
"options": [
"devMac",
"devLastIP"
],
"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 or set to NEWDEV."
}
]
}
]
}

View File

@@ -505,7 +505,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -538,7 +538,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -923,7 +923,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -959,7 +959,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -233,7 +233,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]
@@ -265,7 +265,7 @@
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
"transformers": []
}
]

View File

@@ -44,7 +44,7 @@ from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E
from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression]
# Import tool logic from the MCP/tools module to reuse behavior (no blueprints)
from plugin_helper import is_mac # noqa: E402 [flake8 lint suppression]
from plugin_helper import is_mac, normalize_mac # noqa: E402 [flake8 lint suppression]
# is_mac is provided in mcp_endpoint and used by those handlers
# mcp_endpoint contains helper functions; routes moved into this module to keep a single place for routes
from messaging.in_app import ( # noqa: E402 [flake8 lint suppression]
@@ -469,33 +469,28 @@ def api_device_field_lock(mac, payload=None):
if not field_name:
return jsonify({"success": False, "error": "fieldName is required"}), 400
# Validate that the field can be locked
source_field = field_name + "Source"
allowed_tracked_fields = {
"devMac", "devName", "devLastIP", "devVendor", "devFQDN",
"devSSID", "devParentMAC", "devParentPort", "devParentRelType", "devVlan"
}
if field_name not in allowed_tracked_fields:
return jsonify({"success": False, "error": f"Field '{field_name}' cannot be locked"}), 400
device_handler = DeviceInstance()
normalized_mac = normalize_mac(mac)
try:
# When locking: set source to LOCKED
# When unlocking: check current value and let plugins take over
new_source = "LOCKED" if should_lock else "NEWDEV"
result = device_handler.updateDeviceColumn(mac, source_field, new_source)
if result.get("success"):
action = "locked" if should_lock else "unlocked"
return jsonify({
"success": True,
"message": f"Field {field_name} {action}",
"fieldName": field_name,
"locked": should_lock
})
if should_lock:
result = device_handler.lockDeviceField(normalized_mac, field_name)
action = "locked"
else:
return jsonify(result), 400
result = device_handler.unlockDeviceField(normalized_mac, field_name)
action = "unlocked"
response = dict(result)
response["fieldName"] = field_name
response["locked"] = should_lock
if response.get("success"):
response.setdefault("message", f"Field {field_name} {action}")
return jsonify(response)
if "does not support" in response.get("error", ""):
response["error"] = f"Field '{field_name}' cannot be {action}"
return jsonify(response), 400
except Exception as e:
mylog("none", f"Error locking field {field_name} for {mac}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -152,12 +152,20 @@ def enforce_source_on_user_update(devMac, updates_dict, conn):
cur = conn.cursor()
# Check if field has a corresponding source and should be updated
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(Devices)")
device_columns = {row["name"] for row in cur.fetchall()}
except Exception:
device_columns = set()
updates_to_apply = {}
for field_name, new_value in updates_dict.items():
if field_name in FIELD_SOURCE_MAP:
source_field = FIELD_SOURCE_MAP[field_name]
# User is updating this field, so mark it as USER
updates_to_apply[source_field] = "USER"
if not device_columns or source_field in device_columns:
updates_to_apply[source_field] = "USER"
if not updates_to_apply:
return
@@ -198,6 +206,15 @@ def lock_field(devMac, field_name, conn):
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(Devices)")
device_columns = {row["name"] for row in cur.fetchall()}
except Exception:
device_columns = set()
if device_columns and source_field not in device_columns:
mylog("debug", [f"[lock_field] Source column {source_field} missing for {field_name}"])
return
sql = f"UPDATE Devices SET {source_field}='LOCKED' WHERE devMac = ?"
@@ -227,6 +244,15 @@ def unlock_field(devMac, field_name, conn):
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(Devices)")
device_columns = {row["name"] for row in cur.fetchall()}
except Exception:
device_columns = set()
if device_columns and source_field not in device_columns:
mylog("debug", [f"[unlock_field] Source column {source_field} missing for {field_name}"])
return
# Unlock by resetting to empty (allows overwrite)
sql = f"UPDATE Devices SET {source_field}='' WHERE devMac = ?"

View File

@@ -593,7 +593,6 @@ class DeviceInstance:
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute(sql, values)
conn.commit()
if data.get("createNew", False):
# Initialize source-tracking fields on device creation.
@@ -617,7 +616,6 @@ class DeviceInstance:
source_values.append(normalized_mac)
source_sql = f"UPDATE Devices SET {set_clause} WHERE devMac = ?"
cur.execute(source_sql, source_values)
conn.commit()
# Enforce source tracking on user updates
# User-updated fields should have their *Source set to "USER"
@@ -631,6 +629,8 @@ class DeviceInstance:
conn.close()
return {"success": False, "error": f"Source tracking failed: {e}"}
# Commit all changes atomically after all operations succeed
conn.commit()
conn.close()
mylog("debug", f"[DeviceInstance] setDeviceData SQL: {sql.strip()}")

View File

@@ -545,12 +545,13 @@ def create_new_devices(db):
# Derive primary IP family values
cur_IP = str(cur_IP).strip() if cur_IP else ""
cur_IP_normalized = check_IP_format(cur_IP) if ":" not in cur_IP else cur_IP
# Validate IPv6 addresses using format_ip_long for consistency
if ":" in cur_IP_normalized:
# Validate IPv6 addresses using format_ip_long for consistency (do not store integer result)
if cur_IP_normalized and ":" in cur_IP_normalized:
validated_ipv6 = format_ip_long(cur_IP_normalized)
cur_IP_normalized = validated_ipv6 if validated_ipv6 else ""
if validated_ipv6 is None or validated_ipv6 < 0:
cur_IP_normalized = ""
primary_ipv4 = cur_IP_normalized if cur_IP_normalized and ":" not in cur_IP_normalized else ""
primary_ipv6 = cur_IP_normalized if cur_IP_normalized and ":" in cur_IP_normalized else ""

View File

@@ -115,6 +115,30 @@ class TestDeviceFieldLock:
assert resp.status_code == 400
assert "cannot be locked" in resp.json.get("error", "")
def test_lock_field_normalizes_mac(self, client, test_mac, auth_headers):
"""Lock endpoint should normalize MACs before applying locks."""
# Create device with normalized MAC
self.test_create_test_device(client, test_mac, auth_headers)
mac_variant = "aa-bb-cc-dd-ee-ff"
payload = {
"fieldName": "devName",
"lock": True
}
resp = client.post(
f"/device/{mac_variant}/field/lock",
json=payload,
headers=auth_headers
)
assert resp.status_code == 200, f"Failed to lock via normalized MAC: {resp.json}"
assert resp.json.get("locked") is True
# Verify source is LOCKED on normalized MAC
resp = client.get(f"/device/{test_mac}", headers=auth_headers)
assert resp.status_code == 200
device_data = resp.json
assert device_data.get("devNameSource") == "LOCKED"
def test_lock_all_tracked_fields(self, client, test_mac, auth_headers):
"""Lock each tracked field individually."""
# First create device

View File

@@ -72,6 +72,103 @@ def ip_test_db():
conn.close()
@pytest.fixture
def new_device_db():
"""Create an in-memory SQLite database for create_new_devices tests."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE Devices (
devMac TEXT PRIMARY KEY,
devName TEXT,
devVendor TEXT,
devLastIP TEXT,
devPrimaryIPv4 TEXT,
devPrimaryIPv6 TEXT,
devFirstConnection TEXT,
devLastConnection TEXT,
devSyncHubNode TEXT,
devGUID TEXT,
devParentMAC TEXT,
devParentPort TEXT,
devSite TEXT,
devSSID TEXT,
devType TEXT,
devSourcePlugin TEXT,
devAlertEvents INTEGER,
devAlertDown INTEGER,
devPresentLastScan INTEGER,
devIsArchived INTEGER,
devIsNew INTEGER,
devSkipRepeated INTEGER,
devScan INTEGER,
devOwner TEXT,
devFavorite INTEGER,
devGroup TEXT,
devComments TEXT,
devLogEvents INTEGER,
devLocation TEXT,
devCustomProps TEXT,
devParentRelType TEXT,
devReqNicsOnline INTEGER
)
"""
)
cur.execute(
"""
CREATE TABLE CurrentScan (
cur_MAC TEXT,
cur_Name TEXT,
cur_Vendor TEXT,
cur_ScanMethod TEXT,
cur_IP TEXT,
cur_SyncHubNodeName TEXT,
cur_NetworkNodeMAC TEXT,
cur_PORT TEXT,
cur_NetworkSite TEXT,
cur_SSID TEXT,
cur_Type TEXT
)
"""
)
cur.execute(
"""
CREATE TABLE Events (
eve_MAC TEXT,
eve_IP TEXT,
eve_DateTime TEXT,
eve_EventType TEXT,
eve_AdditionalInfo TEXT,
eve_PendingAlertEmail INTEGER
)
"""
)
cur.execute(
"""
CREATE TABLE Sessions (
ses_MAC TEXT,
ses_IP TEXT,
ses_EventTypeConnection TEXT,
ses_DateTimeConnection TEXT,
ses_EventTypeDisconnection TEXT,
ses_DateTimeDisconnection TEXT,
ses_StillConnected INTEGER,
ses_AdditionalInfo TEXT
)
"""
)
conn.commit()
yield conn
conn.close()
@pytest.fixture
def mock_ip_handlers():
"""Mock device_handling helper functions."""
@@ -311,6 +408,55 @@ def test_invalid_ip_values_rejected(ip_test_db, mock_ip_handlers):
), f"Invalid IP '{invalid_ip}' should not overwrite valid IPv4"
def test_invalid_ipv6_rejected_on_create_new_devices(new_device_db):
"""Invalid IPv6 values should not be persisted when creating new devices."""
cur = new_device_db.cursor()
cur.execute(
"""
INSERT INTO CurrentScan (
cur_MAC, cur_Name, cur_Vendor, cur_ScanMethod, cur_IP,
cur_SyncHubNodeName, cur_NetworkNodeMAC, cur_PORT,
cur_NetworkSite, cur_SSID, cur_Type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"AA:BB:CC:DD:EE:10",
"",
"Vendor",
"ARPSCAN",
"fe80::zz",
"",
"",
"",
"",
"",
"",
),
)
new_device_db.commit()
db = Mock()
db.sql_connection = new_device_db
db.sql = cur
db.commitDB = Mock(side_effect=new_device_db.commit)
with patch("helper.get_setting_value", return_value=""), patch.object(
device_handling, "get_setting_value", return_value=""
):
device_handling.create_new_devices(db)
row = cur.execute(
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
("AA:BB:CC:DD:EE:10",),
).fetchone()
assert row is not None, "Device should be created"
assert row["devLastIP"] == "", "Invalid IPv6 should not set devLastIP"
assert row["devPrimaryIPv4"] == "", "Invalid IPv6 should not set devPrimaryIPv4"
assert row["devPrimaryIPv6"] == "", "Invalid IPv6 should not set devPrimaryIPv6"
def test_ipv4_ipv6_mixed_in_multiple_scans(ip_test_db, mock_ip_handlers):
"""Multiple scans with different IP types should set both primary fields correctly."""
cur = ip_test_db.cursor()

View File

@@ -0,0 +1,231 @@
"""
Test for atomicity of device updates with source-tracking.
Verifies that:
1. If source-tracking fails, the device row is rolled back.
2. If source-tracking succeeds, device row and sources are both committed.
3. Database remains consistent in both scenarios.
"""
import sys
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
# Add server and plugins to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'server'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'front', 'plugins'))
from models.device_instance import DeviceInstance # noqa: E402 [flake8 lint suppression]
from plugin_helper import normalize_mac # noqa: E402 [flake8 lint suppression]
class TestDeviceAtomicity(unittest.TestCase):
"""Test atomic transactions for device updates with source-tracking."""
def setUp(self):
"""Create an in-memory SQLite DB for testing."""
self.test_db = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
self.test_db_path = self.test_db.name
self.test_db.close()
# Create minimal schema
conn = sqlite3.connect(self.test_db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Create Devices table with source-tracking columns
cur.execute("""
CREATE TABLE Devices (
devMac TEXT PRIMARY KEY,
devName TEXT,
devOwner TEXT,
devType TEXT,
devVendor TEXT,
devIcon TEXT,
devFavorite INTEGER DEFAULT 0,
devGroup TEXT,
devLocation TEXT,
devComments TEXT,
devParentMAC TEXT,
devParentPort TEXT,
devSSID TEXT,
devSite TEXT,
devStaticIP INTEGER DEFAULT 0,
devScan INTEGER DEFAULT 0,
devAlertEvents INTEGER DEFAULT 0,
devAlertDown INTEGER DEFAULT 0,
devParentRelType TEXT DEFAULT 'default',
devReqNicsOnline INTEGER DEFAULT 0,
devSkipRepeated INTEGER DEFAULT 0,
devIsNew INTEGER DEFAULT 0,
devIsArchived INTEGER DEFAULT 0,
devLastConnection TEXT,
devFirstConnection TEXT,
devLastIP TEXT,
devGUID TEXT,
devCustomProps TEXT,
devSourcePlugin TEXT,
devNameSource TEXT,
devTypeSource TEXT,
devVendorSource TEXT,
devIconSource TEXT,
devGroupSource TEXT,
devLocationSource TEXT,
devCommentsSource TEXT,
devMacSource TEXT
)
""")
conn.commit()
conn.close()
def tearDown(self):
"""Clean up test database."""
if os.path.exists(self.test_db_path):
os.unlink(self.test_db_path)
def _get_test_db_connection(self):
"""Override database connection for testing."""
conn = sqlite3.connect(self.test_db_path)
conn.row_factory = sqlite3.Row
return conn
def test_create_new_device_atomicity(self):
"""
Test that device creation and source-tracking are atomic.
If source tracking fails, the device should not be created.
"""
device_instance = DeviceInstance()
test_mac = normalize_mac("aa:bb:cc:dd:ee:ff")
# Patch at module level where it's used
with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection):
# Create a new device
data = {
"createNew": True,
"devMac": test_mac,
"devName": "Test Device",
"devOwner": "John Doe",
"devType": "Laptop",
}
result = device_instance.setDeviceData(test_mac, data)
# Verify success
self.assertTrue(result["success"], f"Device creation failed: {result}")
# Verify device exists
conn = self._get_test_db_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,))
device = cur.fetchone()
conn.close()
self.assertIsNotNone(device, "Device was not created")
self.assertEqual(device["devName"], "Test Device")
# Verify source tracking was set
self.assertEqual(device["devMacSource"], "NEWDEV")
self.assertEqual(device["devNameSource"], "NEWDEV")
def test_update_device_with_source_tracking_atomicity(self):
"""
Test that device update and source-tracking are atomic.
If source tracking fails, the device update should be rolled back.
"""
device_instance = DeviceInstance()
test_mac = normalize_mac("aa:bb:cc:dd:ee:ff")
# Create initial device
conn = self._get_test_db_connection()
cur = conn.cursor()
cur.execute("""
INSERT INTO Devices (
devMac, devName, devOwner, devType,
devNameSource, devTypeSource
) VALUES (?, ?, ?, ?, ?, ?)
""", (test_mac, "Old Name", "Old Owner", "Desktop", "PLUGIN", "PLUGIN"))
conn.commit()
conn.close()
# Patch database connection
with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection):
with patch('models.device_instance.enforce_source_on_user_update') as mock_enforce:
mock_enforce.return_value = None
data = {
"createNew": False,
"devMac": test_mac,
"devName": "New Name",
"devOwner": "New Owner",
}
result = device_instance.setDeviceData(test_mac, data)
# Verify success
self.assertTrue(result["success"], f"Device update failed: {result}")
# Verify device was updated
conn = self._get_test_db_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,))
device = cur.fetchone()
conn.close()
self.assertEqual(device["devName"], "New Name")
self.assertEqual(device["devOwner"], "New Owner")
def test_source_tracking_failure_rolls_back_device(self):
"""
Test that if enforce_source_on_user_update fails, the entire
transaction is rolled back (device and sources).
"""
device_instance = DeviceInstance()
test_mac = normalize_mac("aa:bb:cc:dd:ee:ff")
# Create initial device
conn = self._get_test_db_connection()
cur = conn.cursor()
cur.execute("""
INSERT INTO Devices (
devMac, devName, devOwner, devType,
devNameSource, devTypeSource
) VALUES (?, ?, ?, ?, ?, ?)
""", (test_mac, "Original Name", "Original Owner", "Desktop", "PLUGIN", "PLUGIN"))
conn.commit()
conn.close()
# Patch database connection and mock source enforcement failure
with patch('models.device_instance.get_temp_db_connection', self._get_test_db_connection):
with patch('models.device_instance.enforce_source_on_user_update') as mock_enforce:
# Simulate source tracking failure
mock_enforce.side_effect = Exception("Source tracking error")
data = {
"createNew": False,
"devMac": test_mac,
"devName": "Failed Update",
"devOwner": "Failed Owner",
}
result = device_instance.setDeviceData(test_mac, data)
# Verify error response
self.assertFalse(result["success"])
self.assertIn("Source tracking failed", result["error"])
# Verify device was NOT updated (rollback successful)
conn = self._get_test_db_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM Devices WHERE devMac = ?", (test_mac,))
device = cur.fetchone()
conn.close()
self.assertEqual(device["devName"], "Original Name", "Device should not have been updated on source tracking failure")
self.assertEqual(device["devOwner"], "Original Owner", "Device should not have been updated on source tracking failure")
if __name__ == "__main__":
unittest.main()

View File

@@ -16,3 +16,9 @@ def test_normalize_mac_preserves_wildcard():
result = normalize_mac("aabbcc*")
assert result == "AA:BB:CC:*", f"Expected 'AA:BB:CC:*' but got '{result}'"
assert normalize_mac("aa:bb:cc:dd:ee:ff") == "AA:BB:CC:DD:EE:FF"
def test_normalize_mac_preserves_internet_root():
assert normalize_mac("internet") == "Internet"
assert normalize_mac("Internet") == "Internet"
assert normalize_mac("INTERNET") == "Internet"