ALL:Authoritative plugin fields

This commit is contained in:
Jokob @NetAlertX
2026-01-19 11:28:37 +00:00
parent 1e289e94e3
commit 3b203536b8
61 changed files with 5018 additions and 154 deletions

View File

@@ -72,6 +72,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
BaseResponse, DeviceTotalsResponse,
DeleteDevicesRequest, DeviceImportRequest,
DeviceImportResponse, UpdateDeviceColumnRequest,
LockDeviceFieldRequest,
CopyDeviceRequest, TriggerScanRequest,
OpenPortsRequest,
OpenPortsResponse, WakeOnLanRequest,
@@ -444,6 +445,62 @@ def api_device_update_column(mac, payload=None):
return jsonify(result)
@app.route("/device/<mac>/field/lock", methods=["POST"])
@validate_request(
operation_id="lock_device_field",
summary="Lock/Unlock Device Field",
description="Lock a field to prevent plugin overwrites or unlock it to allow overwrites.",
path_params=[{
"name": "mac",
"description": "Device MAC address",
"schema": {"type": "string"}
}],
request_model=LockDeviceFieldRequest,
response_model=BaseResponse,
tags=["devices"],
auth_callable=is_authorized
)
def api_device_field_lock(mac, payload=None):
"""Lock or unlock a device field by setting its source to LOCKED or USER."""
data = request.get_json() or {}
field_name = data.get("fieldName")
should_lock = data.get("lock", False)
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()
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
})
else:
return jsonify(result), 400
except Exception as e:
mylog("error", f"Error locking field {field_name} for {mac}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/mcp/sse/device/<mac>/set-alias', methods=['POST'])
@app.route('/device/<mac>/set-alias', methods=['POST'])
@validate_request(

View File

@@ -58,6 +58,10 @@ class Device(ObjectType):
devFirstConnection = String(description="Timestamp of first discovery")
devLastConnection = String(description="Timestamp of last connection")
devLastIP = String(description="Last known IP address")
devPrimaryIPv4 = String(description="Primary IPv4 address")
devPrimaryIPv6 = String(description="Primary IPv6 address")
devVlan = String(description="VLAN identifier")
devForceStatus = String(description="Force device status (online/offline/dont_force)")
devStaticIP = Int(description="Static IP flag (0 or 1)")
devScan = Int(description="Scan flag (0 or 1)")
devLogEvents = Int(description="Log events flag (0 or 1)")
@@ -86,6 +90,16 @@ class Device(ObjectType):
devFQDN = String(description="Fully Qualified Domain Name")
devParentRelType = String(description="Relationship type to parent")
devReqNicsOnline = Int(description="Required NICs online flag")
devMacSource = String(description="Source tracking for devMac (USER, LOCKED, NEWDEV, or plugin prefix)")
devNameSource = String(description="Source tracking for devName")
devFqdnSource = String(description="Source tracking for devFQDN")
devLastIpSource = String(description="Source tracking for devLastIP")
devVendorSource = String(description="Source tracking for devVendor")
devSsidSource = String(description="Source tracking for devSSID")
devParentMacSource = String(description="Source tracking for devParentMAC")
devParentPortSource = String(description="Source tracking for devParentPort")
devParentRelTypeSource = String(description="Source tracking for devParentRelType")
devVlanSource = String(description="Source tracking for devVlan")
class DeviceResult(ObjectType):

View File

@@ -135,12 +135,26 @@ class DeviceInfo(BaseModel):
devMac: str = Field(..., description="Device MAC address")
devName: Optional[str] = Field(None, description="Device display name/alias")
devLastIP: Optional[str] = Field(None, description="Last known IP address")
devPrimaryIPv4: Optional[str] = Field(None, description="Primary IPv4 address")
devPrimaryIPv6: Optional[str] = Field(None, description="Primary IPv6 address")
devVlan: Optional[str] = Field(None, description="VLAN identifier")
devForceStatus: Optional[str] = Field(None, description="Force device status (online/offline/dont_force)")
devVendor: Optional[str] = Field(None, description="Hardware vendor from OUI lookup")
devOwner: Optional[str] = Field(None, description="Device owner")
devType: Optional[str] = Field(None, description="Device type classification")
devFavorite: Optional[int] = Field(0, description="Favorite flag (0 or 1)")
devPresentLastScan: Optional[int] = Field(None, description="Present in last scan (0 or 1)")
devStatus: Optional[str] = Field(None, description="Online/Offline status")
devMacSource: Optional[str] = Field(None, description="Source of devMac (USER, LOCKED, or plugin prefix)")
devNameSource: Optional[str] = Field(None, description="Source of devName")
devFqdnSource: Optional[str] = Field(None, description="Source of devFQDN")
devLastIpSource: Optional[str] = Field(None, description="Source of devLastIP")
devVendorSource: Optional[str] = Field(None, description="Source of devVendor")
devSsidSource: Optional[str] = Field(None, description="Source of devSSID")
devParentMacSource: Optional[str] = Field(None, description="Source of devParentMAC")
devParentPortSource: Optional[str] = Field(None, description="Source of devParentPort")
devParentRelTypeSource: Optional[str] = Field(None, description="Source of devParentRelType")
devVlanSource: Optional[str] = Field(None, description="Source of devVlan")
class DeviceSearchResponse(BaseResponse):
@@ -259,6 +273,12 @@ class UpdateDeviceColumnRequest(BaseModel):
columnValue: Any = Field(..., description="New value for the column")
class LockDeviceFieldRequest(BaseModel):
"""Request to lock/unlock a device field."""
fieldName: str = Field(..., description="Field name to lock/unlock (devMac, devName, devLastIP, etc.)")
lock: bool = Field(True, description="True to lock the field, False to unlock")
class DeviceUpdateRequest(BaseModel):
"""Request to update device fields (create/update)."""
model_config = ConfigDict(extra="allow")

View File

@@ -67,6 +67,10 @@ sql_devices_all = """
IFNULL(devFirstConnection, '') AS devFirstConnection,
IFNULL(devLastConnection, '') AS devLastConnection,
IFNULL(devLastIP, '') AS devLastIP,
IFNULL(devPrimaryIPv4, '') AS devPrimaryIPv4,
IFNULL(devPrimaryIPv6, '') AS devPrimaryIPv6,
IFNULL(devVlan, '') AS devVlan,
IFNULL(devForceStatus, '') AS devForceStatus,
IFNULL(devStaticIP, '') AS devStaticIP,
IFNULL(devScan, '') AS devScan,
IFNULL(devLogEvents, '') AS devLogEvents,
@@ -90,6 +94,16 @@ sql_devices_all = """
IFNULL(devFQDN, '') AS devFQDN,
IFNULL(devParentRelType, '') AS devParentRelType,
IFNULL(devReqNicsOnline, '') AS devReqNicsOnline,
IFNULL(devMacSource, '') AS devMacSource,
IFNULL(devNameSource, '') AS devNameSource,
IFNULL(devFqdnSource, '') AS devFqdnSource,
IFNULL(devLastIpSource, '') AS devLastIpSource,
IFNULL(devVendorSource, '') AS devVendorSource,
IFNULL(devSsidSource, '') AS devSsidSource,
IFNULL(devParentMacSource, '') AS devParentMacSource,
IFNULL(devParentPortSource, '') AS devParentPortSource,
IFNULL(devParentRelTypeSource, '') AS devParentRelTypeSource,
IFNULL(devVlanSource, '') AS devVlanSource,
CASE
WHEN devIsNew = 1 THEN 'New'
WHEN devPresentLastScan = 1 THEN 'On-line'

View File

@@ -147,10 +147,38 @@ class DB:
# Add Devices fields if missing
if not ensure_column(self.sql, "Devices", "devFQDN", "TEXT"):
raise RuntimeError("ensure_column(devFQDN) failed")
if not ensure_column(self.sql, "Devices", "devPrimaryIPv4", "TEXT"):
raise RuntimeError("ensure_column(devPrimaryIPv4) failed")
if not ensure_column(self.sql, "Devices", "devPrimaryIPv6", "TEXT"):
raise RuntimeError("ensure_column(devPrimaryIPv6) failed")
if not ensure_column(self.sql, "Devices", "devVlan", "TEXT"):
raise RuntimeError("ensure_column(devVlan) failed")
if not ensure_column(self.sql, "Devices", "devForceStatus", "TEXT"):
raise RuntimeError("ensure_column(devForceStatus) failed")
if not ensure_column(self.sql, "Devices", "devParentRelType", "TEXT"):
raise RuntimeError("ensure_column(devParentRelType) failed")
if not ensure_column(self.sql, "Devices", "devReqNicsOnline", "INTEGER"):
raise RuntimeError("ensure_column(devReqNicsOnline) failed")
if not ensure_column(self.sql, "Devices", "devMacSource", "TEXT"):
raise RuntimeError("ensure_column(devMacSource) failed")
if not ensure_column(self.sql, "Devices", "devNameSource", "TEXT"):
raise RuntimeError("ensure_column(devNameSource) failed")
if not ensure_column(self.sql, "Devices", "devFqdnSource", "TEXT"):
raise RuntimeError("ensure_column(devFqdnSource) failed")
if not ensure_column(self.sql, "Devices", "devLastIpSource", "TEXT"):
raise RuntimeError("ensure_column(devLastIpSource) failed")
if not ensure_column(self.sql, "Devices", "devVendorSource", "TEXT"):
raise RuntimeError("ensure_column(devVendorSource) failed")
if not ensure_column(self.sql, "Devices", "devSsidSource", "TEXT"):
raise RuntimeError("ensure_column(devSsidSource) failed")
if not ensure_column(self.sql, "Devices", "devParentMacSource", "TEXT"):
raise RuntimeError("ensure_column(devParentMacSource) failed")
if not ensure_column(self.sql, "Devices", "devParentPortSource", "TEXT"):
raise RuntimeError("ensure_column(devParentPortSource) failed")
if not ensure_column(self.sql, "Devices", "devParentRelTypeSource", "TEXT"):
raise RuntimeError("ensure_column(devParentRelTypeSource) failed")
if not ensure_column(self.sql, "Devices", "devVlanSource", "TEXT"):
raise RuntimeError("ensure_column(devVlanSource) failed")
# Settings table setup
ensure_Settings(self.sql)

View File

@@ -0,0 +1,241 @@
"""
Authoritative field update handler for NetAlertX.
This module enforces source-tracking policies when plugins or users update device fields.
It prevents overwrites when fields are marked as USER or LOCKED, and tracks the source
of each field value.
Author: NetAlertX Core
License: GNU GPLv3
"""
import sys
import os
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/server"])
from logger import mylog # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
# Map of field to its source tracking field
FIELD_SOURCE_MAP = {
"devMac": "devMacSource",
"devName": "devNameSource",
"devFQDN": "devFqdnSource",
"devLastIP": "devLastIpSource",
"devVendor": "devVendorSource",
"devSSID": "devSsidSource",
"devParentMAC": "devParentMacSource",
"devParentPort": "devParentPortSource",
"devParentRelType": "devParentRelTypeSource",
"devVlan": "devVlanSource",
}
# Fields that support source tracking
TRACKED_FIELDS = set(FIELD_SOURCE_MAP.keys())
def get_plugin_authoritative_settings(plugin_prefix):
"""
Get SET_ALWAYS and SET_EMPTY settings for a plugin.
Args:
plugin_prefix: The unique prefix of the plugin (e.g., "UNIFIAPI").
Returns:
dict: {
"set_always": [list of fields],
"set_empty": [list of fields]
}
"""
try:
set_always_key = f"{plugin_prefix}_SET_ALWAYS"
set_empty_key = f"{plugin_prefix}_SET_EMPTY"
set_always = get_setting_value(set_always_key) or []
set_empty = get_setting_value(set_empty_key) or []
# Normalize to list of strings if they aren't already
if isinstance(set_always, str):
set_always = [set_always]
if isinstance(set_empty, str):
set_empty = [set_empty]
return {
"set_always": list(set_always) if set_always else [],
"set_empty": list(set_empty) if set_empty else [],
}
except Exception as e:
mylog("debug", [f"[authoritative_handler] Failed to get settings for {plugin_prefix}: {e}"])
return {"set_always": [], "set_empty": []}
def can_overwrite_field(field_name, current_source, plugin_prefix, plugin_settings, field_value):
"""
Determine if a plugin can overwrite a field.
Rules:
- If current_source is USER or LOCKED, cannot overwrite.
- If field_value is empty/None, cannot overwrite.
- If field is in SET_ALWAYS, can overwrite.
- If field is in SET_EMPTY AND current value is empty, can overwrite.
- If neither SET_ALWAYS nor SET_EMPTY apply, can overwrite empty fields only.
Args:
field_name: The field being updated (e.g., "devName").
current_source: The current source value (e.g., "USER", "LOCKED", "ARPSCAN", "NEWDEV", "").
plugin_prefix: The unique prefix of the overwriting plugin.
plugin_settings: dict with "set_always" and "set_empty" lists.
field_value: The new value the plugin wants to write.
Returns:
bool: True if the overwrite is allowed, False otherwise.
"""
# Rule 1: USER and LOCKED are protected
if current_source in ("USER", "LOCKED"):
return False
# Rule 2: Plugin must provide a non-empty value
if not field_value or (isinstance(field_value, str) and not field_value.strip()):
return False
# Rule 3: SET_ALWAYS takes precedence
set_always = plugin_settings.get("set_always", [])
if field_name in set_always:
return True
# Rule 4: SET_EMPTY allows overwriting only if field is empty
set_empty = plugin_settings.get("set_empty", [])
if field_name in set_empty:
# Check if field is "empty" (no current source or NEWDEV)
return not current_source or current_source == "NEWDEV"
# Rule 5: Default behavior - overwrite if field is empty/NEWDEV
return not current_source or current_source == "NEWDEV"
def get_source_for_field_update(field_name, plugin_prefix, is_user_override=False):
"""
Determine what source value should be set when a field is updated.
Args:
field_name: The field being updated.
plugin_prefix: The unique prefix of the plugin writing (e.g., "UNIFIAPI").
Ignored if is_user_override is True.
is_user_override: If True, return "USER"; if False, return plugin_prefix.
Returns:
str: The source value to set for the *Source field.
"""
if is_user_override:
return "USER"
return plugin_prefix
def enforce_source_on_user_update(devMac, updates_dict, conn):
"""
When a user updates device fields, enforce source tracking.
For each field with a corresponding *Source field:
- If the field value is being changed, set the *Source to "USER".
- If user explicitly locks a field, set the *Source to "LOCKED".
Args:
devMac: The MAC address of the device being updated.
updates_dict: Dict of field -> value being updated.
conn: Database connection object.
"""
cur = conn.cursor()
# Check if field has a corresponding source and should be updated
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 updates_to_apply:
return
# Build SET clause
set_clause = ", ".join([f"{k}=?" for k in updates_to_apply.keys()])
values = list(updates_to_apply.values())
values.append(devMac)
sql = f"UPDATE Devices SET {set_clause} WHERE devMac = ?"
try:
cur.execute(sql, values)
conn.commit()
mylog(
"debug",
[f"[enforce_source_on_user_update] Updated sources for {devMac}: {updates_to_apply}"],
)
except Exception as e:
mylog("none", [f"[enforce_source_on_user_update] ERROR: {e}"])
conn.rollback()
raise
def lock_field(devMac, field_name, conn):
"""
Lock a field so it won't be overwritten by plugins.
Args:
devMac: The MAC address of the device.
field_name: The field to lock.
conn: Database connection object.
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[lock_field] Field {field_name} does not support locking"])
return
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
sql = f"UPDATE Devices SET {source_field}='LOCKED' WHERE devMac = ?"
try:
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[lock_field] Locked {field_name} for {devMac}"])
except Exception as e:
mylog("none", [f"[lock_field] ERROR: {e}"])
conn.rollback()
raise
def unlock_field(devMac, field_name, conn):
"""
Unlock a field so plugins can overwrite it again.
Args:
devMac: The MAC address of the device.
field_name: The field to unlock.
conn: Database connection object.
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[unlock_field] Field {field_name} does not support unlocking"])
return
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
# Unlock by resetting to empty (allows overwrite)
sql = f"UPDATE Devices SET {source_field}='' WHERE devMac = ?"
try:
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[unlock_field] Unlocked {field_name} for {devMac}"])
except Exception as e:
mylog("none", [f"[unlock_field] ERROR: {e}"])
conn.rollback()
raise

View File

@@ -9,6 +9,59 @@ from logger import mylog # noqa: E402 [flake8 lint suppression]
from messaging.in_app import write_notification # noqa: E402 [flake8 lint suppression]
# Define the expected Devices table columns (hardcoded base schema) [v25.5.24]
EXPECTED_DEVICES_COLUMNS = [
"devMac",
"devName",
"devOwner",
"devType",
"devVendor",
"devFavorite",
"devGroup",
"devComments",
"devFirstConnection",
"devLastConnection",
"devLastIP",
"devFQDN",
"devPrimaryIPv4",
"devPrimaryIPv6",
"devVlan",
"devForceStatus",
"devStaticIP",
"devScan",
"devLogEvents",
"devAlertEvents",
"devAlertDown",
"devSkipRepeated",
"devLastNotification",
"devPresentLastScan",
"devIsNew",
"devLocation",
"devIsArchived",
"devParentMAC",
"devParentPort",
"devParentRelType",
"devReqNicsOnline",
"devIcon",
"devGUID",
"devSite",
"devSSID",
"devSyncHubNode",
"devSourcePlugin",
"devMacSource",
"devNameSource",
"devFqdnSource",
"devLastIpSource",
"devVendorSource",
"devSsidSource",
"devParentMacSource",
"devParentPortSource",
"devParentRelTypeSource",
"devVlanSource",
"devCustomProps",
]
def ensure_column(sql, table: str, column_name: str, column_type: str) -> bool:
"""
Ensures a column exists in the specified table. If missing, attempts to add it.
@@ -30,63 +83,18 @@ def ensure_column(sql, table: str, column_name: str, column_type: str) -> bool:
if column_name in actual_columns:
return True # Already exists
# Define the expected columns (hardcoded base schema) [v25.5.24] - available in the default app.db
expected_columns = [
"devMac",
"devName",
"devOwner",
"devType",
"devVendor",
"devFavorite",
"devGroup",
"devComments",
"devFirstConnection",
"devLastConnection",
"devLastIP",
"devStaticIP",
"devScan",
"devLogEvents",
"devAlertEvents",
"devAlertDown",
"devSkipRepeated",
"devLastNotification",
"devPresentLastScan",
"devIsNew",
"devLocation",
"devIsArchived",
"devParentMAC",
"devParentPort",
"devIcon",
"devGUID",
"devSite",
"devSSID",
"devSyncHubNode",
"devSourcePlugin",
"devCustomProps",
]
# Check for mismatches in base schema
missing = set(expected_columns) - set(actual_columns)
extra = set(actual_columns) - set(expected_columns)
if missing:
# Validate that this column is in the expected schema
expected = EXPECTED_DEVICES_COLUMNS if table == "Devices" else []
if not expected or column_name not in expected:
msg = (
f"[db_upgrade] ⚠ ERROR: Unexpected DB structure "
f"(missing: {', '.join(missing) if missing else 'none'}, "
f"extra: {', '.join(extra) if extra else 'none'}) - "
"aborting schema change to prevent corruption. "
f"[db_upgrade] ⚠ ERROR: Column '{column_name}' is not in expected schema - "
f"aborting to prevent corruption. "
"Check https://docs.netalertx.com/UPDATES"
)
mylog("none", [msg])
write_notification(msg)
return False
if extra:
msg = (
f"[db_upgrade] Extra DB columns detected in {table}: {', '.join(extra)}"
)
mylog("none", [msg])
# Add missing column
mylog("verbose", [f"[db_upgrade] Adding '{column_name}' ({column_type}) to {table} table"],)
sql.execute(f'ALTER TABLE "{table}" ADD "{column_name}" {column_type}')
@@ -263,6 +271,7 @@ def ensure_CurrentScan(sql) -> bool:
cur_SyncHubNodeName STRING(50),
cur_NetworkSite STRING(250),
cur_SSID STRING(250),
cur_devVlan STRING(250),
cur_NetworkNodeMAC STRING(250),
cur_PORT STRING(250),
cur_Type STRING(250)

View File

@@ -9,6 +9,7 @@ from logger import mylog
from models.plugin_object_instance import PluginObjectInstance
from database import get_temp_db_connection
from db.db_helper import get_table_json, get_device_condition_by_status, row_to_json, get_date_from_period
from db.authoritative_handler import enforce_source_on_user_update, lock_field, unlock_field, FIELD_SOURCE_MAP
from helper import is_random_mac, get_setting_value
from utils.datetime_utils import timeNowDB, format_date
@@ -593,6 +594,19 @@ class DeviceInstance:
cur = conn.cursor()
cur.execute(sql, values)
conn.commit()
# Enforce source tracking on user updates
# User-updated fields should have their *Source set to "USER"
user_updated_fields = {k: v for k, v in data.items() if k in FIELD_SOURCE_MAP}
if user_updated_fields and not data.get("createNew", False):
try:
enforce_source_on_user_update(normalized_mac, user_updated_fields, conn)
except Exception as e:
mylog("none", [f"[DeviceInstance] Failed to enforce source tracking: {e}"])
conn.rollback()
conn.close()
return {"success": False, "error": f"Source tracking failed: {e}"}
conn.close()
mylog("debug", f"[DeviceInstance] setDeviceData SQL: {sql.strip()}")
@@ -664,6 +678,32 @@ class DeviceInstance:
conn.close()
return result
def lockDeviceField(self, mac, field_name):
"""Lock a device field so it won't be overwritten by plugins."""
if field_name not in FIELD_SOURCE_MAP:
return {"success": False, "error": f"Field {field_name} does not support locking"}
try:
conn = get_temp_db_connection()
lock_field(mac, field_name, conn)
conn.close()
return {"success": True, "message": f"Field {field_name} locked"}
except Exception as e:
return {"success": False, "error": str(e)}
def unlockDeviceField(self, mac, field_name):
"""Unlock a device field so plugins can overwrite it again."""
if field_name not in FIELD_SOURCE_MAP:
return {"success": False, "error": f"Field {field_name} does not support unlocking"}
try:
conn = get_temp_db_connection()
unlock_field(mac, field_name, conn)
conn.close()
return {"success": True, "message": f"Field {field_name} unlocked"}
except Exception as e:
return {"success": False, "error": str(e)}
def copyDevice(self, mac_from, mac_to):
"""Copy a device entry from one MAC to another."""
conn = get_temp_db_connection()

View File

@@ -68,25 +68,34 @@ def update_devices_data_from_scan(db):
WHERE NOT EXISTS (SELECT 1 FROM CurrentScan
WHERE devMac = cur_MAC) """)
# Update IP
mylog("debug", "[Update Devices] - cur_IP -> devLastIP (always updated)")
sql.execute("""UPDATE Devices
SET devLastIP = (
SELECT cur_IP
FROM CurrentScan
WHERE devMac = cur_MAC
AND cur_IP IS NOT NULL
AND cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
ORDER BY cur_DateTime DESC
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM CurrentScan
WHERE devMac = cur_MAC
AND cur_IP IS NOT NULL
AND cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
)""")
# Update IP (devLastIP always updated, primary IPv4/IPv6 set based on family)
mylog("debug", "[Update Devices] - cur_IP -> devLastIP / devPrimaryIPv4 / devPrimaryIPv6")
sql.execute("""
WITH LatestIP AS (
SELECT c.cur_MAC AS mac, c.cur_IP AS ip
FROM CurrentScan c
WHERE c.cur_IP IS NOT NULL
AND c.cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
AND c.cur_DateTime = (
SELECT MAX(c2.cur_DateTime)
FROM CurrentScan c2
WHERE c2.cur_MAC = c.cur_MAC
AND c2.cur_IP IS NOT NULL
AND c2.cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
)
)
UPDATE Devices
SET devLastIP = (SELECT ip FROM LatestIP WHERE mac = devMac),
devPrimaryIPv4 = CASE
WHEN (SELECT ip FROM LatestIP WHERE mac = devMac) LIKE '%:%' THEN devPrimaryIPv4
ELSE (SELECT ip FROM LatestIP WHERE mac = devMac)
END,
devPrimaryIPv6 = CASE
WHEN (SELECT ip FROM LatestIP WHERE mac = devMac) LIKE '%:%' THEN (SELECT ip FROM LatestIP WHERE mac = devMac)
ELSE devPrimaryIPv6
END
WHERE EXISTS (SELECT 1 FROM LatestIP WHERE mac = devMac);
""")
# Update only devices with empty, NULL or (u(U)nknown) vendors
mylog("debug", "[Update Devices] - cur_Vendor -> (if empty) devVendor")
@@ -344,7 +353,14 @@ def print_scan_stats(db):
(SELECT COUNT(*) FROM Devices WHERE devAlertDown != 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = cur_MAC)) AS new_down_alerts,
(SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 0) AS new_connections,
(SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = cur_MAC)) AS disconnections,
(SELECT COUNT(*) FROM Devices, CurrentScan WHERE devMac = cur_MAC AND devLastIP <> cur_IP) AS ip_changes,
(SELECT COUNT(*) FROM Devices, CurrentScan
WHERE devMac = cur_MAC
AND cur_IP IS NOT NULL
AND cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
AND cur_IP <> COALESCE(devPrimaryIPv4, '')
AND cur_IP <> COALESCE(devPrimaryIPv6, '')
AND cur_IP <> COALESCE(devLastIP, '')
) AS ip_changes,
cur_ScanMethod,
COUNT(*) AS scan_method_count
FROM CurrentScan
@@ -525,6 +541,12 @@ def create_new_devices(db):
else (get_setting_value("SYNC_node_name"))
)
# 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
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 ""
# Preparing the individual insert statement
sqlQuery = f"""INSERT OR IGNORE INTO Devices
(
@@ -532,6 +554,8 @@ def create_new_devices(db):
devName,
devVendor,
devLastIP,
devPrimaryIPv4,
devPrimaryIPv6,
devFirstConnection,
devLastConnection,
devSyncHubNode,
@@ -549,7 +573,9 @@ def create_new_devices(db):
'{sanitize_SQL_input(cur_MAC)}',
'{sanitize_SQL_input(cur_Name)}',
'{sanitize_SQL_input(cur_Vendor)}',
'{sanitize_SQL_input(cur_IP)}',
'{sanitize_SQL_input(cur_IP_normalized)}',
'{sanitize_SQL_input(primary_ipv4)}',
'{sanitize_SQL_input(primary_ipv6)}',
?,
?,
'{sanitize_SQL_input(cur_SyncHubNodeName)}',

View File

@@ -182,7 +182,11 @@ def insert_events(db):
'Previous IP: '|| devLastIP, devAlertEvents
FROM Devices, CurrentScan
WHERE devMac = cur_MAC
AND devLastIP <> cur_IP """)
AND cur_IP IS NOT NULL
AND cur_IP NOT IN ('', 'null', '(unknown)', '(Unknown)')
AND cur_IP <> COALESCE(devPrimaryIPv4, '')
AND cur_IP <> COALESCE(devPrimaryIPv6, '')
AND cur_IP <> COALESCE(devLastIP, '') """)
mylog("debug", "[Events] - Events end")