mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-03 08:41:35 -07:00
ALL:Authoritative plugin fields
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
241
server/db/authoritative_handler.py
Normal file
241
server/db/authoritative_handler.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}',
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user