mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-01-28 00:28:25 -05:00
Merge branch 'main' of https://github.com/jokob-sk/NetAlertX
This commit is contained in:
112
.github/workflows/docker_dev_unsafe.yml
vendored
Normal file
112
.github/workflows/docker_dev_unsafe.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: docker-unsafe
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next_release
|
||||
pull_request:
|
||||
branches:
|
||||
- next_release
|
||||
|
||||
jobs:
|
||||
docker_dev_unsafe:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: >
|
||||
!contains(github.event.head_commit.message, 'PUSHPROD') &&
|
||||
(
|
||||
github.repository == 'jokob-sk/NetAlertX' ||
|
||||
github.repository == 'netalertx/NetAlertX'
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# --- Generate timestamped dev version
|
||||
- name: Generate timestamp version
|
||||
id: timestamp
|
||||
run: |
|
||||
ts=$(date -u +'%Y%m%d-%H%M%S')
|
||||
echo "version=dev-${ts}" >> $GITHUB_OUTPUT
|
||||
echo "Generated version: dev-${ts}"
|
||||
|
||||
- name: Set up dynamic build ARGs
|
||||
id: getargs
|
||||
run: echo "version=$(cat ./stable/VERSION)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get release version
|
||||
id: get_version
|
||||
run: echo "version=Dev" >> $GITHUB_OUTPUT
|
||||
|
||||
# --- debug output
|
||||
- name: Debug version
|
||||
run: |
|
||||
echo "GITHUB_REF: $GITHUB_REF"
|
||||
echo "Version: '${{ steps.get_version.outputs.version }}'"
|
||||
|
||||
# --- Write the timestamped version to .VERSION file
|
||||
- name: Create .VERSION file
|
||||
run: echo "${{ steps.timestamp.outputs.version }}" > .VERSION
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/netalertx/netalertx-dev-unsafe
|
||||
jokobsk/netalertx-dev-unsafe
|
||||
tags: |
|
||||
type=raw,value=unsafe
|
||||
type=raw,value=${{ steps.timestamp.outputs.version }}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Login GHCR (netalertx org)
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login GHCR (jokob-sk legacy)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: jokob-sk
|
||||
password: ${{ secrets.GHCR_JOKOBSK_PAT }}
|
||||
|
||||
- name: Log in to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=NetAlertX Dev Unsafe
|
||||
org.opencontainers.image.description=EXPERIMENTAL BUILD – NOT SUPPORTED – DATA LOSS POSSIBLE
|
||||
org.opencontainers.image.version=${{ steps.timestamp.outputs.version }}
|
||||
netalertx.stability=unsafe
|
||||
netalertx.support=none
|
||||
netalertx.data_risk=high
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -1,57 +1,46 @@
|
||||
# Warning - use of this unhardened image is not recommended for production use.
|
||||
# This image is provided for backward compatibility, development and testing purposes only.
|
||||
# For production use, please use the hardened image built with Alpine. This image attempts to
|
||||
# treat a container as an operating system, which is an anti-pattern and a common source of
|
||||
# security issues.
|
||||
#
|
||||
# The default Dockerfile/docker-compose image contains the following security improvements
|
||||
# over the Debian image:
|
||||
# - read-only filesystem
|
||||
# - no sudo access
|
||||
# - least possible permissions on all files and folders
|
||||
# - Root user has all permissions revoked and is unused
|
||||
# - Secure umask applied so files are owner-only by default
|
||||
# - non-privileged user runs the application
|
||||
# - no shell access for non-privileged users
|
||||
# - no unnecessary packages or services
|
||||
# - reduced capabilities
|
||||
# - tmpfs for writable folders
|
||||
# - healthcheck
|
||||
# - no package managers
|
||||
# - no compilers or build tools
|
||||
# - no systemd, uses lightweight init system
|
||||
# - no persistent storage except for config and db volumes
|
||||
# - minimal image size due to segmented build stages
|
||||
# - minimal base image (Alpine Linux)
|
||||
# - minimal python environment (venv, no pip)
|
||||
# - minimal stripped web server
|
||||
# - minimal stripped php environment
|
||||
# - minimal services (nginx, php-fpm, crond, no unnecessary services or service managers)
|
||||
# - minimal users and groups (netalertx and readonly only, no others)
|
||||
# - minimal permissions (read-only for most files and folders, write-only for necessary folders)
|
||||
# - minimal capabilities (NET_ADMIN and NET_RAW only, no others)
|
||||
# - minimal environment variables (only necessary ones, no others)
|
||||
# - minimal entrypoint (only necessary commands, no others)
|
||||
# - Uses the same base image as the development environmnment (Alpine Linux)
|
||||
# - Uses the same services as the development environment (nginx, php-fpm, crond)
|
||||
# - Uses the same environment variables as the development environment (only necessary ones, no others)
|
||||
# - Uses the same file and folder structure as the development environment (only necessary ones, no others)
|
||||
# NetAlertX is designed to be run as an unattended network security monitoring appliance, which means it
|
||||
# should be able to operate without human intervention. Overall, the hardened image is designed to be as
|
||||
# secure as possible while still being functional and is recommended because you cannot attack a surface
|
||||
# that isn't there.
|
||||
# Stage 1: Builder
|
||||
# Install build dependencies and create virtual environment
|
||||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gcc \
|
||||
git \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
rustc \
|
||||
cargo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
#TZ=Europe/London
|
||||
RUN python3 -m venv ${VIRTUAL_ENV}
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Stage 2: Runner
|
||||
# Main runtime stage with minimum requirements
|
||||
FROM debian:bookworm-slim AS runner
|
||||
|
||||
ARG INSTALL_DIR=/app
|
||||
ARG NETALERTX_UID=20211
|
||||
ARG NETALERTX_GID=20211
|
||||
ARG READONLY_UID=20212
|
||||
ARG READONLY_GID=20212
|
||||
|
||||
# NetAlertX app directories
|
||||
ENV INSTALL_DIR=/app
|
||||
ENV NETALERTX_APP=${INSTALL_DIR}
|
||||
ENV NETALERTX_DATA=/data
|
||||
ENV NETALERTX_CONFIG=${NETALERTX_DATA}/config
|
||||
ENV NETALERTX_FRONT=${NETALERTX_APP}/front
|
||||
ENV NETALERTX_PLUGINS=${NETALERTX_FRONT}/plugins
|
||||
ENV NETALERTX_SERVER=${NETALERTX_APP}/server
|
||||
ENV NETALERTX_API=/tmp/api
|
||||
ENV NETALERTX_DB=${NETALERTX_DATA}/db
|
||||
@@ -59,8 +48,8 @@ ENV NETALERTX_DB_FILE=${NETALERTX_DB}/app.db
|
||||
ENV NETALERTX_BACK=${NETALERTX_APP}/back
|
||||
ENV NETALERTX_LOG=/tmp/log
|
||||
ENV NETALERTX_PLUGINS_LOG=${NETALERTX_LOG}/plugins
|
||||
ENV NETALERTX_CONFIG_FILE=${NETALERTX_CONFIG}/app.conf
|
||||
|
||||
# NetAlertX log files
|
||||
ENV LOG_IP_CHANGES=${NETALERTX_LOG}/IP_changes.log
|
||||
ENV LOG_APP=${NETALERTX_LOG}/app.log
|
||||
ENV LOG_APP_FRONT=${NETALERTX_LOG}/app_front.log
|
||||
@@ -75,102 +64,176 @@ ENV LOG_STDOUT=${NETALERTX_LOG}/stdout.log
|
||||
ENV LOG_CRON=${NETALERTX_LOG}/cron.log
|
||||
ENV LOG_NGINX_ERROR=${NETALERTX_LOG}/nginx-error.log
|
||||
|
||||
# System Services configuration files
|
||||
ENV ENTRYPOINT_CHECKS=/entrypoint.d
|
||||
ENV SYSTEM_SERVICES=/services
|
||||
ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts
|
||||
ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config
|
||||
ENV SYSTEM_NGINIX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
||||
ENV SYSTEM_NGINX_CONFIG_FILE=${SYSTEM_NGINIX_CONFIG}/nginx.conf
|
||||
ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
||||
ENV SYSTEM_NGINX_CONFIG_TEMPLATE=${SYSTEM_NGINX_CONFIG}/netalertx.conf.template
|
||||
ENV SYSTEM_SERVICES_CONFIG_CRON=${SYSTEM_SERVICES_CONFIG}/cron
|
||||
ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config
|
||||
ENV NETALERTX_CONFIG_FILE=${NETALERTX_CONFIG}/app.conf
|
||||
ENV SYSTEM_SERVICES_ACTIVE_CONFIG_FILE=${SYSTEM_SERVICES_ACTIVE_CONFIG}/nginx.conf
|
||||
ENV SYSTEM_SERVICES_PHP_FOLDER=${SYSTEM_SERVICES_CONFIG}/php
|
||||
ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d
|
||||
ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond
|
||||
ENV SYSTEM_SERVICES_RUN=/tmp/run
|
||||
ENV SYSTEM_SERVICES_RUN_TMP=${SYSTEM_SERVICES_RUN}/tmp
|
||||
ENV SYSTEM_SERVICES_RUN_LOG=${SYSTEM_SERVICES_RUN}/logs
|
||||
ENV PHP_FPM_CONFIG_FILE=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.conf
|
||||
|
||||
#Python environment
|
||||
ENV PYTHONPATH=${NETALERTX_SERVER}
|
||||
ENV READ_ONLY_FOLDERS="${NETALERTX_BACK} ${NETALERTX_FRONT} ${NETALERTX_SERVER} ${SYSTEM_SERVICES} \
|
||||
${SYSTEM_SERVICES_CONFIG} ${ENTRYPOINT_CHECKS}"
|
||||
ENV READ_WRITE_FOLDERS="${NETALERTX_DATA} ${NETALERTX_CONFIG} ${NETALERTX_DB} ${NETALERTX_API} \
|
||||
${NETALERTX_LOG} ${NETALERTX_PLUGINS_LOG} ${SYSTEM_SERVICES_RUN} \
|
||||
${SYSTEM_SERVICES_RUN_TMP} ${SYSTEM_SERVICES_RUN_LOG} \
|
||||
${SYSTEM_SERVICES_ACTIVE_CONFIG}"
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV VIRTUAL_ENV_BIN=/opt/venv/bin
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}:/services"
|
||||
ENV VENDORSPATH=/app/back/ieee-oui.txt
|
||||
ENV VENDORSPATH_NEWEST=${SYSTEM_SERVICES_RUN_TMP}/ieee-oui.txt
|
||||
ENV PYTHONPATH=${NETALERTX_APP}:${NETALERTX_SERVER}:${NETALERTX_PLUGINS}:${VIRTUAL_ENV}/lib/python3.11/site-packages
|
||||
ENV PATH="${SYSTEM_SERVICES}:${VIRTUAL_ENV_BIN}:$PATH"
|
||||
|
||||
|
||||
# App Environment
|
||||
ENV LISTEN_ADDR=0.0.0.0
|
||||
ENV PORT=20211
|
||||
ENV NETALERTX_DEBUG=0
|
||||
|
||||
#Container environment
|
||||
ENV VENDORSPATH=/app/back/ieee-oui.txt
|
||||
ENV VENDORSPATH_NEWEST=${SYSTEM_SERVICES_RUN_TMP}/ieee-oui.txt
|
||||
ENV ENVIRONMENT=debian
|
||||
ENV USER=netalertx
|
||||
ENV USER_ID=1000
|
||||
ENV USER_GID=1000
|
||||
ENV READ_ONLY_USER=readonly READ_ONLY_GROUP=readonly
|
||||
ENV NETALERTX_USER=netalertx NETALERTX_GROUP=netalertx
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
# Todo, figure out why using a workdir instead of full paths don't work
|
||||
# Todo, do we still need all these packages? I can already see sudo which isn't needed
|
||||
|
||||
|
||||
# create pi user and group
|
||||
# add root and www-data to pi group so they can r/w files and db
|
||||
RUN groupadd --gid "${USER_GID}" "${USER}" && \
|
||||
useradd \
|
||||
--uid ${USER_ID} \
|
||||
--gid ${USER_GID} \
|
||||
--create-home \
|
||||
--shell /bin/bash \
|
||||
${USER} && \
|
||||
usermod -a -G ${USER_GID} root && \
|
||||
usermod -a -G ${USER_GID} www-data
|
||||
|
||||
COPY --chmod=775 --chown=${USER_ID}:${USER_GID} install/production-filesystem/ /
|
||||
COPY --chmod=775 --chown=${USER_ID}:${USER_GID} . ${INSTALL_DIR}/
|
||||
|
||||
|
||||
# ❗ IMPORTANT - if you modify this file modify the /install/install_dependecies.debian.sh file as well ❗
|
||||
# hadolint ignore=DL3008,DL3027
|
||||
# Install dependencies
|
||||
# Using sury.org for PHP 8.3 to match Alpine version
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini snmp ca-certificates curl libwww-perl arp-scan sudo gettext-base \
|
||||
nginx-light php php-cgi php-fpm php-sqlite3 php-curl sqlite3 dnsutils net-tools \
|
||||
python3 python3-dev iproute2 nmap fping python3-pip zip git systemctl usbutils traceroute nbtscan openrc \
|
||||
busybox nginx nginx-core mtr python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# While php8.3 is in debian bookworm repos, php-fpm is not included so we need to add sury.org repo
|
||||
# (Ondřej Surý maintains php packages for debian. This is temp until debian includes php-fpm in their
|
||||
# repos. Likely it will be in Debian Trixie.). This keeps the image up-to-date with the alpine version.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
apt-transport-https \
|
||||
tini \
|
||||
snmp \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libwww-perl \
|
||||
arp-scan \
|
||||
sudo \
|
||||
gettext-base \
|
||||
nginx-light \
|
||||
sqlite3 \
|
||||
dnsutils \
|
||||
net-tools \
|
||||
python3 \
|
||||
iproute2 \
|
||||
nmap \
|
||||
fping \
|
||||
zip \
|
||||
git \
|
||||
usbutils \
|
||||
traceroute \
|
||||
nbtscan \
|
||||
lsb-release \
|
||||
wget && \
|
||||
wget -q -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg && \
|
||||
echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends php8.3-fpm php8.3-cli php8.3-sqlite3 php8.3-common php8.3-curl php8.3-cgi && \
|
||||
ln -s /usr/sbin/php-fpm8.3 /usr/sbin/php-fpm83 && \
|
||||
rm -rf /var/lib/apt/lists/* # make it compatible with alpine version
|
||||
wget \
|
||||
apt-transport-https \
|
||||
gnupg2 \
|
||||
mtr \
|
||||
procps \
|
||||
gosu \
|
||||
&& wget -qO /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg \
|
||||
&& echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
php8.3-fpm \
|
||||
php8.3-cli \
|
||||
php8.3-sqlite3 \
|
||||
php8.3-common \
|
||||
php8.3-curl \
|
||||
&& ln -s /usr/sbin/php-fpm8.3 /usr/sbin/php-fpm \
|
||||
&& ln -s /usr/sbin/php-fpm8.3 /usr/sbin/php-fpm83 \
|
||||
&& ln -s /usr/sbin/gosu /usr/sbin/su-exec \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup virtual python environment and use pip3 to install packages
|
||||
RUN python3 -m venv ${VIRTUAL_ENV} && \
|
||||
/bin/bash -c "source ${VIRTUAL_ENV_BIN}/activate && update-alternatives --install /usr/bin/python python /usr/bin/python3 10 && pip3 install -r ${INSTALL_DIR}/requirements.txt"
|
||||
# Fix permissions for /tmp BEFORE copying anything that might overwrite it with bad perms
|
||||
RUN chmod 1777 /tmp
|
||||
|
||||
# Configure php-fpm
|
||||
RUN chmod -R 755 /services && \
|
||||
chown -R ${USER}:${USER_GID} /services && \
|
||||
sed -i 's/^;listen.mode = .*/listen.mode = 0666/' ${SYSTEM_SERVICES_PHP_FPM_D}/www.conf && \
|
||||
printf "user = %s\ngroup = %s\n" "${USER}" "${USER_GID}" >> /services/config/php/php-fpm.d/www.conf
|
||||
# User setup
|
||||
RUN groupadd -g ${NETALERTX_GID} ${NETALERTX_GROUP} && \
|
||||
useradd -u ${NETALERTX_UID} -g ${NETALERTX_GID} -d ${NETALERTX_APP} -s /bin/bash ${NETALERTX_USER}
|
||||
|
||||
# Copy filesystem (excluding tmp if possible, or we just fix it after)
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} install/production-filesystem/ /
|
||||
# Re-apply sticky bit to /tmp in case COPY overwrote it
|
||||
RUN chmod 1777 /tmp
|
||||
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} --chmod=755 back ${NETALERTX_BACK}
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} --chmod=755 front ${NETALERTX_FRONT}
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} --chmod=755 server ${NETALERTX_SERVER}
|
||||
|
||||
# Create a buildtimestamp.txt to later check if a new version was released
|
||||
RUN date +%s > ${INSTALL_DIR}/front/buildtimestamp.txt
|
||||
USER netalertx:netalertx
|
||||
ENTRYPOINT ["/bin/bash","/entrypoint.sh"]
|
||||
# Create required folders
|
||||
RUN install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FOLDERS} && \
|
||||
chmod 750 /entrypoint.sh /root-entrypoint.sh
|
||||
|
||||
# Copy Version
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .[V]ERSION ${NETALERTX_APP}/.VERSION
|
||||
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .[V]ERSION ${NETALERTX_APP}/.VERSION_PREV
|
||||
|
||||
# Copy venv from builder
|
||||
COPY --from=builder --chown=${READONLY_UID}:${READONLY_GID} ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
# Init process
|
||||
RUN for vfile in .VERSION .VERSION_PREV; do \
|
||||
if [ ! -f "${NETALERTX_APP}/${vfile}" ]; then \
|
||||
echo "DEVELOPMENT 00000000" > "${NETALERTX_APP}/${vfile}"; \
|
||||
fi; \
|
||||
chown ${READONLY_UID}:${READONLY_GID} "${NETALERTX_APP}/${vfile}"; \
|
||||
done && \
|
||||
# Set capabilities for raw socket access
|
||||
setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \
|
||||
setcap cap_net_raw,cap_net_admin+eip /usr/sbin/arp-scan && \
|
||||
setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip /usr/bin/nbtscan && \
|
||||
setcap cap_net_raw,cap_net_admin+eip /usr/bin/traceroute.db && \
|
||||
# Note: python path needs to be dynamic or verificed
|
||||
# setcap cap_net_raw,cap_net_admin+eip $(readlink -f ${VIRTUAL_ENV_BIN}/python) && \
|
||||
/bin/bash /build/init-nginx.sh && \
|
||||
/bin/bash /build/init-php-fpm.sh && \
|
||||
# /bin/bash /build/init-cron.sh && \
|
||||
# Debian cron init might differ, skipping for now or need to check init-cron.sh content
|
||||
# Checking init-backend.sh
|
||||
/bin/bash /build/init-backend.sh && \
|
||||
rm -rf /build && \
|
||||
date +%s > "${NETALERTX_FRONT}/buildtimestamp.txt"
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
||||
|
||||
# Stage 3: Hardened
|
||||
FROM runner AS hardened
|
||||
|
||||
ARG NETALERTX_UID=20211
|
||||
ARG NETALERTX_GID=20211
|
||||
ARG READONLY_UID=20212
|
||||
ARG READONLY_GID=20212
|
||||
ENV READ_ONLY_USER=readonly READ_ONLY_GROUP=readonly
|
||||
|
||||
# Create readonly user
|
||||
RUN groupadd -g ${READONLY_GID} ${READ_ONLY_GROUP} && \
|
||||
useradd -u ${READONLY_UID} -g ${READONLY_GID} -d /app -s /usr/sbin/nologin ${READ_ONLY_USER}
|
||||
|
||||
# Hardening: Remove package managers and set permissions
|
||||
RUN chown -R ${READ_ONLY_USER}:${READ_ONLY_GROUP} ${READ_ONLY_FOLDERS} && \
|
||||
chmod -R 004 ${READ_ONLY_FOLDERS} && \
|
||||
find ${READ_ONLY_FOLDERS} -type d -exec chmod 005 {} + && \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 0777 ${READ_WRITE_FOLDERS} && \
|
||||
chown ${READ_ONLY_USER}:${READ_ONLY_GROUP} /entrypoint.sh /root-entrypoint.sh /app /opt /opt/venv && \
|
||||
# Permissions
|
||||
chmod 005 /entrypoint.sh /root-entrypoint.sh ${SYSTEM_SERVICES}/*.sh ${SYSTEM_SERVICES_SCRIPTS}/* ${ENTRYPOINT_CHECKS}/* /app /opt /opt/venv && \
|
||||
# Cleanups
|
||||
rm -f \
|
||||
"${NETALERTX_CONFIG}/app.conf" \
|
||||
"${NETALERTX_DB_FILE}" \
|
||||
"${NETALERTX_DB_FILE}-shm" \
|
||||
"${NETALERTX_DB_FILE}-wal" || true && \
|
||||
# Remove apt and sensitive files
|
||||
rm -rf /var/lib/apt /var/lib/dpkg /var/cache/apt /usr/bin/apt* /usr/bin/dpkg* \
|
||||
/etc/shadow /etc/gshadow /etc/sudoers /root /home/root && \
|
||||
# Dummy sudo
|
||||
printf '#!/bin/sh\n"$@"\n' > /usr/bin/sudo && chmod +x /usr/bin/sudo
|
||||
|
||||
USER 0
|
||||
ENTRYPOINT ["/root-entrypoint.sh"]
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD /services/healthcheck.sh
|
||||
|
||||
112
back/app.sql
112
back/app.sql
@@ -24,6 +24,10 @@ CREATE TABLE Devices (
|
||||
devFirstConnection DATETIME NOT NULL,
|
||||
devLastConnection DATETIME NOT NULL,
|
||||
devLastIP STRING (50) NOT NULL COLLATE NOCASE,
|
||||
devPrimaryIPv4 TEXT,
|
||||
devPrimaryIPv6 TEXT,
|
||||
devVlan TEXT,
|
||||
devForceStatus TEXT,
|
||||
devStaticIP BOOLEAN DEFAULT (0) NOT NULL CHECK (devStaticIP IN (0, 1)),
|
||||
devScan INTEGER DEFAULT (1) NOT NULL,
|
||||
devLogEvents BOOLEAN NOT NULL DEFAULT (1) CHECK (devLogEvents IN (0, 1)),
|
||||
@@ -37,13 +41,24 @@ CREATE TABLE Devices (
|
||||
devIsArchived BOOLEAN NOT NULL DEFAULT (0) CHECK (devIsArchived IN (0, 1)),
|
||||
devParentMAC TEXT,
|
||||
devParentPort INTEGER,
|
||||
devParentRelType TEXT,
|
||||
devIcon TEXT,
|
||||
devGUID TEXT,
|
||||
devSite TEXT,
|
||||
devSSID TEXT,
|
||||
devSyncHubNode TEXT,
|
||||
devSourcePlugin TEXT
|
||||
, "devCustomProps" TEXT);
|
||||
devSourcePlugin TEXT,
|
||||
devMacSource TEXT,
|
||||
devNameSource TEXT,
|
||||
devFQDNSource TEXT,
|
||||
devLastIPSource TEXT,
|
||||
devVendorSource TEXT,
|
||||
devSSIDSource TEXT,
|
||||
devParentMACSource TEXT,
|
||||
devParentPortSource TEXT,
|
||||
devParentRelTypeSource TEXT,
|
||||
devVlanSource TEXT,
|
||||
"devCustomProps" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "Settings" (
|
||||
"setKey" TEXT,
|
||||
"setName" TEXT,
|
||||
@@ -61,7 +76,7 @@ CREATE TABLE IF NOT EXISTS "Parameters" (
|
||||
);
|
||||
CREATE TABLE Plugins_Objects(
|
||||
"Index" INTEGER,
|
||||
Plugin TEXT NOT NULL,
|
||||
Plugin TEXT NOT NULL,
|
||||
Object_PrimaryID TEXT NOT NULL,
|
||||
Object_SecondaryID TEXT NOT NULL,
|
||||
DateTimeCreated TEXT NOT NULL,
|
||||
@@ -134,7 +149,7 @@ CREATE TABLE Plugins_Language_Strings(
|
||||
Extra TEXT NOT NULL,
|
||||
PRIMARY KEY("Index" AUTOINCREMENT)
|
||||
);
|
||||
CREATE TABLE CurrentScan (
|
||||
CREATE TABLE CurrentScan (
|
||||
cur_MAC STRING(50) NOT NULL COLLATE NOCASE,
|
||||
cur_IP STRING(50) NOT NULL COLLATE NOCASE,
|
||||
cur_Vendor STRING(250),
|
||||
@@ -145,6 +160,7 @@ CREATE TABLE CurrentScan (
|
||||
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),
|
||||
@@ -161,11 +177,11 @@ CREATE TABLE IF NOT EXISTS "AppEvents" (
|
||||
"ObjectPrimaryID" TEXT,
|
||||
"ObjectSecondaryID" TEXT,
|
||||
"ObjectForeignKey" TEXT,
|
||||
"ObjectIndex" TEXT,
|
||||
"ObjectIsNew" BOOLEAN,
|
||||
"ObjectIsArchived" BOOLEAN,
|
||||
"ObjectIndex" TEXT,
|
||||
"ObjectIsNew" BOOLEAN,
|
||||
"ObjectIsArchived" BOOLEAN,
|
||||
"ObjectStatusColumn" TEXT,
|
||||
"ObjectStatus" TEXT,
|
||||
"ObjectStatus" TEXT,
|
||||
"AppEventType" TEXT,
|
||||
"Helper1" TEXT,
|
||||
"Helper2" TEXT,
|
||||
@@ -203,21 +219,21 @@ CREATE INDEX IDX_dev_Favorite ON Devices (devFavorite);
|
||||
CREATE INDEX IDX_dev_LastIP ON Devices (devLastIP);
|
||||
CREATE INDEX IDX_dev_NewDevice ON Devices (devIsNew);
|
||||
CREATE INDEX IDX_dev_Archived ON Devices (devIsArchived);
|
||||
CREATE VIEW Events_Devices AS
|
||||
SELECT *
|
||||
FROM Events
|
||||
CREATE VIEW Events_Devices AS
|
||||
SELECT *
|
||||
FROM Events
|
||||
LEFT JOIN Devices ON eve_MAC = devMac
|
||||
/* Events_Devices(eve_MAC,eve_IP,eve_DateTime,eve_EventType,eve_AdditionalInfo,eve_PendingAlertEmail,eve_PairEventRowid,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) */;
|
||||
CREATE VIEW LatestEventsPerMAC AS
|
||||
WITH RankedEvents AS (
|
||||
SELECT
|
||||
SELECT
|
||||
e.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.eve_MAC ORDER BY e.eve_DateTime DESC) AS row_num
|
||||
FROM Events AS e
|
||||
)
|
||||
SELECT
|
||||
e.*,
|
||||
d.*,
|
||||
SELECT
|
||||
e.*,
|
||||
d.*,
|
||||
c.*
|
||||
FROM RankedEvents AS e
|
||||
LEFT JOIN Devices AS d ON e.eve_MAC = d.devMac
|
||||
@@ -256,11 +272,11 @@ CREATE VIEW Convert_Events_to_Sessions AS SELECT EVE1.eve_MAC,
|
||||
CREATE TRIGGER "trg_insert_devices"
|
||||
AFTER INSERT ON "Devices"
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
AND ObjectType = 'Devices'
|
||||
AND ObjectGUID = NEW.devGUID
|
||||
AND ObjectStatus = CASE WHEN NEW.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND ObjectStatus = CASE WHEN NEW.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND AppEventType = 'insert'
|
||||
)
|
||||
BEGIN
|
||||
@@ -281,18 +297,18 @@ CREATE TRIGGER "trg_insert_devices"
|
||||
"AppEventType"
|
||||
)
|
||||
VALUES (
|
||||
|
||||
|
||||
lower(
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
substr('AB89', 1 + (abs(random()) % 4) , 1) ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(6))
|
||||
)
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
NEW.devGUID, -- ObjectGUID
|
||||
NEW.devMac, -- ObjectPrimaryID
|
||||
NEW.devLastIP, -- ObjectSecondaryID
|
||||
@@ -308,11 +324,11 @@ CREATE TRIGGER "trg_insert_devices"
|
||||
CREATE TRIGGER "trg_update_devices"
|
||||
AFTER UPDATE ON "Devices"
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
AND ObjectType = 'Devices'
|
||||
AND ObjectGUID = NEW.devGUID
|
||||
AND ObjectStatus = CASE WHEN NEW.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND ObjectStatus = CASE WHEN NEW.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND AppEventType = 'update'
|
||||
)
|
||||
BEGIN
|
||||
@@ -333,18 +349,18 @@ CREATE TRIGGER "trg_update_devices"
|
||||
"AppEventType"
|
||||
)
|
||||
VALUES (
|
||||
|
||||
|
||||
lower(
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
substr('AB89', 1 + (abs(random()) % 4) , 1) ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(6))
|
||||
)
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
NEW.devGUID, -- ObjectGUID
|
||||
NEW.devMac, -- ObjectPrimaryID
|
||||
NEW.devLastIP, -- ObjectSecondaryID
|
||||
@@ -360,11 +376,11 @@ CREATE TRIGGER "trg_update_devices"
|
||||
CREATE TRIGGER "trg_delete_devices"
|
||||
AFTER DELETE ON "Devices"
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
SELECT 1 FROM AppEvents
|
||||
WHERE AppEventProcessed = 0
|
||||
AND ObjectType = 'Devices'
|
||||
AND ObjectGUID = OLD.devGUID
|
||||
AND ObjectStatus = CASE WHEN OLD.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND ObjectStatus = CASE WHEN OLD.devPresentLastScan = 1 THEN 'online' ELSE 'offline' END
|
||||
AND AppEventType = 'delete'
|
||||
)
|
||||
BEGIN
|
||||
@@ -385,18 +401,18 @@ CREATE TRIGGER "trg_delete_devices"
|
||||
"AppEventType"
|
||||
)
|
||||
VALUES (
|
||||
|
||||
|
||||
lower(
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || '4' ||
|
||||
substr(hex( randomblob(2)), 2) || '-' ||
|
||||
substr('AB89', 1 + (abs(random()) % 4) , 1) ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(6))
|
||||
)
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
,
|
||||
DATETIME('now'),
|
||||
FALSE,
|
||||
'Devices',
|
||||
OLD.devGUID, -- ObjectGUID
|
||||
OLD.devMac, -- ObjectPrimaryID
|
||||
OLD.devLastIP, -- ObjectSecondaryID
|
||||
|
||||
184
docs/API_DEVICE_FIELD_LOCK.md
Normal file
184
docs/API_DEVICE_FIELD_LOCK.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Device Field Lock/Unlock API
|
||||
|
||||
## Overview
|
||||
|
||||
The Device Field Lock/Unlock feature allows users to lock specific device fields to prevent plugin overwrites. This is part of the authoritative device field update system that ensures data integrity while maintaining flexibility for user customization.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Tracked Fields
|
||||
|
||||
Only certain device fields support locking. These are the fields that can be modified by both plugins and users:
|
||||
|
||||
- `devName` - Device name/hostname
|
||||
- `devVendor` - Device vendor/manufacturer
|
||||
- `devFQDN` - Fully qualified domain name
|
||||
- `devSSID` - Network SSID
|
||||
- `devParentMAC` - Parent device MAC address
|
||||
- `devParentPort` - Parent device port
|
||||
- `devParentRelType` - Parent device relationship type
|
||||
- `devVlan` - VLAN identifier
|
||||
|
||||
### Field Source Tracking
|
||||
|
||||
Every tracked field has an associated `*Source` field that indicates where the current value originated:
|
||||
|
||||
- `NEWDEV` - Created via the UI as a new device
|
||||
- `USER` - Manually edited by a user
|
||||
- `LOCKED` - Field is locked; prevents any plugin overwrites
|
||||
- Plugin name (e.g., `UNIFIAPI`, `PIHOLE`) - Last updated by this plugin
|
||||
|
||||
### Locking Mechanism
|
||||
|
||||
When a field is **locked**, its source is set to `LOCKED`. This prevents plugin overwrites based on the authorization logic:
|
||||
|
||||
1. Plugin wants to update field
|
||||
2. Authoritative handler checks field's `*Source` value
|
||||
3. If `*Source` == `LOCKED`, plugin update is rejected
|
||||
4. User can still manually unlock the field
|
||||
|
||||
When a field is **unlocked**, its source is set to `NEWDEV`, allowing plugins to resume updates.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Lock or Unlock a Field
|
||||
|
||||
```
|
||||
POST /device/{mac}/field/lock
|
||||
Authorization: Bearer {API_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fieldName": "devName",
|
||||
"lock": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
- `mac` (path, required): Device MAC address (e.g., `AA:BB:CC:DD:EE:FF`)
|
||||
- `fieldName` (body, required): Name of the field to lock/unlock. Must be one of the tracked fields listed above.
|
||||
- `lock` (body, required): Boolean. `true` to lock, `false` to unlock.
|
||||
|
||||
#### Responses
|
||||
|
||||
**Success (200)**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Field devName locked",
|
||||
"fieldName": "devName",
|
||||
"locked": true
|
||||
}
|
||||
```
|
||||
|
||||
**Bad Request (400)**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "fieldName is required"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Field 'devInvalidField' cannot be locked"
|
||||
}
|
||||
```
|
||||
|
||||
**Unauthorized (403)**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
**Not Found (404)**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Device not found"
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Lock a Device Name
|
||||
Prevent the device name from being overwritten by plugins:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-netalertx.local/api/device/AA:BB:CC:DD:EE:FF/field/lock \
|
||||
-H "Authorization: Bearer your-api-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"fieldName": "devName",
|
||||
"lock": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Unlock a Field
|
||||
Allow plugins to resume updating a field:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-netalertx.local/api/device/AA:BB:CC:DD:EE:FF/field/lock \
|
||||
-H "Authorization: Bearer your-api-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"fieldName": "devName",
|
||||
"lock": false
|
||||
}'
|
||||
```
|
||||
|
||||
## UI Integration
|
||||
|
||||
The Device Edit form displays lock/unlock buttons for all tracked fields:
|
||||
|
||||
1. **Lock Button** (🔒): Click to prevent plugin overwrites
|
||||
2. **Unlock Button** (🔓): Click to allow plugin overwrites again
|
||||
3. **Source Indicator**: Shows current field source (USER, LOCKED, NEWDEV, or plugin name)
|
||||
|
||||
## UI Workflow
|
||||
|
||||
### Locking a Field via UI
|
||||
|
||||
1. Navigate to Device Details
|
||||
2. Find the field you want to protect
|
||||
3. Click the lock button (🔒) next to the field
|
||||
4. Button changes to unlock (🔓) and source indicator turns red (LOCKED)
|
||||
5. Field is now protected from plugin overwrites
|
||||
|
||||
### Unlocking a Field via UI
|
||||
|
||||
1. Find the locked field (button shows 🔓)
|
||||
2. Click the unlock button
|
||||
3. Button changes back to lock (🔒) and source resets to NEWDEV
|
||||
4. Plugins can now update this field again
|
||||
|
||||
## Authorization
|
||||
|
||||
All lock/unlock operations require:
|
||||
- Valid API token in `Authorization: Bearer {token}` header
|
||||
- User must be authenticated to the NetAlertX instance
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Backend Logic
|
||||
The lock/unlock feature is implemented in:
|
||||
- **API Endpoint**: `/server/api_server/api_server_start.py` - `api_device_field_lock()`
|
||||
- **Data Model**: `/server/models/device_instance.py` - Authorization checks in `setDeviceData()`
|
||||
- **Database**: Devices table with `*Source` columns tracking field origins
|
||||
|
||||
### Authorization Handler
|
||||
|
||||
The authoritative field update logic prevents plugin overwrites:
|
||||
|
||||
1. Plugin provides new value for field via plugin config `SET_ALWAYS`/`SET_EMPTY`
|
||||
2. Authoritative handler (in DeviceInstance) checks `{field}Source` value
|
||||
3. If source is `LOCKED` or `USER`, plugin update is rejected
|
||||
4. If source is `NEWDEV` or plugin name, plugin update is accepted
|
||||
|
||||
## See Also
|
||||
- [API Device Endpoints Documentation](./API_DEVICE.md)
|
||||
- [Authoritative Field Updates System](./PLUGINS_DEV.md#authoritative-fields)
|
||||
- [Plugin Configuration Reference](./PLUGINS_DEV_CONFIG.md)
|
||||
@@ -4,7 +4,7 @@ Please follow tips 1 - 4 to get a more detailed error.
|
||||
|
||||
## 1. More Logging
|
||||
|
||||
When debugging an issue always set the highest log level:
|
||||
When debugging an issue always set the highest log level in **Settings -> Core**:
|
||||
|
||||
`LOG_LEVEL='trace'`
|
||||
|
||||
|
||||
@@ -47,4 +47,25 @@ The **MAC** field and the **Last IP** field will then become editable.
|
||||
|
||||
To speed up device population you can also copy data from an existing device. This can be done from the **Tools** tab on the Device details.
|
||||
|
||||
## Field Locking (Preventing Plugin Overwrites)
|
||||
|
||||
NetAlertX allows you to "lock" specific device fields to prevent plugins from automatically overwriting your custom values. This is useful when you've manually corrected information that might be discovered differently by discovery plugins.
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Open a device for editing
|
||||
2. Click the **lock button** (🔒) next to any tracked field
|
||||
3. The field is now protected—plugins cannot change it until you unlock it
|
||||
|
||||
### Tracked Fields
|
||||
|
||||
The following 10 fields support locking:
|
||||
- devMac, devName, devLastIP, devVendor, devFQDN, devSSID, devParentMAC, devParentPort, devParentRelType, devVlan
|
||||
|
||||
### See Also
|
||||
|
||||
- **For Users:** [Quick Reference - Device Field Lock/Unlock](QUICK_REFERENCE_FIELD_LOCK.md) - How to use field locking
|
||||
- **For Developers:** [API Device Field Lock Documentation](API_DEVICE_FIELD_LOCK.md) - Technical API reference
|
||||
- **For Plugin Developers:** [Plugin Field Configuration (SET_ALWAYS/SET_EMPTY)](PLUGINS_DEV_CONFIG.md) - Configure which fields plugins can update
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ services:
|
||||
- NET_ADMIN # Required for ARP scanning
|
||||
- NET_RAW # Required for raw socket operations
|
||||
- NET_BIND_SERVICE # Required to bind to privileged ports (nbtscan)
|
||||
- CHOWN # Required for root-entrypoint to chown /data + /tmp before dropping privileges
|
||||
- SETUID # Required for root-entrypoint to switch to non-root user
|
||||
- SETGID # Required for root-entrypoint to switch to non-root group
|
||||
|
||||
volumes:
|
||||
- type: volume # Persistent Docker-managed named volume for config + database
|
||||
|
||||
@@ -177,6 +177,55 @@ After persistence:
|
||||
|
||||
---
|
||||
|
||||
## Field Update Authorization (SET_ALWAYS / SET_EMPTY)
|
||||
|
||||
For tracked fields (devMac, devName, devLastIP, devVendor, devFQDN, devSSID, devParentMAC, devParentPort, devParentRelType, devVlan), plugins can configure how they interact with the authoritative field update system.
|
||||
|
||||
### SET_ALWAYS
|
||||
|
||||
**Mandatory when field is tracked.**
|
||||
|
||||
Controls whether a plugin field is enabled:
|
||||
|
||||
- `["devName", "devLastIP"]` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
|
||||
|
||||
**Authorization logic:** Even with a field listed in `SET_ALWAYS`, the plugin respects source-based permissions:
|
||||
|
||||
- Cannot overwrite `USER` source (user manually edited)
|
||||
- Cannot overwrite `LOCKED` source (user locked field)
|
||||
- Can overwrite `NEWDEV` or plugin-owned sources (if plugin has SET_ALWAYS enabled)
|
||||
- Will update plugin-owned sources if value the same
|
||||
|
||||
**Example in config.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"SET_ALWAYS": ["devName", "devLastIP"]
|
||||
}
|
||||
```
|
||||
|
||||
### SET_EMPTY
|
||||
|
||||
**Optional field override.**
|
||||
|
||||
Restricts when a plugin can update a field:
|
||||
|
||||
- `"SET_EMPTY": ["devName", "devLastIP"]` - Overwrite these fields only if current value is empty OR source is `NEWDEV`
|
||||
|
||||
**Use case:** Some plugins discover optional enrichment data (like vendor/hostname) that shouldn't override user-set or existing values. Use `SET_EMPTY` to be less aggressive.
|
||||
|
||||
|
||||
### Authorization Decision Flow
|
||||
|
||||
1. **Source check:** Is field LOCKED or USER? → REJECT (protected)
|
||||
2. **Field in SET_ALWAYS check:** Is SET_ALWAYS enabled for this plugin+field? → YES: ALLOW (can overwrite empty values, NEWDEV, plugin sources, etc.) | NO: Continue to step 3
|
||||
3. **Field in SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT
|
||||
4. **Default behavior:** Allow overwrite if field empty or NEWDEV source
|
||||
|
||||
**Note:** Check each plugin's `config.json` manifest for its specific SET_ALWAYS/SET_EMPTY configuration.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The lifecycle of a plugin configuration is:
|
||||
|
||||
147
docs/QUICK_REFERENCE_FIELD_LOCK.md
Normal file
147
docs/QUICK_REFERENCE_FIELD_LOCK.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Quick Reference Guide - Device Field Lock/Unlock System
|
||||
|
||||
## One-Minute Overview
|
||||
|
||||
The device field lock/unlock system allows you to protect specific device fields from being automatically overwritten by scanning plugins. When you lock a field, NetAlertX remembers your choice and prevents plugins from changing that value until you unlock it.
|
||||
|
||||
**Use case:** You've manually corrected a device name or port number and want to keep it that way, even when plugins discover different values.
|
||||
|
||||
## Tracked Fields
|
||||
|
||||
These are the ONLY fields that can be locked:
|
||||
|
||||
- devName - Device hostname/alias
|
||||
- devVendor - Device manufacturer
|
||||
- devFQDN - Fully qualified domain name
|
||||
- devSSID - WiFi network name
|
||||
- devParentMAC - Parent/gateway MAC
|
||||
- devParentPort - Parent device port
|
||||
- devParentRelType - Relationship type (e.g., "gateway")
|
||||
- devVlan - VLAN identifier
|
||||
|
||||
## Source Values Explained
|
||||
|
||||
Each locked field has a "source" indicator that shows you why the value is protected:
|
||||
|
||||
| Indicator | Meaning | Can It Change? |
|
||||
|-----------|---------|---|
|
||||
| 🔒 **LOCKED** | You locked this field | No, until you unlock it |
|
||||
| ✏️ **USER** | You edited this field | No, plugins can't overwrite |
|
||||
| 📡 **NEWDEV** | Default/unset value | Yes, plugins can update |
|
||||
| 📡 **Plugin name** | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update if field in SET_ALWAYS |
|
||||
|
||||
## How to Use
|
||||
|
||||
### Lock a Field (Prevent Plugin Changes)
|
||||
|
||||
1. Navigate to **Device Details** for the device
|
||||
2. Find the field you want to protect (e.g., device name)
|
||||
3. Click the **lock button** (🔒) next to the field
|
||||
4. The button changes to **unlock** (🔓)
|
||||
5. That field is now protected
|
||||
|
||||
### Unlock a Field (Allow Plugin Updates)
|
||||
|
||||
1. Go to **Device Details**
|
||||
2. Find the locked field (shows 🔓)
|
||||
3. Click the **unlock button** (🔓)
|
||||
4. The button changes back to **lock** (🔒)
|
||||
5. Plugins can now update that field again
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: You've Named Your Device and Want to Keep the Name
|
||||
|
||||
1. You manually edit device name to "Living Room Smart TV"
|
||||
2. A scanning plugin later discovers it as "Unknown Device" or "DEVICE-ABC123"
|
||||
3. **Solution:** Lock the device name field
|
||||
4. Your custom name is preserved even after future scans
|
||||
|
||||
### Scenario 2: You Lock a Field, But It Still Changes
|
||||
|
||||
**This means the field source is USER or LOCKED (protected).** Check:
|
||||
- Is it showing the lock icon? (If yes, it's protected)
|
||||
- Wait a moment—sometimes changes take a few seconds to display
|
||||
- Try refreshing the page
|
||||
|
||||
### Scenario 3: You Want to Let Plugins Update Again
|
||||
|
||||
1. Find the device with locked fields
|
||||
2. Click the unlock button (🔓) next to each field
|
||||
3. Refresh the page
|
||||
4. Next time a plugin runs, it can update that field
|
||||
|
||||
## What Happens When You Lock a Field
|
||||
|
||||
- ✅ Your custom value is kept
|
||||
- ✅ Future plugin scans won't overwrite it
|
||||
- ✅ You can still manually edit it anytime after unlocking
|
||||
- ✅ Lock persists across plugin runs
|
||||
- ✅ Other users can see it's locked
|
||||
|
||||
## What Happens When You Unlock a Field
|
||||
|
||||
- ✅ Plugins can update it again on next scan
|
||||
- ✅ If a plugin has a new value, it will be applied
|
||||
- ✅ You can lock it again anytime
|
||||
- ✅ Your manual edits are still saved in the database
|
||||
|
||||
## Error Messages & Solutions
|
||||
|
||||
| Message | What It Means | What to Do |
|
||||
|---------|--------------|-----------|
|
||||
| "Field cannot be locked" | You tried to lock a field that doesn't support locking | Only lock the fields listed above |
|
||||
| "Device not found" | The device MAC address doesn't exist | Verify the device hasn't been deleted |
|
||||
| Lock button doesn't work | Network or permission issue | Refresh the page and try again |
|
||||
| Unexpected field changed | Field might have been unlocked | Check if field shows unlock icon (🔓) |
|
||||
|
||||
## Quick Tips
|
||||
|
||||
- **Lock names you manually corrected** to keep them stable
|
||||
- **Leave discovery fields (vendor, FQDN) unlocked** for automatic updates
|
||||
- **Use locks sparingly**—they prevent automatic data enrichment
|
||||
- **Check the source indicator** (colored badge) to understand field origin
|
||||
- **Lock buttons only appear for devices that are saved** (not for new devices being created)
|
||||
|
||||
## When to Lock vs. When NOT to Lock
|
||||
|
||||
### ✅ **Good reasons to lock:**
|
||||
- You've customized the device name and it's correct
|
||||
- You've set a static IP and it shouldn't change
|
||||
- You've configured VLAN information
|
||||
- You know the parent device and don't want it auto-corrected
|
||||
|
||||
### ❌ **Bad reasons to lock:**
|
||||
- The value seems wrong—edit it first, then lock
|
||||
- You want to prevent data from another source—use field lock, not to hide problems
|
||||
- You're trying to force a value the system disagrees with
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Lock button not appearing:**
|
||||
- Confirm the field is one of the tracked fields (see list above)
|
||||
- Confirm the device is already saved (new devices don't show lock buttons)
|
||||
- Refresh the page
|
||||
|
||||
**Lock button is there but click doesn't work:**
|
||||
- Check your internet connection
|
||||
- Check you have permission to edit devices
|
||||
- Look at browser console (F12 > Console tab) for error messages
|
||||
- Try again in a few seconds
|
||||
|
||||
**Field still changes after locking:**
|
||||
- Double-check the lock icon shows
|
||||
- Reload the page—the change might be a display issue
|
||||
- Check if you accidentally unlocked it
|
||||
- Open an issue if it persists
|
||||
|
||||
## For More Information
|
||||
|
||||
- **Technical details:** See [API_DEVICE_FIELD_LOCK.md](API_DEVICE_FIELD_LOCK.md)
|
||||
- **Plugin configuration:** See [PLUGINS_DEV_CONFIG.md](PLUGINS_DEV_CONFIG.md)
|
||||
- **Admin guide:** See [DEVICE_MANAGEMENT.md](DEVICE_MANAGEMENT.md)
|
||||
|
||||
---
|
||||
|
||||
**Quick Start:** Find a device field you want to protect → Click the lock icon → That's it! The field won't change until you unlock it.
|
||||
|
||||
@@ -99,13 +99,13 @@ a[target="_blank"] {
|
||||
/* -----------------------------------------------------------------------------
|
||||
Text Classes
|
||||
----------------------------------------------------------------------------- */
|
||||
.logs
|
||||
.logs, .log-area textarea
|
||||
{
|
||||
color:white;
|
||||
background-color: black;
|
||||
color:white !important;
|
||||
background-color: black !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: .85em;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
.logs-row textarea
|
||||
{
|
||||
@@ -1216,12 +1216,14 @@ height: 50px;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
/* Apply styles to the readonly input */
|
||||
background-color: #646566 !important;
|
||||
color: #e6e6e6;
|
||||
input[readonly],
|
||||
textarea[readonly],
|
||||
.form-control[readonly] {
|
||||
background-color: #f4f6f8;
|
||||
border-color: #d2d6de;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.interactable-option:hover::before {
|
||||
opacity: 1;
|
||||
@@ -1491,12 +1493,12 @@ input[readonly] {
|
||||
}
|
||||
.select2-container--default .select2-selection--multiple
|
||||
{
|
||||
background-color:#606060 !important;
|
||||
background-color:#ffffff !important;
|
||||
}
|
||||
|
||||
.select2-container .select2-dropdown
|
||||
{
|
||||
background-color:#606060 !important;
|
||||
background-color:#ffffff !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple,
|
||||
@@ -2439,3 +2441,35 @@ table.dataTable tbody > tr.selected
|
||||
{
|
||||
margin-top: 10px;
|
||||
}
|
||||
/* -----------------------------------------------------------------------------
|
||||
Field Lock/Unlock Buttons & Source Indicators
|
||||
----------------------------------------------------------------------------- */
|
||||
.field-lock-btn {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.field-lock-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.field-lock-btn:active {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
.input-group-addon.text-warning {
|
||||
color: #f39c12;
|
||||
background-color: rgba(243, 156, 18, 0.1);
|
||||
}
|
||||
|
||||
.input-group-addon.text-danger {
|
||||
color: #dd4b39;
|
||||
background-color: rgba(221, 75, 57, 0.1);
|
||||
}
|
||||
|
||||
.input-group-addon.text-muted {
|
||||
color: #8c8c8c;
|
||||
background-color: rgba(140, 140, 140, 0.05);
|
||||
}
|
||||
@@ -509,11 +509,20 @@ div.dataTables_wrapper div.dataTables_length select {
|
||||
border: 1px solid #3d444b;
|
||||
}
|
||||
.form-control[disabled],
|
||||
.form-control[readonly],
|
||||
fieldset[disabled] .form-control {
|
||||
background-color: #353c42;
|
||||
opacity: 1;
|
||||
}
|
||||
input[readonly],
|
||||
textarea[readonly],
|
||||
.form-control[readonly] {
|
||||
background-color: #545659 !important;
|
||||
border-color: #3d444b;
|
||||
color: #979a9d;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navbar-custom-menu > .navbar-nav > li > .dropdown-menu {
|
||||
background-color: #4c5761;
|
||||
color: #bec5cb;
|
||||
@@ -682,7 +691,7 @@ table.dataTable tbody tr.selected, table.dataTable tbody tr .selected
|
||||
.db_tools_table_cell_b:nth-child(1) {background: #272c30}
|
||||
.db_tools_table_cell_b:nth-child(2) {background: #272c30}
|
||||
|
||||
.db_info_table {
|
||||
.db_info_table {
|
||||
display: table;
|
||||
border-spacing: 0em;
|
||||
font-weight: 400;
|
||||
@@ -746,7 +755,7 @@ table.dataTable tbody tr.selected, table.dataTable tbody tr .selected
|
||||
.small-box:hover .icon {
|
||||
font-size: 3em;
|
||||
}
|
||||
.small-box .icon {
|
||||
.small-box .icon {
|
||||
top: 0.01em;
|
||||
font-size: 3.25em;
|
||||
}
|
||||
@@ -774,6 +783,11 @@ table.dataTable tbody tr.selected, table.dataTable tbody tr .selected
|
||||
border-color: #3d444b !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple {
|
||||
background-color: #353c42 !important;
|
||||
color: #bec5cb;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered .custom-chip
|
||||
{
|
||||
color: #bec5cb;
|
||||
@@ -791,10 +805,19 @@ table.dataTable tbody tr.selected, table.dataTable tbody tr .selected
|
||||
|
||||
.thresholdFormControl
|
||||
{
|
||||
color:#000;
|
||||
color:#000;
|
||||
}
|
||||
|
||||
.btn:hover
|
||||
{
|
||||
color: var(--color-gray);
|
||||
}
|
||||
|
||||
.logs, .log-area textarea
|
||||
{
|
||||
color:white !important;
|
||||
background-color: black !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: .85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
--color-gray: #8c8c8c;
|
||||
--color-white: #fff;
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
--datatable-bgcolor: rgba(64, 76, 88, 0.8);
|
||||
}
|
||||
@@ -427,7 +427,7 @@
|
||||
background: transparent;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
|
||||
/* Used in debug log page */
|
||||
.log-red {
|
||||
color: #ff4038;
|
||||
@@ -512,11 +512,19 @@
|
||||
border: 1px solid #3d444b;
|
||||
}
|
||||
.form-control[disabled],
|
||||
.form-control[readonly],
|
||||
fieldset[disabled] .form-control {
|
||||
background-color: #353c42;
|
||||
opacity: 1;
|
||||
}
|
||||
input[readonly],
|
||||
textarea[readonly],
|
||||
.form-control[readonly] {
|
||||
background-color: #545659 !important;
|
||||
border-color: #3d444b;
|
||||
color: #979a9d;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.navbar-custom-menu > .navbar-nav > li > .dropdown-menu {
|
||||
background-color: #4c5761;
|
||||
color: #bec5cb;
|
||||
@@ -685,7 +693,7 @@
|
||||
.db_tools_table_cell_b:nth-child(1) {background: #272c30}
|
||||
.db_tools_table_cell_b:nth-child(2) {background: #272c30}
|
||||
|
||||
.db_info_table {
|
||||
.db_info_table {
|
||||
display: table;
|
||||
border-spacing: 0em;
|
||||
font-weight: 400;
|
||||
@@ -749,7 +757,7 @@
|
||||
.small-box:hover .icon {
|
||||
font-size: 3em;
|
||||
}
|
||||
.small-box .icon {
|
||||
.small-box .icon {
|
||||
top: 0.01em;
|
||||
font-size: 3.25em;
|
||||
}
|
||||
@@ -776,6 +784,11 @@
|
||||
border-color: #3d444b !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple {
|
||||
background-color: #353c42 !important;
|
||||
color: #bec5cb;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered .custom-chip
|
||||
{
|
||||
color: #bec5cb;
|
||||
@@ -795,10 +808,20 @@
|
||||
|
||||
.thresholdFormControl
|
||||
{
|
||||
color:#000;
|
||||
color:#000;
|
||||
}
|
||||
|
||||
.btn:hover
|
||||
{
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
|
||||
.logs, .log-area textarea
|
||||
{
|
||||
color:white !important;
|
||||
background-color: black !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: .85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -36,6 +36,9 @@ require_once $_SERVER["DOCUMENT_ROOT"] . "/php/templates/security.php"; ?>
|
||||
|
||||
<script defer>
|
||||
|
||||
// Global variable to store device data for access by toggleFieldLock and other functions
|
||||
let deviceData = {};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Get plugin and settings data from API endpoints
|
||||
function getDeviceData() {
|
||||
@@ -57,7 +60,9 @@ function getDeviceData() {
|
||||
"Authorization": `Bearer ${apiToken}`
|
||||
},
|
||||
dataType: "json",
|
||||
success: function(deviceData) {
|
||||
success: function(data) {
|
||||
// Assign to global variable for access by toggleFieldLock and other functions
|
||||
deviceData = data;
|
||||
|
||||
// some race condition, need to implement delay
|
||||
setTimeout(() => {
|
||||
@@ -104,7 +109,21 @@ function getDeviceData() {
|
||||
// columns to hide
|
||||
hiddenFields = ["NEWDEV_devScan", "NEWDEV_devPresentLastScan"]
|
||||
// columns to disable/readonly - conditional depending if a new dummy device is created
|
||||
disabledFields = mac == "new" ? ["NEWDEV_devLastNotification", "NEWDEV_devFirstConnection", "NEWDEV_devLastConnection"] : ["NEWDEV_devLastNotification", "NEWDEV_devFirstConnection", "NEWDEV_devLastConnection", "NEWDEV_devMac", "NEWDEV_devLastIP", "NEWDEV_devSyncHubNode", "NEWDEV_devFQDN"];
|
||||
disabledFields = mac == "new" ? ["NEWDEV_devLastNotification", "NEWDEV_devFirstConnection", "NEWDEV_devLastConnection"] : ["NEWDEV_devLastNotification", "NEWDEV_devFirstConnection", "NEWDEV_devLastConnection", "NEWDEV_devMac", "NEWDEV_devLastIP", "NEWDEV_devPrimaryIPv6", "NEWDEV_devPrimaryIPv4", "NEWDEV_devSyncHubNode", "NEWDEV_devFQDN"];
|
||||
|
||||
// Fields that are tracked by authoritative handler and can be locked/unlocked
|
||||
const trackedFields = {
|
||||
"devMac": true,
|
||||
"devName": true,
|
||||
"devLastIP": true,
|
||||
"devVendor": true,
|
||||
"devFQDN": true,
|
||||
"devSSID": true,
|
||||
"devParentMAC": true,
|
||||
"devParentPort": true,
|
||||
"devParentRelType": true,
|
||||
"devVlan": true
|
||||
};
|
||||
|
||||
// Grouping of fields into categories with associated documentation links
|
||||
const fieldGroups = {
|
||||
@@ -137,7 +156,7 @@ function getDeviceData() {
|
||||
},
|
||||
// Group for other fields like static IP, archived status, etc.
|
||||
DevDetail_DisplayFields_Title: {
|
||||
data: ["devStaticIP", "devIsNew", "devFavorite", "devIsArchived"],
|
||||
data: ["devStaticIP", "devIsNew", "devFavorite", "devIsArchived", "devForceStatus"],
|
||||
docs: "https://docs.netalertx.com/DEVICE_DISPLAY_SETTINGS",
|
||||
iconClass: "fa fa-list-check",
|
||||
inputGroupClasses: "field-group display-group col-lg-4 col-sm-6 col-xs-12",
|
||||
@@ -146,7 +165,7 @@ function getDeviceData() {
|
||||
},
|
||||
// Group for session information
|
||||
DevDetail_SessionInfo_Title: {
|
||||
data: ["devStatus", "devLastConnection", "devFirstConnection", "devFQDN"],
|
||||
data: ["devPrimaryIPv4", "devPrimaryIPv6", "devStatus", "devLastConnection", "devFirstConnection", "devFQDN"],
|
||||
docs: "https://docs.netalertx.com/SESSION_INFO",
|
||||
iconClass: "fa fa-calendar",
|
||||
inputGroupClasses: "field-group session-group col-lg-4 col-sm-6 col-xs-12",
|
||||
@@ -252,6 +271,50 @@ function getDeviceData() {
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// timestamps
|
||||
if (setting.setKey == "NEWDEV_devFirstConnection" || setting.setKey == "NEWDEV_devLastConnection") {
|
||||
fieldData = localizeTimestamp(fieldData)
|
||||
}
|
||||
|
||||
// Add lock/unlock icon button for tracked fields (not for new devices)
|
||||
const fieldName = setting.setKey.replace('NEWDEV_', '');
|
||||
if (trackedFields[fieldName] && !["devFQDN", "devMac", "devLastIP"].includes(fieldName) && mac != "new") {
|
||||
const sourceField = fieldName + "Source";
|
||||
|
||||
const currentSource = deviceData[sourceField] || "N/A";
|
||||
const isLocked = currentSource === "LOCKED";
|
||||
const lockIcon = isLocked ? "fa-lock" : "fa-lock-open";
|
||||
const lockTitle = isLocked ? getString("FieldLock_Unlock_Tooltip") : getString("FieldLock_Lock_Tooltip");
|
||||
inlineControl += `<span class="input-group-addon pointer field-lock-btn"
|
||||
onclick="toggleFieldLock('${mac}', '${fieldName}')"
|
||||
title="${lockTitle}"
|
||||
data-field="${fieldName}"
|
||||
data-locked="${isLocked ? 1 : 0}">
|
||||
<i class="fa-solid ${lockIcon}"></i>
|
||||
</span>`;
|
||||
if (isLocked) {
|
||||
if (!disabledFields.includes(setting.setKey)) {
|
||||
disabledFields.push(setting.setKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add source indicator for tracked fields
|
||||
const fieldName2 = setting.setKey.replace('NEWDEV_', '');
|
||||
if (trackedFields[fieldName2] && mac != "new") {
|
||||
const sourceField = fieldName2 + "Source";
|
||||
// only show if data available
|
||||
if (deviceData[sourceField] != "")
|
||||
{
|
||||
const currentSource = deviceData[sourceField] || "N/A";
|
||||
const sourceTitle = getString("FieldLock_Source_Label") + currentSource;
|
||||
const sourceColor = currentSource === "USER" ? "text-warning" : (currentSource === "LOCKED" ? "text-danger" : "text-muted");
|
||||
inlineControl += `<span class="input-group-addon pointer ${sourceColor}" title="${sourceTitle}">
|
||||
${currentSource}
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// handle devChildrenDynamic or NEWDEV_devChildrenNicsDynamic - selected values and options are the same
|
||||
if (
|
||||
Array.isArray(fieldData) &&
|
||||
@@ -358,7 +421,7 @@ function setDeviceData(direction = '', refreshCallback = '') {
|
||||
|
||||
mac = $('#NEWDEV_devMac').val();
|
||||
|
||||
// Build payload for new endpoint
|
||||
// Build payload
|
||||
const payload = {
|
||||
devName: $('#NEWDEV_devName').val().replace(/'/g, "’"),
|
||||
devOwner: $('#NEWDEV_devOwner').val().replace(/'/g, "’"),
|
||||
@@ -384,6 +447,7 @@ function setDeviceData(direction = '', refreshCallback = '') {
|
||||
devAlertEvents: ($('#NEWDEV_devAlertEvents')[0].checked * 1),
|
||||
devAlertDown: ($('#NEWDEV_devAlertDown')[0].checked * 1),
|
||||
devSkipRepeated: $('#NEWDEV_devSkipRepeated').val().split(' ')[0],
|
||||
devForceStatus: $('#NEWDEV_devForceStatus').val().replace(/'/g, ""),
|
||||
|
||||
devReqNicsOnline: ($('#NEWDEV_devReqNicsOnline')[0].checked * 1),
|
||||
devIsNew: ($('#NEWDEV_devIsNew')[0].checked * 1),
|
||||
@@ -412,9 +476,9 @@ function setDeviceData(direction = '', refreshCallback = '') {
|
||||
success: function(resp) {
|
||||
|
||||
if (resp && resp.success) {
|
||||
showMessage("Device saved successfully");
|
||||
showMessage(getString("Device_Saved_Success"));
|
||||
} else {
|
||||
showMessage("Device update returned an unexpected response");
|
||||
showMessage(getString("Device_Saved_Unexpected"));
|
||||
}
|
||||
|
||||
// Remove navigation prompt
|
||||
@@ -433,9 +497,9 @@ function setDeviceData(direction = '', refreshCallback = '') {
|
||||
},
|
||||
error: function(xhr) {
|
||||
if (xhr.status === 403) {
|
||||
showMessage("Unauthorized - invalid API token");
|
||||
showMessage(getString("Device_Save_Unauthorized"));
|
||||
} else {
|
||||
showMessage("Failed to save device (" + xhr.status + ")");
|
||||
showMessage(getString("Device_Save_Failed") + " (" + xhr.status + ")");
|
||||
}
|
||||
hideSpinner();
|
||||
}
|
||||
@@ -500,5 +564,86 @@ if (!$('#panDetails:visible').length) {
|
||||
getDeviceData();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Lock/Unlock field to prevent plugin overwrites
|
||||
function toggleFieldLock(mac, fieldName) {
|
||||
if (!mac || !fieldName) {
|
||||
console.error("Invalid parameters for toggleFieldLock");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const apiBaseUrl = getApiBase();
|
||||
|
||||
// Get current source value
|
||||
const sourceField = fieldName + "Source";
|
||||
const currentSource = deviceData[sourceField] || "N/A";
|
||||
const shouldLock = currentSource !== "LOCKED";
|
||||
|
||||
const payload = {
|
||||
fieldName: fieldName,
|
||||
lock: shouldLock ? 1 : 0
|
||||
};
|
||||
|
||||
const url = `${apiBaseUrl}/device/${mac}/field/lock`;
|
||||
|
||||
// Show visual feedback
|
||||
const lockBtn = $(`.field-lock-btn[data-field="${fieldName}"]`);
|
||||
lockBtn.css("opacity", 0.6);
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiToken}`
|
||||
},
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(payload),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Update the button state
|
||||
const newLocked = shouldLock ? 1 : 0;
|
||||
lockBtn.attr("data-locked", newLocked);
|
||||
|
||||
const lockIcon = shouldLock ? "fa-lock" : "fa-lock-open";
|
||||
const lockTitle = shouldLock ? getString("FieldLock_Unlock_Tooltip") : getString("FieldLock_Lock_Tooltip");
|
||||
lockBtn.find("i").attr("class", `fa-solid ${lockIcon}`);
|
||||
lockBtn.attr("title", lockTitle);
|
||||
|
||||
// Update local source state
|
||||
deviceData[sourceField] = shouldLock ? "LOCKED" : "";
|
||||
|
||||
const fieldKey = `NEWDEV_${fieldName}`;
|
||||
const fieldInput = $(`#${fieldKey}`);
|
||||
fieldInput.prop("readonly", shouldLock);
|
||||
|
||||
// Update source indicator
|
||||
const sourceIndicator = lockBtn.next();
|
||||
if (sourceIndicator.hasClass("input-group-addon")) {
|
||||
if (shouldLock) {
|
||||
const sourceValue = "LOCKED";
|
||||
const sourceClass = "input-group-addon pointer text-danger";
|
||||
sourceIndicator.text(sourceValue);
|
||||
sourceIndicator.attr("class", sourceClass);
|
||||
sourceIndicator.attr("title", getString("FieldLock_Source_Label") + sourceValue);
|
||||
} else {
|
||||
sourceIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
showMessage(shouldLock ? getString("FieldLock_Locked") : getString("FieldLock_Unlocked"), 3000, "modal_green");
|
||||
} else {
|
||||
showMessage(response.error || getString("FieldLock_Error"), 5000, "modal_red");
|
||||
}
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.error("Lock/Unlock error:", jqXHR, textStatus, errorThrown);
|
||||
showMessage(getString("FieldLock_Error"), 5000, "modal_red");
|
||||
},
|
||||
complete: function() {
|
||||
lockBtn.css("opacity", 1.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -46,7 +46,7 @@ function renderList(
|
||||
data: JSON.stringify({ rawSql: base64Sql }),
|
||||
contentType: "application/json",
|
||||
success: function(data) {
|
||||
console.log("SQL query response:", data);
|
||||
// console.log("SQL query response:", data);
|
||||
|
||||
// Parse the returned SQL data
|
||||
let sqlOption = [];
|
||||
@@ -62,7 +62,7 @@ function renderList(
|
||||
// Concatenate options from SQL query with the supplied options
|
||||
options = options.concat(sqlOption);
|
||||
|
||||
console.log("Combined options:", options);
|
||||
// console.log("Combined options:", options);
|
||||
|
||||
// Process the combined options
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -16,8 +16,12 @@ function askDeleteDevice() {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
function askDelDevDTInline(mac) {
|
||||
// Check MAC
|
||||
mac = getMac()
|
||||
|
||||
// only try getting mac from URL if not supplied - used in inline buttons on in the my devices listing pages
|
||||
if(isEmpty(mac))
|
||||
{
|
||||
mac = getMac()
|
||||
}
|
||||
|
||||
showModalWarning(
|
||||
getString("DevDetail_button_Delete"),
|
||||
@@ -54,13 +58,17 @@ function deleteDevice() {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
function deleteDeviceByMac(mac) {
|
||||
// Check MAC
|
||||
mac = getMac()
|
||||
// only try getting mac from URL if not supplied - used in inline buttons on in teh my devices listing pages
|
||||
if(isEmpty(mac))
|
||||
{
|
||||
mac = getMac()
|
||||
}
|
||||
|
||||
const apiBase = getApiBase();
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const url = `${apiBase}/device/${mac}/delete`;
|
||||
|
||||
|
||||
$.ajax({
|
||||
url,
|
||||
method: "DELETE",
|
||||
|
||||
@@ -639,7 +639,10 @@ function ImportPastedCSV()
|
||||
data: JSON.stringify({ content: csvBase64 }),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
showMessage(response.success ? (response.message || "Devices imported successfully") : (response.error || "Unknown error"));
|
||||
|
||||
console.log(response);
|
||||
|
||||
showMessage(response.success ? (response.message || response.inserted + " Devices imported successfully") : (response.error || "Unknown error"));
|
||||
write_notification(`[Maintenance] Devices imported from pasted content`, 'info');
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "إجراءات جماعية",
|
||||
"Device_MultiEdit_No_Devices": "لم يتم تحديد أي أجهزة.",
|
||||
"Device_MultiEdit_Tooltip": "تعديل الأجهزة المحددة",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "بحث",
|
||||
"Device_Shortcut_AllDevices": "جميع الأجهزة",
|
||||
"Device_Shortcut_AllNodes": "جميع العقد",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "الكل",
|
||||
"Events_Title": "الأحداث",
|
||||
"FakeMAC_hover": "تم الكشف التلقائي - يشير إلى ما إذا كان الجهاز يستخدم عنوان MAC مزيفًا (يبدأ بـ FA:CE أو 00:1A)، والذي يتم إنشاؤه عادةً بواسطة مكون إضافي لا يمكنه اكتشاف عنوان MAC الحقيقي أو عند إنشاء جهاز وهمي.",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "منفذ خادم GraphQL",
|
||||
"GRAPHQL_PORT_name": "منفذ GraphQL",
|
||||
"Gen_Action": "إجراء",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "نظام",
|
||||
"settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. <b>لم يتم إجراء التحقق.</b>",
|
||||
"test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Accions massives:",
|
||||
"Device_MultiEdit_No_Devices": "Cap dispositiu seleccionat.",
|
||||
"Device_MultiEdit_Tooltip": "Atenció. Si feu clic a això s'aplicarà el valor de l'esquerra a tots els dispositius seleccionats a dalt.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Cerca",
|
||||
"Device_Shortcut_AllDevices": "Els meus dispositius",
|
||||
"Device_Shortcut_AllNodes": "Tots els nodes",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Tot",
|
||||
"Events_Title": "Esdeveniments",
|
||||
"FakeMAC_hover": "Autodetecció - indica si el dispositiu fa servir una adreça MAC falsa (comença amb FA:CE o 00:1A), típicament generada per un plugin que no pot detectar la MAC real o quan es crea un dispositiu amagat (dummy).",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "El número de port del servidor GraphQL. Comprova que el port és únic en totes les aplicacions d'aquest servidor i en totes les instàncies de NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Port GraphQL",
|
||||
"Gen_Action": "Acció",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Sistema",
|
||||
"settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. <b>No hi ha validació.</b>",
|
||||
"test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "",
|
||||
"Device_Shortcut_AllDevices": "",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "",
|
||||
"Events_Title": "",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "",
|
||||
"GRAPHQL_PORT_name": "",
|
||||
"Gen_Action": "",
|
||||
|
||||
@@ -207,6 +207,10 @@
|
||||
"Device_MultiEdit_MassActions": "Massen aktionen:",
|
||||
"Device_MultiEdit_No_Devices": "Keine Geräte ausgewählt.",
|
||||
"Device_MultiEdit_Tooltip": "Achtung! Beim Drücken werden alle Werte auf die oben ausgewählten Geräte übertragen.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Suche",
|
||||
"Device_Shortcut_AllDevices": "Meine Geräte",
|
||||
"Device_Shortcut_AllNodes": "Alle Knoten",
|
||||
@@ -297,6 +301,12 @@
|
||||
"Events_Tablelenght_all": "Alle",
|
||||
"Events_Title": "Ereignisse",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Die Portnummer des GraphQL-Servers. Stellen Sie sicher, dass dieser Port von keiner anderen Anwendung oder NetAlertX Instanz verwendet wird.",
|
||||
"GRAPHQL_PORT_name": "GraphQL-Port",
|
||||
"Gen_Action": "Action",
|
||||
@@ -838,4 +848,4 @@
|
||||
"settings_system_label": "System",
|
||||
"settings_update_item_warning": "",
|
||||
"test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen."
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,10 @@
|
||||
"DevDetail_MainInfo_Network": "<i class=\"fa fa-server\"></i> Node (MAC)",
|
||||
"DevDetail_MainInfo_Network_Port": "<i class=\"fa fa-ethernet\"></i> Port",
|
||||
"DevDetail_MainInfo_Network_Site": "Site",
|
||||
"DevDetail_MainInfo_Network_Title": "Network",
|
||||
"DevDetail_MainInfo_Network_Title": "Network Details",
|
||||
"DevDetail_MainInfo_Owner": "Owner",
|
||||
"DevDetail_MainInfo_SSID": "SSID",
|
||||
"DevDetail_MainInfo_Title": "Main Info",
|
||||
"DevDetail_MainInfo_Title": "Device Information",
|
||||
"DevDetail_MainInfo_Type": "Type",
|
||||
"DevDetail_MainInfo_Vendor": "Vendor",
|
||||
"DevDetail_MainInfo_mac": "MAC",
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Mass actions:",
|
||||
"Device_MultiEdit_No_Devices": "No devices selected.",
|
||||
"Device_MultiEdit_Tooltip": "Careful. Clicking this will apply the value on the left to all devices selected above.",
|
||||
"Device_Save_Failed": "Failed to save device",
|
||||
"Device_Save_Unauthorized": "Unauthorized - invalid API token",
|
||||
"Device_Saved_Success": "Device saved successfully",
|
||||
"Device_Saved_Unexpected": "Device update returned an unexpected response",
|
||||
"Device_Searchbox": "Search",
|
||||
"Device_Shortcut_AllDevices": "My devices",
|
||||
"Device_Shortcut_AllNodes": "All Nodes",
|
||||
@@ -292,7 +296,13 @@
|
||||
"Events_Tablelenght": "Show _MENU_ entries",
|
||||
"Events_Tablelenght_all": "All",
|
||||
"Events_Title": "Events",
|
||||
"FakeMAC_hover": "Autodetected - indicates if the device uses a FAKE MAC address (starting with FA:CE or 00:1A), typically generated by a plugin that cannot detect the real MAC or when creating a dummy device.",
|
||||
"FakeMAC_hover": "This device has a fake/spoofed MAC address",
|
||||
"FieldLock_Error": "Error updating field lock status",
|
||||
"FieldLock_Lock_Tooltip": "Lock field (prevent plugin overwrites)",
|
||||
"FieldLock_Locked": "Field locked",
|
||||
"FieldLock_Source_Label": "Source: ",
|
||||
"FieldLock_Unlock_Tooltip": "Unlock field (allow plugin overwrites)",
|
||||
"FieldLock_Unlocked": "Field unlocked",
|
||||
"GRAPHQL_PORT_description": "The port number of the GraphQL server. Make sure the port is unique across all your applications on this host and NetAlertX instances.",
|
||||
"GRAPHQL_PORT_name": "GraphQL port",
|
||||
"Gen_Action": "Action",
|
||||
@@ -591,7 +601,7 @@
|
||||
"REPORT_MAIL_description": "If enabled an email is sent out with a list of changes you have subscribed to. Please also fill out all remaining settings related to the SMTP setup below. If facing issues, set <code>LOG_LEVEL</code> to <code>debug</code> and check the <a href=\"/maintenance.php#tab_Logging\">error log</a>.",
|
||||
"REPORT_MAIL_name": "Enable email",
|
||||
"REPORT_TITLE": "Report",
|
||||
"RandomMAC_hover": "Autodetected - indicates if the device randomizes it's MAC address. You can exclude specific MACs with the UI_NOT_RANDOM_MAC setting. Click to find out more.",
|
||||
"RandomMAC_hover": "This device has a random MAC address",
|
||||
"Reports_Sent_Log": "Sent reports log",
|
||||
"SCAN_SUBNETS_description": "Most on-network scanners (ARP-SCAN, NMAP, NSLOOKUP, DIG) rely on scanning specific network interfaces and subnets. Check the <a href=\"https://docs.netalertx.com/SUBNETS\" target=\"_blank\">subnets documentation</a> for help on this setting, especially VLANs, what VLANs are supported, or how to figure out the network mask and your interface. <br/> <br/> An alternative to on-network scanners is to enable some other device scanners/importers that don't rely on NetAlert<sup>X</sup> having access to the network (UNIFI, dhcp.leases, PiHole, etc.). <br/> <br/> Note: The scan time itself depends on the number of IP addresses to check, so set this up carefully with the appropriate network mask and interface.",
|
||||
"SCAN_SUBNETS_name": "Networks to scan",
|
||||
@@ -599,7 +609,7 @@
|
||||
"Setting_Override": "Override value",
|
||||
"Setting_Override_Description": "Enabling this option will override an App supplied default value with the value specified above.",
|
||||
"Settings_Metadata_Toggle": "Show/hide metadata for the given setting.",
|
||||
"Settings_Show_Description": "Show setting description.",
|
||||
"Settings_Show_Description": "Show description",
|
||||
"Settings_device_Scanners_desync": "⚠ Device scanner schedules are out-of-sync.",
|
||||
"Settings_device_Scanners_desync_popup": "Schedules of devices scanners (<code>*_RUN_SCHD</code>) are not the same. This will result into inconsistent device online/offline notifications. Unless this is intended, please use the same schedule for all enabled <b>🔍device scanners</b>.",
|
||||
"Speedtest_Results": "Speedtest Results",
|
||||
|
||||
@@ -205,6 +205,10 @@
|
||||
"Device_MultiEdit_MassActions": "Acciones masivas:",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "Cuidado. Al hacer clic se aplicará el valor de la izquierda a todos los dispositivos seleccionados anteriormente.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Búsqueda",
|
||||
"Device_Shortcut_AllDevices": "Mis dispositivos",
|
||||
"Device_Shortcut_AllNodes": "Todos los nodos",
|
||||
@@ -295,6 +299,12 @@
|
||||
"Events_Tablelenght_all": "Todos",
|
||||
"Events_Title": "Eventos",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "El número de puerto del servidor GraphQL. Asegúrese de que el puerto sea único en todas sus aplicaciones en este host y en las instancias de NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Puerto GraphQL",
|
||||
"Gen_Action": "Acción",
|
||||
@@ -836,4 +846,4 @@
|
||||
"settings_system_label": "Sistema",
|
||||
"settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. <b>O la validación no se realiza.</b>",
|
||||
"test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "",
|
||||
"Device_Shortcut_AllDevices": "",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "",
|
||||
"Events_Title": "",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "",
|
||||
"GRAPHQL_PORT_name": "",
|
||||
"Gen_Action": "",
|
||||
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Actions en masse :",
|
||||
"Device_MultiEdit_No_Devices": "Aucun appareil sélectionné.",
|
||||
"Device_MultiEdit_Tooltip": "Attention. Ceci va appliquer la valeur de gauche à tous les appareils sélectionnés au-dessus.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Rechercher",
|
||||
"Device_Shortcut_AllDevices": "Mes appareils",
|
||||
"Device_Shortcut_AllNodes": "Tous les nœuds",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Tous",
|
||||
"Events_Title": "Évènements",
|
||||
"FakeMAC_hover": "Autodétecté - indique si l'appareil utilise une fausse adresse MAC (qui commence par FA:CE ou 00:1A), typiquement générée par un plugin qui ne peut pas détecter la vraie adresse MAC, ou en créant un appareil factice.",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Le numéro de port du serveur GraphQL. Assurez vous sue le port est unique a l'échelle de toutes les applications sur cet hôte et vos instances NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Port GraphQL",
|
||||
"Gen_Action": "Action",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Système",
|
||||
"settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. <b>Il n'y a pas de pas de contrôle.</b>",
|
||||
"test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Azioni di massa:",
|
||||
"Device_MultiEdit_No_Devices": "Nessun dispositivo selezionato.",
|
||||
"Device_MultiEdit_Tooltip": "Attento. Facendo clic verrà applicato il valore sulla sinistra a tutti i dispositivi selezionati sopra.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Cerca",
|
||||
"Device_Shortcut_AllDevices": "I miei dispositivi",
|
||||
"Device_Shortcut_AllNodes": "Tutti i nodi",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Tutti",
|
||||
"Events_Title": "Eventi",
|
||||
"FakeMAC_hover": "Rilevato automaticamente: indica se il dispositivo utilizza un indirizzo MAC FALSO (che inizia con FA:CE o 00:1A), in genere generato da un plugin che non riesce a rilevare il MAC reale o quando si crea un dispositivo fittizio.",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Il numero di porta del server GraphQL. Assicurati che la porta sia univoca in tutte le tue applicazioni su questo host e nelle istanze di NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Porta GraphQL",
|
||||
"Gen_Action": "Azione",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Sistema",
|
||||
"settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. <b>La convalida non viene eseguita.</b>",
|
||||
"test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "大量のアクション:",
|
||||
"Device_MultiEdit_No_Devices": "デバイスが選択されていません。",
|
||||
"Device_MultiEdit_Tooltip": "注意。これをクリックすると、左側の値が上記で選択したすべてのデバイスに適用されます。",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "検索",
|
||||
"Device_Shortcut_AllDevices": "自分のデバイス",
|
||||
"Device_Shortcut_AllNodes": "全ノード",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "全件",
|
||||
"Events_Title": "イベント",
|
||||
"FakeMAC_hover": "自動検出 - デバイスがFAKE MACアドレス(FA:CEまたは00:1Aで始まる)を使用しているかどうかを示します。これは通常、本来のMACアドレスを検出できないプラグインによる生成か、ダミーデバイスの作成によって使用されます。",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "GraphQLサーバーのポート番号。このホスト上のすべてのアプリケーションおよびNetAlertXインスタンスにおいて、ポートが一意であることを確認してください。",
|
||||
"GRAPHQL_PORT_name": "GraphQLポート",
|
||||
"Gen_Action": "アクション",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "システム",
|
||||
"settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。<b>検証は行われません。</b>",
|
||||
"test_event_tooltip": "設定をテストする前に、まず変更を保存してください。"
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Flerhandlinger:",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "Forsiktig. Ved å klikke på denne vil verdien til venstre brukes på alle enhetene som er valgt ovenfor.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Søk",
|
||||
"Device_Shortcut_AllDevices": "Mine Enheter",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Alle",
|
||||
"Events_Title": "Hendelser",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "",
|
||||
"GRAPHQL_PORT_name": "",
|
||||
"Gen_Action": "Handling",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "System",
|
||||
"settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. <b>Validering etterpå utføres ikke.</b>",
|
||||
"test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Operacje zbiorcze:",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "Uwaga. Kliknięcie tego spowoduje zastosowanie wartości po lewej stronie do wszystkich wybranych powyżej urządzeń.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Szukaj",
|
||||
"Device_Shortcut_AllDevices": "Moje urządzenia",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Wszystkie",
|
||||
"Events_Title": "Zdarzenia",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Numer portu serwera GraphQL. Upewnij się, że port jest unikalny na wszystkich twoich aplikacjach na tym hoście i instancjach NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Port GraphQL",
|
||||
"Gen_Action": "Akcja",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "System",
|
||||
"settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. <b>Walidacja nie jest wykonywana.</b>",
|
||||
"test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Ações em massa:",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "Cuidadoso. Clicar aqui aplicará o valor à esquerda a todos os dispositivos selecionados acima.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Procurar",
|
||||
"Device_Shortcut_AllDevices": "Meus dispositivos",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Todos",
|
||||
"Events_Title": "Eventos",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "O número da porta do servidor GraphQL. Certifique-se de que a porta seja exclusiva em todos os seus aplicativos neste host e nas instâncias do NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Porta GraphQL",
|
||||
"Gen_Action": "Ação",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "",
|
||||
"settings_update_item_warning": "",
|
||||
"test_event_tooltip": "Guarde as alterações antes de testar as definições."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Ações em massa:",
|
||||
"Device_MultiEdit_No_Devices": "Nenhum dispositivo selecionado.",
|
||||
"Device_MultiEdit_Tooltip": "Cuidadoso. Clicar aqui aplicará o valor à esquerda a todos os dispositivos selecionados acima.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Procurar",
|
||||
"Device_Shortcut_AllDevices": "Os meus dispositivos",
|
||||
"Device_Shortcut_AllNodes": "Todos os Nodes",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Todos",
|
||||
"Events_Title": "Eventos",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "O número da porta do servidor GraphQL. Certifique-se de que a porta seja exclusiva em todas as suas aplicações neste host e nas instâncias do NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Porta GraphQL",
|
||||
"Gen_Action": "Ação",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "",
|
||||
"settings_update_item_warning": "",
|
||||
"test_event_tooltip": "Guarde as alterações antes de testar as definições."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Массовые действия:",
|
||||
"Device_MultiEdit_No_Devices": "Устройства не выбраны.",
|
||||
"Device_MultiEdit_Tooltip": "Осторожно. При нажатии на эту кнопку значение слева будет применено ко всем устройствам, выбранным выше.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Поиск",
|
||||
"Device_Shortcut_AllDevices": "Мои устройства",
|
||||
"Device_Shortcut_AllNodes": "Все узлы",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Все",
|
||||
"Events_Title": "События",
|
||||
"FakeMAC_hover": "Автоопределение — указывает, использует ли устройство ПОДДЕЛЬНЫЙ MAC-адрес (начинающийся с FA:CE или 00:1A), обычно создаваемый плагином, который не может обнаружить настоящий MAC-адрес, или при создании фиктивного устройства.",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Номер порта сервера GraphQL. Убедитесь, что порт уникален для всех ваших приложений на этом хосте и экземпляров NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Порт GraphQL",
|
||||
"Gen_Action": "Действия",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Система",
|
||||
"settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. <b>Проверка не выполняется.</b>",
|
||||
"test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "",
|
||||
"Device_Shortcut_AllDevices": "",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "",
|
||||
"Events_Title": "",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "",
|
||||
"GRAPHQL_PORT_name": "",
|
||||
"Gen_Action": "",
|
||||
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Toplu komutlar:",
|
||||
"Device_MultiEdit_No_Devices": "",
|
||||
"Device_MultiEdit_Tooltip": "Dikkat. Buna tıklamak, soldaki değeri yukarıda seçilen tüm cihazlara uygulayacaktır.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Arama",
|
||||
"Device_Shortcut_AllDevices": "Cihazlarım",
|
||||
"Device_Shortcut_AllNodes": "",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Hepsi",
|
||||
"Events_Title": "Olaylar",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "GraphQL sunucusunun port numarası. Portun, bu anahtardaki tüm uygulamalar ve NetAlertX örnekleri arasında benzersiz olduğundan emin olun.",
|
||||
"GRAPHQL_PORT_name": "GraphQL port",
|
||||
"Gen_Action": "Komut",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Sistem",
|
||||
"settings_update_item_warning": "",
|
||||
"test_event_tooltip": ""
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "Масові акції:",
|
||||
"Device_MultiEdit_No_Devices": "Не вибрано жодного пристрою.",
|
||||
"Device_MultiEdit_Tooltip": "Обережно. Якщо натиснути це, значення зліва буде застосовано до всіх пристроїв, вибраних вище.",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "Пошук",
|
||||
"Device_Shortcut_AllDevices": "Мої пристрої",
|
||||
"Device_Shortcut_AllNodes": "Усі вузли",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "Все",
|
||||
"Events_Title": "Події",
|
||||
"FakeMAC_hover": "Автоматично виявлено – вказує, чи пристрій використовує ПІДРОБНУ MAC-адресу (що починається з FA:CE або 00:1A), зазвичай згенеровану плагіном, який не може визначити справжню MAC-адресу, або під час створення фіктивного пристрою.",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "Номер порту сервера GraphQL. Переконайтеся, що порт є унікальним для всіх ваших програм на цьому хості та екземплярах NetAlertX.",
|
||||
"GRAPHQL_PORT_name": "Порт GraphQL",
|
||||
"Gen_Action": "Дія",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "Система",
|
||||
"settings_update_item_warning": "Оновіть значення нижче. Слідкуйте за попереднім форматом. <b>Перевірка не виконана.</b>",
|
||||
"test_event_tooltip": "Перш ніж перевіряти налаштування, збережіть зміни."
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@
|
||||
"Device_MultiEdit_MassActions": "谨慎操作:",
|
||||
"Device_MultiEdit_No_Devices": "未选择设备。",
|
||||
"Device_MultiEdit_Tooltip": "小心。 单击此按钮会将左侧的值应用到上面选择的所有设备。",
|
||||
"Device_Save_Failed": "",
|
||||
"Device_Save_Unauthorized": "",
|
||||
"Device_Saved_Success": "",
|
||||
"Device_Saved_Unexpected": "",
|
||||
"Device_Searchbox": "搜索",
|
||||
"Device_Shortcut_AllDevices": "我的设备",
|
||||
"Device_Shortcut_AllNodes": "全部节点",
|
||||
@@ -293,6 +297,12 @@
|
||||
"Events_Tablelenght_all": "全部",
|
||||
"Events_Title": "事件",
|
||||
"FakeMAC_hover": "",
|
||||
"FieldLock_Error": "",
|
||||
"FieldLock_Lock_Tooltip": "",
|
||||
"FieldLock_Locked": "",
|
||||
"FieldLock_Source_Label": "",
|
||||
"FieldLock_Unlock_Tooltip": "",
|
||||
"FieldLock_Unlocked": "",
|
||||
"GRAPHQL_PORT_description": "GraphQL服务器的端口号。请确保该端口在该主机和 NetAlertX 实例上的所有应用程序中都是唯一的。",
|
||||
"GRAPHQL_PORT_name": "GraphQL端口",
|
||||
"Gen_Action": "动作",
|
||||
@@ -765,4 +775,4 @@
|
||||
"settings_system_label": "系统",
|
||||
"settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。<b>未执行验证。</b>",
|
||||
"test_event_tooltip": "在测试设置之前,请先保存更改。"
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,72 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -307,6 +307,71 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -267,6 +267,70 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devVendor",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
|
||||
@@ -129,8 +129,71 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "CMD",
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "function": "CMD",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
|
||||
@@ -682,6 +682,70 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
|
||||
@@ -303,6 +303,72 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -296,6 +296,70 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -410,6 +410,70 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -132,8 +132,72 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "CMD",
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "function": "CMD",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
|
||||
@@ -383,7 +383,70 @@
|
||||
"string": "Retrieve only devices that are reachable."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
{
|
||||
|
||||
@@ -267,6 +267,70 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -1828,6 +1828,145 @@
|
||||
"string": "Children nodes with the <code>nic</code> Relationship Type. Navigate to the child device directly to edit the relationship and details. Database column name: <code>N/A</code> (evaluated dynamically)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "devPrimaryIPv4",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"readonly": "true"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"maxLength": 50,
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Primary IPv4"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "The primary IPv4 address of the device. Uneditable - Automatically maintained from scan results. Database column name: <code>devPrimaryIPv4</code>."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "devPrimaryIPv6",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [
|
||||
{
|
||||
"readonly": "true"
|
||||
}
|
||||
],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"maxLength": 50,
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Primary IPv6"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "The primary IPv6 address of the device. Uneditable - Automatically maintained from scan results. Database column name: <code>devPrimaryIPv6</code>."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "devVlan",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "input",
|
||||
"elementOptions": [],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"maxLength": 50,
|
||||
"default_value": "",
|
||||
"options": [],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "VLAN"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "The VLAN identifier or name the device belongs to. Database column name: <code>devVlan</code>."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "devForceStatus",
|
||||
"type": {
|
||||
"dataType": "string",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": "dont_force",
|
||||
"options": [
|
||||
"dont_force" ,
|
||||
"online",
|
||||
"offline"
|
||||
],
|
||||
"localized": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Force Status"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Force the device online/offline status: <code>online</code> always online, <code>offline</code> always offline, <code>dont_force</code> auto-detect. Database column name: <code>devForceStatus</code>."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"required": [],
|
||||
|
||||
@@ -451,6 +451,71 @@
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -467,6 +467,73 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devParentMAC",
|
||||
"devSSID",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -440,6 +440,74 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devParentMAC",
|
||||
"devSSID",
|
||||
"devType",
|
||||
"devSourcePlugin",
|
||||
"devSite"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -115,6 +115,72 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "function": "SET_ALWAYS",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": ["devMac", "devLastIP", "devName", "devVendor"],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set always columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devName",
|
||||
"devLastIP",
|
||||
"devVendor",
|
||||
"devMac",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "URL",
|
||||
"type": {
|
||||
@@ -130,7 +196,7 @@
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Setting name"
|
||||
"string": "PiHole URL"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
|
||||
@@ -216,6 +216,73 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_ALWAYS",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true"}],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": ["devMac", "devLastIP"],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set always columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -586,6 +586,70 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
|
||||
@@ -596,6 +596,72 @@
|
||||
"string": "Maximale Zeit in Sekunden, die auf den Abschluss des Skripts gewartet werden soll. Bei Überschreitung dieser Zeit wird das Skript abgebrochen."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSyncHubNode",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -497,6 +497,74 @@
|
||||
"string": "UniFi site configurations. Use a unique name for each site. You can find necessary details to configure this in your controller under <i>Settings -> Control Plane -> Integrations</i>."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_ALWAYS",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": ["devMac", "devLastIP", "devName", "devParentMAC"],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devParentMAC"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set always columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devParentMAC",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"database_column_definitions": [
|
||||
|
||||
@@ -916,6 +916,80 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"function": "SET_ALWAYS",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": ["devMac", "devLastIP", "devName", "devVendor", "devSSID", "devParentMAC", "devParentPort"],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSSID",
|
||||
"devParentMAC",
|
||||
"devParentPort"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set always columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devLastIP",
|
||||
"devName",
|
||||
"devVendor",
|
||||
"devSSID",
|
||||
"devParentMAC",
|
||||
"devParentPort",
|
||||
"devType",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"default_value": ["Watched_Value1", "Watched_Value4"],
|
||||
"description": [
|
||||
|
||||
@@ -226,6 +226,72 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_ALWAYS",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": ["devMac", "devVendor"],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devVendor",
|
||||
"devName"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set always columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "SET_EMPTY",
|
||||
"type": {
|
||||
"dataType": "array",
|
||||
"elements": [
|
||||
{
|
||||
"elementType": "select",
|
||||
"elementOptions": [{ "multiple": "true", "ordeable": "true" }],
|
||||
"transformers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"default_value": [],
|
||||
"options": [
|
||||
"devMac",
|
||||
"devVendor",
|
||||
"devName",
|
||||
"devLastIP",
|
||||
"devSourcePlugin"
|
||||
],
|
||||
"localized": ["name", "description"],
|
||||
"name": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "Set empty columns"
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
{
|
||||
"language_code": "en_us",
|
||||
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "WATCH",
|
||||
"type": {
|
||||
|
||||
@@ -73,6 +73,7 @@ nav:
|
||||
- Custom Properties: CUSTOM_PROPERTIES.md
|
||||
- Device Display Settings: DEVICE_DISPLAY_SETTINGS.md
|
||||
- Session Info: SESSION_INFO.md
|
||||
- Field Lock/Unlock: QUICK_REFERENCE_FIELD_LOCK.md
|
||||
- Icons and Topology:
|
||||
- Icons: ICONS.md
|
||||
- Network Topology: NETWORK_TREE.md
|
||||
@@ -109,6 +110,7 @@ nav:
|
||||
- Overview: API.md
|
||||
- Devices Collection: API_DEVICES.md
|
||||
- Device: API_DEVICE.md
|
||||
- Device Field Lock: API_DEVICE_FIELD_LOCK.md
|
||||
- Sessions: API_SESSIONS.md
|
||||
- Settings: API_SETTINGS.md
|
||||
- Events: API_EVENTS.md
|
||||
|
||||
@@ -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]
|
||||
@@ -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,57 @@ 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
|
||||
|
||||
device_handler = DeviceInstance()
|
||||
normalized_mac = normalize_mac(mac)
|
||||
|
||||
try:
|
||||
if should_lock:
|
||||
result = device_handler.lockDeviceField(normalized_mac, field_name)
|
||||
action = "locked"
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@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 (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devFQDNSource = String(description="Source tracking for devFQDN (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devLastIPSource = String(description="Source tracking for devLastIP (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devVendorSource = String(description="Source tracking for devVendor (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devSSIDSource = String(description="Source tracking for devSSID (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devParentMACSource = String(description="Source tracking for devParentMAC (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devParentPortSource = String(description="Source tracking for devParentPort (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||
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: Optional[str] = Field(None, 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'
|
||||
@@ -179,14 +193,18 @@ sql_online_history = "SELECT * FROM Online_History"
|
||||
sql_plugins_events = "SELECT * FROM Plugins_Events"
|
||||
sql_plugins_history = "SELECT * FROM Plugins_History ORDER BY DateTimeChanged DESC"
|
||||
sql_new_devices = """SELECT * FROM (
|
||||
SELECT eve_IP as devLastIP, eve_MAC as devMac
|
||||
SELECT eve_IP as devLastIP,
|
||||
eve_MAC as devMac,
|
||||
MAX(eve_DateTime) as lastEvent
|
||||
FROM Events_Devices
|
||||
WHERE eve_PendingAlertEmail = 1
|
||||
AND eve_EventType = 'New Device'
|
||||
ORDER BY eve_DateTime ) t1
|
||||
LEFT JOIN
|
||||
( SELECT devName, devMac as devMac_t2 FROM Devices) t2
|
||||
ON t1.devMac = t2.devMac_t2"""
|
||||
GROUP BY eve_MAC
|
||||
ORDER BY lastEvent
|
||||
) t1
|
||||
LEFT JOIN
|
||||
( SELECT devName, devMac as devMac_t2 FROM Devices ) t2
|
||||
ON t1.devMac = t2.devMac_t2"""
|
||||
|
||||
|
||||
sql_generateGuid = """
|
||||
|
||||
@@ -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)
|
||||
|
||||
360
server/db/authoritative_handler.py
Normal file
360
server/db/authoritative_handler.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
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]
|
||||
from db.db_helper import row_to_json # 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_value, current_source, plugin_prefix, plugin_settings, field_value):
|
||||
"""
|
||||
Determine if a plugin can overwrite a field.
|
||||
|
||||
Rules:
|
||||
- USER/LOCKED cannot overwrite.
|
||||
- SET_ALWAYS can overwrite everything if new value not empty.
|
||||
- SET_EMPTY can overwrite if current value empty.
|
||||
- Otherwise, overwrite only empty fields.
|
||||
|
||||
Args:
|
||||
field_name: The field being updated (e.g., "devName").
|
||||
current_value: Current value in Devices.
|
||||
current_source: Current source in Devices (USER, LOCKED, etc.).
|
||||
plugin_prefix: Plugin prefix.
|
||||
plugin_settings: Dict with set_always and set_empty lists.
|
||||
field_value: The new value from scan.
|
||||
|
||||
Returns:
|
||||
bool: True if overwrite allowed.
|
||||
"""
|
||||
|
||||
# Rule 1: USER/LOCKED protected
|
||||
if current_source in ("USER", "LOCKED"):
|
||||
return False
|
||||
|
||||
# Rule 2: Must provide a non-empty value or same as current
|
||||
empty_values = ("0.0.0.0", "", "null", "(unknown)", "(name not found)", None)
|
||||
if not field_value or (isinstance(field_value, str) and not field_value.strip()):
|
||||
if current_value == field_value:
|
||||
return True # Allow overwrite if value same
|
||||
return False
|
||||
|
||||
# Rule 3: SET_ALWAYS
|
||||
set_always = plugin_settings.get("set_always", [])
|
||||
if field_name in set_always:
|
||||
return True
|
||||
|
||||
# Rule 4: SET_EMPTY
|
||||
set_empty = plugin_settings.get("set_empty", [])
|
||||
empty_values = ("0.0.0.0", "", "null", "(unknown)", "(name not found)", None)
|
||||
if field_name in set_empty:
|
||||
if current_value in empty_values:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Rule 5: Default - overwrite if current value empty
|
||||
return current_value in empty_values
|
||||
|
||||
|
||||
def get_overwrite_sql_clause(field_name, source_column, plugin_settings):
|
||||
"""
|
||||
Build a SQL condition for authoritative overwrite checks.
|
||||
|
||||
Returns a SQL snippet that permits overwrite for the given field
|
||||
based on SET_ALWAYS/SET_EMPTY and USER/LOCKED protection.
|
||||
|
||||
Args:
|
||||
field_name: The field being updated (e.g., "devName").
|
||||
source_column: The *Source column name (e.g., "devNameSource").
|
||||
plugin_settings: dict with "set_always" and "set_empty" lists.
|
||||
|
||||
Returns:
|
||||
str: SQL condition snippet (no leading WHERE).
|
||||
"""
|
||||
set_always = plugin_settings.get("set_always", [])
|
||||
set_empty = plugin_settings.get("set_empty", [])
|
||||
|
||||
mylog("debug", [f"[get_overwrite_sql_clause] DEBUG: field_name:{field_name}, source_column:{source_column}, set_always:{set_always}, set_empty:{set_empty}"])
|
||||
|
||||
if field_name in set_always:
|
||||
return f"COALESCE({source_column}, '') NOT IN ('USER', 'LOCKED')"
|
||||
|
||||
if field_name in set_empty or field_name not in set_always:
|
||||
return f"COALESCE({source_column}, '') IN ('', 'NEWDEV')"
|
||||
|
||||
return f"COALESCE({source_column}, '') IN ('', 'NEWDEV')"
|
||||
|
||||
|
||||
def get_source_for_field_update_with_value(
|
||||
field_name, plugin_prefix, field_value, is_user_override=False
|
||||
):
|
||||
"""
|
||||
Determine the source value for a field update based on the new value.
|
||||
|
||||
If the new value is empty or an "unknown" placeholder, return NEWDEV.
|
||||
Otherwise, fall back to standard source selection rules.
|
||||
|
||||
Args:
|
||||
field_name: The field being updated.
|
||||
plugin_prefix: The unique prefix of the plugin writing (e.g., "UNIFIAPI").
|
||||
field_value: The new value being written.
|
||||
is_user_override: If True, return "USER".
|
||||
|
||||
Returns:
|
||||
str: The source value to set for the *Source field.
|
||||
"""
|
||||
if is_user_override:
|
||||
return "USER"
|
||||
|
||||
if field_value is None:
|
||||
return "NEWDEV"
|
||||
|
||||
if isinstance(field_value, str):
|
||||
stripped = field_value.strip()
|
||||
if stripped in ("", "null"):
|
||||
return "NEWDEV"
|
||||
if stripped.lower() in ("(unknown)", "(name not found)"):
|
||||
return "NEWDEV"
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
# 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 in updates_dict.keys():
|
||||
if field_name in FIELD_SOURCE_MAP:
|
||||
source_field = FIELD_SOURCE_MAP[field_name]
|
||||
if not device_columns or source_field in device_columns:
|
||||
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)
|
||||
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}"])
|
||||
raise
|
||||
|
||||
|
||||
def get_locked_field_overrides(devMac, updates_dict, conn):
|
||||
"""
|
||||
For user updates, restore values for any fields whose *Source is LOCKED.
|
||||
|
||||
Args:
|
||||
devMac: The MAC address of the device being updated.
|
||||
updates_dict: Dict of field -> value being updated.
|
||||
conn: Database connection object.
|
||||
|
||||
Returns:
|
||||
tuple(set, dict): (locked_fields, overrides)
|
||||
locked_fields: set of field names that are locked
|
||||
overrides: dict of field -> existing value to preserve
|
||||
"""
|
||||
tracked_fields = [field for field in updates_dict.keys() if field in FIELD_SOURCE_MAP]
|
||||
if not tracked_fields:
|
||||
return set(), {}
|
||||
|
||||
select_columns = tracked_fields + [FIELD_SOURCE_MAP[field] for field in tracked_fields]
|
||||
select_clause = ", ".join(select_columns)
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
f"SELECT {select_clause} FROM Devices WHERE devMac=?",
|
||||
(devMac,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
|
||||
if not row:
|
||||
return set(), {}
|
||||
|
||||
row_data = row_to_json(list(row.keys()), row)
|
||||
locked_fields = set()
|
||||
overrides = {}
|
||||
|
||||
for field in tracked_fields:
|
||||
source_field = FIELD_SOURCE_MAP[field]
|
||||
if row_data.get(source_field) == "LOCKED":
|
||||
locked_fields.add(field)
|
||||
overrides[field] = row_data.get(field) or ""
|
||||
|
||||
return locked_fields, overrides
|
||||
|
||||
|
||||
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()
|
||||
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 = ?"
|
||||
|
||||
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()
|
||||
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 = ?"
|
||||
|
||||
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) [v26.1/2.XX]
|
||||
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}')
|
||||
@@ -163,6 +171,27 @@ def ensure_views(sql) -> bool:
|
||||
EVE1.eve_PairEventRowID IS NULL;
|
||||
""")
|
||||
|
||||
sql.execute(""" DROP VIEW IF EXISTS LatestDeviceScan;""")
|
||||
sql.execute(""" CREATE VIEW LatestDeviceScan AS
|
||||
WITH RankedScans AS (
|
||||
SELECT
|
||||
c.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.cur_MAC, c.cur_ScanMethod
|
||||
ORDER BY c.cur_DateTime DESC
|
||||
) AS rn
|
||||
FROM CurrentScan c
|
||||
)
|
||||
SELECT
|
||||
d.*, -- all Device fields
|
||||
r.* -- all CurrentScan fields (cur_*)
|
||||
FROM Devices d
|
||||
LEFT JOIN RankedScans r
|
||||
ON d.devMac = r.cur_MAC
|
||||
WHERE r.rn = 1;
|
||||
|
||||
""")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -263,6 +292,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)
|
||||
|
||||
@@ -189,7 +189,7 @@ def get_setting(key):
|
||||
SETTINGS_LASTCACHEDATE = fileModifiedTime
|
||||
|
||||
if key not in SETTINGS_CACHE:
|
||||
mylog("none", [f"[Settings] ⚠ ERROR - setting_missing - {key} not in {settingsFile}"],)
|
||||
mylog("verbose", [f"[Settings] INFO - setting_missing - {key} not in {settingsFile}"],)
|
||||
return None
|
||||
|
||||
return SETTINGS_CACHE[key]
|
||||
|
||||
@@ -9,6 +9,13 @@ 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,
|
||||
get_locked_field_overrides,
|
||||
lock_field,
|
||||
unlock_field,
|
||||
FIELD_SOURCE_MAP,
|
||||
)
|
||||
from helper import is_random_mac, get_setting_value
|
||||
from utils.datetime_utils import timeNowDB, format_date
|
||||
|
||||
@@ -411,7 +418,7 @@ class DeviceInstance:
|
||||
"devGUID": "",
|
||||
"devSite": "",
|
||||
"devSSID": "",
|
||||
"devSyncHubNode": "",
|
||||
"devSyncHubNode": str(get_setting_value("SYNC_node_name")),
|
||||
"devSourcePlugin": "",
|
||||
"devCustomProps": "",
|
||||
"devStatus": "Unknown",
|
||||
@@ -421,6 +428,7 @@ class DeviceInstance:
|
||||
"devDownAlerts": 0,
|
||||
"devPresenceHours": 0,
|
||||
"devFQDN": "",
|
||||
"devForceStatus" : "dont_force"
|
||||
}
|
||||
return device_data
|
||||
|
||||
@@ -503,6 +511,68 @@ class DeviceInstance:
|
||||
normalized_mac = normalize_mac(mac)
|
||||
normalized_parent_mac = normalize_mac(data.get("devParentMAC") or "")
|
||||
|
||||
fields_updated_by_set_device_data = {
|
||||
"devName",
|
||||
"devOwner",
|
||||
"devType",
|
||||
"devVendor",
|
||||
"devIcon",
|
||||
"devFavorite",
|
||||
"devGroup",
|
||||
"devLocation",
|
||||
"devComments",
|
||||
"devParentMAC",
|
||||
"devParentPort",
|
||||
"devSSID",
|
||||
"devSite",
|
||||
"devStaticIP",
|
||||
"devScan",
|
||||
"devAlertEvents",
|
||||
"devAlertDown",
|
||||
"devParentRelType",
|
||||
"devReqNicsOnline",
|
||||
"devSkipRepeated",
|
||||
"devIsNew",
|
||||
"devIsArchived",
|
||||
"devCustomProps",
|
||||
"devForceStatus"
|
||||
}
|
||||
|
||||
# Only mark USER for tracked fields that this method actually updates.
|
||||
tracked_update_fields = set(FIELD_SOURCE_MAP.keys()) & fields_updated_by_set_device_data
|
||||
tracked_update_fields.discard("devMac")
|
||||
|
||||
locked_fields = set()
|
||||
pre_update_tracked_values = {}
|
||||
if not data.get("createNew", False):
|
||||
conn_preview = get_temp_db_connection()
|
||||
try:
|
||||
locked_fields, overrides = get_locked_field_overrides(
|
||||
normalized_mac,
|
||||
data,
|
||||
conn_preview,
|
||||
)
|
||||
if overrides:
|
||||
data.update(overrides)
|
||||
|
||||
# Capture pre-update values for tracked fields so we can mark USER only
|
||||
# when the user actually changes the value.
|
||||
tracked_fields_in_payload = [
|
||||
k for k in data.keys() if k in tracked_update_fields
|
||||
]
|
||||
if tracked_fields_in_payload:
|
||||
select_clause = ", ".join(tracked_fields_in_payload)
|
||||
cur_preview = conn_preview.cursor()
|
||||
cur_preview.execute(
|
||||
f"SELECT {select_clause} FROM Devices WHERE devMac=?",
|
||||
(normalized_mac,),
|
||||
)
|
||||
row = cur_preview.fetchone()
|
||||
if row:
|
||||
pre_update_tracked_values = row_to_json(list(row.keys()), row)
|
||||
finally:
|
||||
conn_preview.close()
|
||||
|
||||
conn = None
|
||||
try:
|
||||
if data.get("createNew", False):
|
||||
@@ -515,8 +585,8 @@ class DeviceInstance:
|
||||
devParentRelType, devReqNicsOnline, devSkipRepeated,
|
||||
devIsNew, devIsArchived, devLastConnection,
|
||||
devFirstConnection, devLastIP, devGUID, devCustomProps,
|
||||
devSourcePlugin
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
devSourcePlugin, devForceStatus
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
values = (
|
||||
@@ -549,6 +619,7 @@ class DeviceInstance:
|
||||
data.get("devGUID") or "",
|
||||
data.get("devCustomProps") or "",
|
||||
data.get("devSourcePlugin") or "DUMMY",
|
||||
data.get("devForceStatus") or "dont_force",
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -559,7 +630,7 @@ class DeviceInstance:
|
||||
devParentMAC=?, devParentPort=?, devSSID=?, devSite=?,
|
||||
devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?,
|
||||
devParentRelType=?, devReqNicsOnline=?, devSkipRepeated=?,
|
||||
devIsNew=?, devIsArchived=?, devCustomProps=?
|
||||
devIsNew=?, devIsArchived=?, devCustomProps=?, devForceStatus=?
|
||||
WHERE devMac=?
|
||||
"""
|
||||
values = (
|
||||
@@ -586,12 +657,73 @@ class DeviceInstance:
|
||||
data.get("devIsNew") or 0,
|
||||
data.get("devIsArchived") or 0,
|
||||
data.get("devCustomProps") or "",
|
||||
data.get("devForceStatus") or "dont_force",
|
||||
normalized_mac,
|
||||
)
|
||||
|
||||
conn = get_temp_db_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql, values)
|
||||
|
||||
if data.get("createNew", False):
|
||||
# Initialize source-tracking fields on device creation.
|
||||
# We always mark devMacSource as NEWDEV, and mark other tracked fields
|
||||
# as NEWDEV only if the create payload provides a non-empty value.
|
||||
initial_sources = {FIELD_SOURCE_MAP["devMac"]: "NEWDEV"}
|
||||
for field_name, source_field in FIELD_SOURCE_MAP.items():
|
||||
if field_name == "devMac":
|
||||
continue
|
||||
field_value = data.get(field_name)
|
||||
if field_value is None:
|
||||
continue
|
||||
if isinstance(field_value, str) and not field_value.strip():
|
||||
continue
|
||||
initial_sources[source_field] = "NEWDEV"
|
||||
|
||||
if initial_sources:
|
||||
# Apply source updates in a single statement for the newly inserted row.
|
||||
set_clause = ", ".join([f"{col}=?" for col in initial_sources.keys()])
|
||||
source_values = list(initial_sources.values())
|
||||
source_values.append(normalized_mac)
|
||||
source_sql = f"UPDATE Devices SET {set_clause} WHERE devMac = ?"
|
||||
cur.execute(source_sql, source_values)
|
||||
|
||||
# Enforce source tracking on user updates
|
||||
# User-updated fields should have their *Source set to "USER"
|
||||
def _normalize_tracked_value(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return str(value)
|
||||
|
||||
user_updated_fields = {}
|
||||
if not data.get("createNew", False):
|
||||
for field_name in tracked_update_fields:
|
||||
if field_name in locked_fields:
|
||||
continue
|
||||
if field_name not in data:
|
||||
continue
|
||||
|
||||
if field_name == "devParentMAC":
|
||||
new_value = normalized_parent_mac
|
||||
else:
|
||||
new_value = data.get(field_name)
|
||||
|
||||
old_value = pre_update_tracked_values.get(field_name)
|
||||
if _normalize_tracked_value(old_value) != _normalize_tracked_value(new_value):
|
||||
user_updated_fields[field_name] = new_value
|
||||
|
||||
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}"}
|
||||
|
||||
# Commit all changes atomically after all operations succeed
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -664,6 +796,36 @@ 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"}
|
||||
|
||||
mac_normalized = normalize_mac(mac)
|
||||
conn = get_temp_db_connection()
|
||||
try:
|
||||
lock_field(mac_normalized, field_name, conn)
|
||||
return {"success": True, "message": f"Field {field_name} locked"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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"}
|
||||
|
||||
mac_normalized = normalize_mac(mac)
|
||||
conn = get_temp_db_connection()
|
||||
try:
|
||||
unlock_field(mac_normalized, field_name, conn)
|
||||
return {"success": True, "message": f"Field {field_name} unlocked"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def copyDevice(self, mac_from, mac_to):
|
||||
"""Copy a device entry from one MAC to another."""
|
||||
conn = get_temp_db_connection()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
import ipaddress
|
||||
from helper import get_setting_value, check_IP_format
|
||||
from utils.datetime_utils import timeNowDB, normalizeTimeStamp
|
||||
from logger import mylog, Logger
|
||||
@@ -9,10 +10,45 @@ from models.device_instance import DeviceInstance
|
||||
from scan.name_resolution import NameResolver
|
||||
from scan.device_heuristics import guess_icon, guess_type
|
||||
from db.db_helper import sanitize_SQL_input, list_to_where, safe_int
|
||||
from db.authoritative_handler import (
|
||||
get_overwrite_sql_clause,
|
||||
can_overwrite_field,
|
||||
get_plugin_authoritative_settings,
|
||||
get_source_for_field_update_with_value,
|
||||
FIELD_SOURCE_MAP
|
||||
)
|
||||
from helper import format_ip_long
|
||||
|
||||
# Make sure log level is initialized correctly
|
||||
Logger(get_setting_value("LOG_LEVEL"))
|
||||
|
||||
_device_columns_cache = None
|
||||
|
||||
|
||||
def get_device_columns(sql, force_reload=False):
|
||||
"""
|
||||
Return a set of column names in the Devices table.
|
||||
|
||||
Cached after first call unless force_reload=True.
|
||||
"""
|
||||
global _device_columns_cache
|
||||
if _device_columns_cache is None or force_reload:
|
||||
try:
|
||||
_device_columns_cache = {row["name"] for row in sql.execute("PRAGMA table_info(Devices)").fetchall()}
|
||||
except Exception:
|
||||
_device_columns_cache = set()
|
||||
return _device_columns_cache
|
||||
|
||||
|
||||
def has_column(sql, column_name):
|
||||
"""
|
||||
Check if a column exists in Devices table.
|
||||
|
||||
Uses cached columns.
|
||||
"""
|
||||
device_columns = get_device_columns(sql)
|
||||
return column_name in device_columns
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
# Removing devices from the CurrentScan DB table which the user chose to ignore by MAC or IP
|
||||
@@ -51,188 +87,287 @@ def exclude_ignored_devices(db):
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
def update_devices_data_from_scan(db):
|
||||
sql = db.sql # TO-DO
|
||||
FIELD_SPECS = {
|
||||
|
||||
# ==========================================================
|
||||
# DEVICE NAME
|
||||
# ==========================================================
|
||||
"devName": {
|
||||
"scan_col": "cur_Name",
|
||||
"source_col": "devNameSource",
|
||||
"empty_values": ["", "null", "(unknown)", "(name not found)"],
|
||||
"default_value": "(unknown)",
|
||||
"priority": ["NSLOOKUP", "AVAHISCAN", "NBTSCAN", "DIGSCAN", "ARPSCAN", "DHCPLSS", "NEWDEV", "N/A"],
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# DEVICE FQDN
|
||||
# ==========================================================
|
||||
"devFQDN": {
|
||||
"scan_col": "cur_Name",
|
||||
"source_col": "devNameSource",
|
||||
"empty_values": ["", "null", "(unknown)", "(name not found)"],
|
||||
"priority": ["NSLOOKUP", "AVAHISCAN", "NBTSCAN", "DIGSCAN", "ARPSCAN", "DHCPLSS", "NEWDEV", "N/A"],
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# IP ADDRESS (last seen)
|
||||
# ==========================================================
|
||||
"devLastIP": {
|
||||
"scan_col": "cur_IP",
|
||||
"source_col": "devLastIPSource",
|
||||
"empty_values": ["", "null", "(unknown)", "(Unknown)"],
|
||||
"priority": ["ARPSCAN", "NEWDEV", "N/A"],
|
||||
"default_value": "0.0.0.0",
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# VENDOR
|
||||
# ==========================================================
|
||||
"devVendor": {
|
||||
"scan_col": "cur_Vendor",
|
||||
"source_col": "devVendorSource",
|
||||
"empty_values": ["", "null", "(unknown)", "(Unknown)"],
|
||||
"priority": ["VNDRPDT", "ARPSCAN", "NEWDEV", "N/A"],
|
||||
},
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# SYNC HUB NODE NAME
|
||||
# ==========================================================
|
||||
"devSyncHubNode": {
|
||||
"scan_col": "cur_SyncHubNodeName",
|
||||
"source_col": None,
|
||||
"empty_values": ["", "null"],
|
||||
"priority": None,
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# Network Site
|
||||
# ==========================================================
|
||||
"devSite": {
|
||||
"scan_col": "cur_NetworkSite",
|
||||
"source_col": None,
|
||||
"empty_values": ["", "null"],
|
||||
"priority": None,
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# VLAN
|
||||
# ==========================================================
|
||||
"devVlan": {
|
||||
"scan_col": "cur_devVlan",
|
||||
"source_col": "devVlanSource",
|
||||
"empty_values": ["", "null"],
|
||||
"priority": None,
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# devType
|
||||
# ==========================================================
|
||||
"devType": {
|
||||
"scan_col": "cur_Type",
|
||||
"source_col": None,
|
||||
"empty_values": ["", "null"],
|
||||
"priority": None,
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# TOPOLOGY (PARENT NODE)
|
||||
# ==========================================================
|
||||
"devParentMAC": {
|
||||
"scan_col": "cur_NetworkNodeMAC",
|
||||
"source_col": "devParentMACSource",
|
||||
"empty_values": ["", "null"],
|
||||
"priority": ["SNMPDSC", "UNIFIAPI", "UNFIMP", "NEWDEV", "N/A"],
|
||||
},
|
||||
|
||||
"devParentPort": {
|
||||
"scan_col": "cur_PORT",
|
||||
"source_col": None,
|
||||
"empty_values": ["", "null"],
|
||||
"priority": ["SNMPDSC", "UNIFIAPI", "UNFIMP", "NEWDEV", "N/A"],
|
||||
},
|
||||
|
||||
# ==========================================================
|
||||
# WIFI SSID
|
||||
# ==========================================================
|
||||
"devSSID": {
|
||||
"scan_col": "cur_SSID",
|
||||
"source_col": None,
|
||||
"empty_values": ["", "null"],
|
||||
"priority": ["SNMPDSC", "UNIFIAPI", "UNFIMP", "NEWDEV", "N/A"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_presence_from_CurrentScan(db):
|
||||
"""
|
||||
Update devPresentLastScan based on whether the device has entries in CurrentScan.
|
||||
"""
|
||||
sql = db.sql
|
||||
mylog("debug", "[Update Devices] - Updating devPresentLastScan")
|
||||
|
||||
# Mark present if exists in CurrentScan
|
||||
sql.execute("""
|
||||
UPDATE Devices
|
||||
SET devPresentLastScan = 1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM CurrentScan
|
||||
WHERE devMac = cur_MAC
|
||||
)
|
||||
""")
|
||||
|
||||
# Mark not present if not in CurrentScan
|
||||
sql.execute("""
|
||||
UPDATE Devices
|
||||
SET devPresentLastScan = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM CurrentScan
|
||||
WHERE devMac = cur_MAC
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def update_devLastConnection_from_CurrentScan(db):
|
||||
"""
|
||||
Update devLastConnection to current time for all devices seen in CurrentScan.
|
||||
"""
|
||||
sql = db.sql
|
||||
startTime = timeNowDB()
|
||||
mylog("debug", f"[Update Devices] - Updating devLastConnection to {startTime}")
|
||||
|
||||
# Update Last Connection
|
||||
mylog("debug", "[Update Devices] 1 Last Connection")
|
||||
sql.execute(f"""UPDATE Devices SET devLastConnection = '{startTime}',
|
||||
devPresentLastScan = 1
|
||||
WHERE EXISTS (SELECT 1 FROM CurrentScan
|
||||
WHERE devMac = cur_MAC) """)
|
||||
sql.execute(f"""
|
||||
UPDATE Devices
|
||||
SET devLastConnection = '{startTime}'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM CurrentScan
|
||||
WHERE devMac = cur_MAC
|
||||
)
|
||||
""")
|
||||
|
||||
# Clean no active devices
|
||||
mylog("debug", "[Update Devices] 2 Clean no active devices")
|
||||
sql.execute("""UPDATE Devices SET devPresentLastScan = 0
|
||||
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)')
|
||||
)""")
|
||||
def update_devices_data_from_scan(db):
|
||||
sql = db.sql
|
||||
|
||||
# Update only devices with empty, NULL or (u(U)nknown) vendors
|
||||
mylog("debug", "[Update Devices] - cur_Vendor -> (if empty) devVendor")
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devVendor = (
|
||||
SELECT cur_Vendor
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devVendor IS NULL OR devVendor IN ("", "null", "(unknown)", "(Unknown)"))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)""")
|
||||
# ----------------------------------------------------------------
|
||||
# 1️⃣ Get plugin scan methods
|
||||
# ----------------------------------------------------------------
|
||||
plugin_rows = sql.execute("SELECT DISTINCT cur_ScanMethod FROM CurrentScan").fetchall()
|
||||
plugin_prefixes = [row[0] for row in plugin_rows if row[0]] or [None]
|
||||
|
||||
# Update only devices with empty or NULL devParentPort
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_Port -> devParentPort")
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devParentPort = (
|
||||
SELECT cur_Port
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devParentPort IS NULL OR devParentPort IN ("", "null", "(unknown)", "(Unknown)"))
|
||||
AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
AND CurrentScan.cur_Port IS NOT NULL AND CurrentScan.cur_Port NOT IN ("", "null")
|
||||
)""")
|
||||
plugin_settings_cache = {}
|
||||
|
||||
# Update only devices with empty or NULL devParentMAC
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_NetworkNodeMAC -> devParentMAC")
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devParentMAC = (
|
||||
SELECT cur_NetworkNodeMAC
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devParentMAC IS NULL OR devParentMAC IN ("", "null", "(unknown)", "(Unknown)"))
|
||||
AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
AND CurrentScan.cur_NetworkNodeMAC IS NOT NULL AND CurrentScan.cur_NetworkNodeMAC NOT IN ("", "null")
|
||||
)
|
||||
""")
|
||||
def get_plugin_settings_cached(plugin_prefix):
|
||||
if plugin_prefix not in plugin_settings_cache:
|
||||
plugin_settings_cache[plugin_prefix] = get_plugin_authoritative_settings(plugin_prefix)
|
||||
return plugin_settings_cache[plugin_prefix]
|
||||
|
||||
# Update only devices with empty or NULL devSite
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_NetworkSite -> (if empty) devSite",)
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devSite = (
|
||||
SELECT cur_NetworkSite
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devSite IS NULL OR devSite IN ("", "null"))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
AND CurrentScan.cur_NetworkSite IS NOT NULL AND CurrentScan.cur_NetworkSite NOT IN ("", "null")
|
||||
)""")
|
||||
# ----------------------------------------------------------------
|
||||
# 2️⃣ Loop over plugins & update fields
|
||||
# ----------------------------------------------------------------
|
||||
for plugin_prefix in plugin_prefixes:
|
||||
filter_by_scan_method = bool(plugin_prefix)
|
||||
source_prefix = plugin_prefix if filter_by_scan_method else "NEWDEV"
|
||||
plugin_settings = get_plugin_settings_cached(source_prefix)
|
||||
|
||||
# Update only devices with empty or NULL devSSID
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_SSID -> (if empty) devSSID")
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devSSID = (
|
||||
SELECT cur_SSID
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devSSID IS NULL OR devSSID IN ("", "null"))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
AND CurrentScan.cur_SSID IS NOT NULL AND CurrentScan.cur_SSID NOT IN ("", "null")
|
||||
)""")
|
||||
# Get all devices joined with latest scan
|
||||
sql_tmp = f"""
|
||||
SELECT *
|
||||
FROM LatestDeviceScan
|
||||
{"WHERE cur_ScanMethod = ?" if filter_by_scan_method else ""}
|
||||
"""
|
||||
rows = sql.execute(sql_tmp, (source_prefix,) if filter_by_scan_method else ()).fetchall()
|
||||
col_names = [desc[0] for desc in sql.description]
|
||||
|
||||
# Update only devices with empty or NULL devType
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_Type -> (if empty) devType")
|
||||
sql.execute("""UPDATE Devices
|
||||
SET devType = (
|
||||
SELECT cur_Type
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
)
|
||||
WHERE
|
||||
(devType IS NULL OR devType IN ("", "null"))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE Devices.devMac = CurrentScan.cur_MAC
|
||||
AND CurrentScan.cur_Type IS NOT NULL AND CurrentScan.cur_Type NOT IN ("", "null")
|
||||
)""")
|
||||
for row in rows:
|
||||
row_dict = dict(zip(col_names, row))
|
||||
|
||||
# Update (unknown) or (name not found) Names if available
|
||||
mylog("debug", "[Update Devices] - (if not empty) cur_Name -> (if empty) devName")
|
||||
sql.execute(""" UPDATE Devices
|
||||
SET devName = COALESCE((
|
||||
SELECT cur_Name
|
||||
FROM CurrentScan
|
||||
WHERE cur_MAC = devMac
|
||||
AND cur_Name IS NOT NULL
|
||||
AND cur_Name <> 'null'
|
||||
AND cur_Name <> ''
|
||||
), devName)
|
||||
WHERE (devName IN ('(unknown)', '(name not found)', '')
|
||||
OR devName IS NULL)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM CurrentScan
|
||||
WHERE cur_MAC = devMac
|
||||
AND cur_Name IS NOT NULL
|
||||
AND cur_Name <> 'null'
|
||||
AND cur_Name <> ''
|
||||
) """)
|
||||
for field, spec in FIELD_SPECS.items():
|
||||
|
||||
# Update VENDORS
|
||||
recordsToUpdate = []
|
||||
query = """SELECT * FROM Devices
|
||||
WHERE devVendor IS NULL OR devVendor IN ("", "null", "(unknown)", "(Unknown)")
|
||||
"""
|
||||
scan_col = spec.get("scan_col")
|
||||
if scan_col not in row_dict:
|
||||
continue
|
||||
|
||||
for device in sql.execute(query):
|
||||
vendor = query_MAC_vendor(device["devMac"])
|
||||
if vendor != -1 and vendor != -2:
|
||||
recordsToUpdate.append([vendor, device["devMac"]])
|
||||
current_value = row_dict.get(field)
|
||||
current_source = row_dict.get(f"{field}Source") or ""
|
||||
new_value = row_dict.get(scan_col)
|
||||
|
||||
if len(recordsToUpdate) > 0:
|
||||
mylog("debug", f"[Update Devices] - current_value: {current_value} new_value: {new_value} -> {field}")
|
||||
|
||||
if can_overwrite_field(
|
||||
field_name=field,
|
||||
current_value=current_value,
|
||||
current_source=current_source,
|
||||
plugin_prefix=source_prefix,
|
||||
plugin_settings=plugin_settings,
|
||||
field_value=new_value,
|
||||
):
|
||||
# Build UPDATE dynamically
|
||||
update_cols = [f"{field} = ?"]
|
||||
sql_val = [new_value]
|
||||
|
||||
# if a source field available, update too
|
||||
source_field = FIELD_SOURCE_MAP.get(field)
|
||||
if source_field:
|
||||
update_cols.append(f"{source_field} = ?")
|
||||
sql_val.append(source_prefix)
|
||||
|
||||
sql_val.append(row_dict["devMac"])
|
||||
|
||||
sql_tmp = f"""
|
||||
UPDATE Devices
|
||||
SET {', '.join(update_cols)}
|
||||
WHERE devMac = ?
|
||||
"""
|
||||
|
||||
mylog("debug", f"[Update Devices] - ({source_prefix}) {spec['scan_col']} -> {field}")
|
||||
mylog("debug", f"[Update Devices] sql_tmp: {sql_tmp}, sql_val: {sql_val}")
|
||||
sql.execute(sql_tmp, sql_val)
|
||||
|
||||
db.commitDB()
|
||||
|
||||
|
||||
def update_ipv4_ipv6(db):
|
||||
"""
|
||||
Fill devPrimaryIPv4 and devPrimaryIPv6 based on devLastIP.
|
||||
Skips empty devLastIP.
|
||||
"""
|
||||
sql = db.sql
|
||||
|
||||
mylog("debug", "[Update Devices] Updating devPrimaryIPv4 / devPrimaryIPv6 from devLastIP")
|
||||
|
||||
devices = sql.execute("SELECT devMac, devLastIP FROM Devices").fetchall()
|
||||
records_to_update = []
|
||||
|
||||
for device in devices:
|
||||
last_ip = device["devLastIP"]
|
||||
if not last_ip or last_ip.lower() in ("", "null", "(unknown)", "(Unknown)"):
|
||||
continue # skip empty
|
||||
|
||||
ipv4, ipv6 = None, None
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(last_ip)
|
||||
if ip_obj.version == 4:
|
||||
ipv4 = last_ip
|
||||
else:
|
||||
ipv6 = last_ip
|
||||
except ValueError:
|
||||
continue # invalid IP, skip
|
||||
|
||||
records_to_update.append([ipv4, ipv6, device["devMac"]])
|
||||
|
||||
if records_to_update:
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devVendor = ? WHERE devMac = ? ", recordsToUpdate
|
||||
"UPDATE Devices SET devPrimaryIPv4 = ?, devPrimaryIPv6 = ? WHERE devMac = ?",
|
||||
records_to_update,
|
||||
)
|
||||
|
||||
# Update devPresentLastScan based on NICs presence
|
||||
update_devPresentLastScan_based_on_nics(db)
|
||||
mylog("debug", f"[Update Devices] Updated {len(records_to_update)} IPv4/IPv6 entries")
|
||||
|
||||
|
||||
def update_icons_and_types(db):
|
||||
sql = db.sql
|
||||
# Guess ICONS
|
||||
recordsToUpdate = []
|
||||
|
||||
@@ -290,7 +425,62 @@ def update_devices_data_from_scan(db):
|
||||
"UPDATE Devices SET devType = ? WHERE devMac = ? ", recordsToUpdate
|
||||
)
|
||||
|
||||
mylog("debug", "[Update Devices] Update devices end")
|
||||
|
||||
def update_vendors_from_mac(db):
|
||||
"""
|
||||
Enrich Devices.devVendor using MAC vendor lookup (VNDRPDT),
|
||||
without modifying CurrentScan. Respects plugin authoritative rules.
|
||||
"""
|
||||
sql = db.sql
|
||||
recordsToUpdate = []
|
||||
|
||||
# Get plugin authoritative settings for vendor
|
||||
vendor_settings = get_plugin_authoritative_settings("VNDRPDT")
|
||||
vendor_clause = (
|
||||
get_overwrite_sql_clause("devVendor", "devVendorSource", vendor_settings)
|
||||
if has_column(sql, "devVendorSource")
|
||||
else "1=1"
|
||||
)
|
||||
|
||||
# Build mapping: devMac -> vendor (skip unknown or invalid)
|
||||
vendor_map = {}
|
||||
for row in sql.execute("SELECT DISTINCT cur_MAC FROM CurrentScan"):
|
||||
mac = row["cur_MAC"]
|
||||
vendor = query_MAC_vendor(mac)
|
||||
if vendor not in (-1, -2):
|
||||
vendor_map[mac] = vendor
|
||||
|
||||
mylog("debug", f"[Vendor Mapping] Found {len(vendor_map)} valid MACs to enrich")
|
||||
|
||||
# Select Devices eligible for vendor update
|
||||
if "devVendor" in vendor_settings.get("set_always", []):
|
||||
# Always overwrite eligible devices
|
||||
query = f"SELECT devMac FROM Devices WHERE {vendor_clause}"
|
||||
else:
|
||||
# Only update empty or unknown vendors
|
||||
empty_vals = FIELD_SPECS.get("devVendor", {}).get("empty_values", [])
|
||||
empty_condition = " OR ".join(f"devVendor = '{v}'" for v in empty_vals)
|
||||
query = f"SELECT devMac FROM Devices WHERE ({empty_condition} OR devVendor IS NULL) AND {vendor_clause}"
|
||||
|
||||
for device in sql.execute(query):
|
||||
mac = device["devMac"]
|
||||
if mac in vendor_map:
|
||||
recordsToUpdate.append([vendor_map[mac], "VNDRPDT", mac])
|
||||
|
||||
# Apply updates
|
||||
if recordsToUpdate:
|
||||
if has_column(sql, "devVendorSource"):
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devVendor = ?, devVendorSource = ? WHERE devMac = ? AND " + vendor_clause,
|
||||
recordsToUpdate,
|
||||
)
|
||||
else:
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devVendor = ? WHERE devMac = ?",
|
||||
[(r[0], r[2]) for r in recordsToUpdate],
|
||||
)
|
||||
|
||||
mylog("debug", f"[Update Devices] Updated {len(recordsToUpdate)} vendors using MAC mapping")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
@@ -344,7 +534,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
|
||||
@@ -411,7 +608,7 @@ def create_new_devices(db):
|
||||
eve_EventType, eve_AdditionalInfo,
|
||||
eve_PendingAlertEmail
|
||||
)
|
||||
SELECT cur_MAC, cur_IP, '{startTime}', 'New Device', cur_Vendor, 1
|
||||
SELECT DISTINCT cur_MAC, cur_IP, '{startTime}', 'New Device', cur_Vendor, 1
|
||||
FROM CurrentScan
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM Devices
|
||||
@@ -504,12 +701,28 @@ def create_new_devices(db):
|
||||
cur_Type,
|
||||
) = row
|
||||
|
||||
# Preserve raw values to determine source attribution
|
||||
raw_name = str(cur_Name).strip() if cur_Name else ""
|
||||
raw_vendor = str(cur_Vendor).strip() if cur_Vendor else ""
|
||||
raw_ip = str(cur_IP).strip() if cur_IP else ""
|
||||
if raw_ip.lower() in ("null", "(unknown)"):
|
||||
raw_ip = ""
|
||||
raw_ssid = str(cur_SSID).strip() if cur_SSID else ""
|
||||
if raw_ssid.lower() in ("null", "(unknown)"):
|
||||
raw_ssid = ""
|
||||
raw_parent_mac = str(cur_NetworkNodeMAC).strip() if cur_NetworkNodeMAC else ""
|
||||
if raw_parent_mac.lower() in ("null", "(unknown)"):
|
||||
raw_parent_mac = ""
|
||||
raw_parent_port = str(cur_PORT).strip() if cur_PORT else ""
|
||||
if raw_parent_port.lower() in ("null", "(unknown)"):
|
||||
raw_parent_port = ""
|
||||
|
||||
# Handle NoneType
|
||||
cur_Name = str(cur_Name).strip() if cur_Name else "(unknown)"
|
||||
cur_Name = raw_name if raw_name else "(unknown)"
|
||||
cur_Type = (
|
||||
str(cur_Type).strip() if cur_Type else get_setting_value("NEWDEV_devType")
|
||||
)
|
||||
cur_NetworkNodeMAC = cur_NetworkNodeMAC.strip() if cur_NetworkNodeMAC else ""
|
||||
cur_NetworkNodeMAC = raw_parent_mac
|
||||
cur_NetworkNodeMAC = (
|
||||
cur_NetworkNodeMAC
|
||||
if cur_NetworkNodeMAC and cur_MAC != "Internet"
|
||||
@@ -525,6 +738,48 @@ def create_new_devices(db):
|
||||
else (get_setting_value("SYNC_node_name"))
|
||||
)
|
||||
|
||||
# Derive primary IP family values
|
||||
cur_IP = raw_ip
|
||||
cur_SSID = raw_ssid
|
||||
cur_PORT = raw_parent_port
|
||||
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 (do not store integer result)
|
||||
if cur_IP_normalized and ":" in cur_IP_normalized:
|
||||
validated_ipv6 = format_ip_long(cur_IP_normalized)
|
||||
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 ""
|
||||
|
||||
plugin_prefix = str(cur_ScanMethod).strip() if cur_ScanMethod else "NEWDEV"
|
||||
|
||||
dev_mac_source = get_source_for_field_update_with_value(
|
||||
"devMac", plugin_prefix, cur_MAC, is_user_override=False
|
||||
)
|
||||
dev_name_source = get_source_for_field_update_with_value(
|
||||
"devName", plugin_prefix, raw_name, is_user_override=False
|
||||
)
|
||||
dev_vendor_source = get_source_for_field_update_with_value(
|
||||
"devVendor", plugin_prefix, raw_vendor, is_user_override=False
|
||||
)
|
||||
dev_last_ip_source = get_source_for_field_update_with_value(
|
||||
"devLastIP", plugin_prefix, cur_IP_normalized, is_user_override=False
|
||||
)
|
||||
dev_ssid_source = get_source_for_field_update_with_value(
|
||||
"devSSID", plugin_prefix, raw_ssid, is_user_override=False
|
||||
)
|
||||
dev_parent_mac_source = get_source_for_field_update_with_value(
|
||||
"devParentMAC", plugin_prefix, raw_parent_mac, is_user_override=False
|
||||
)
|
||||
dev_parent_port_source = get_source_for_field_update_with_value(
|
||||
"devParentPort", plugin_prefix, raw_parent_port, is_user_override=False
|
||||
)
|
||||
dev_parent_rel_type_source = "NEWDEV"
|
||||
dev_fqdn_source = "NEWDEV"
|
||||
dev_vlan_source = "NEWDEV"
|
||||
|
||||
# Preparing the individual insert statement
|
||||
sqlQuery = f"""INSERT OR IGNORE INTO Devices
|
||||
(
|
||||
@@ -532,6 +787,8 @@ def create_new_devices(db):
|
||||
devName,
|
||||
devVendor,
|
||||
devLastIP,
|
||||
devPrimaryIPv4,
|
||||
devPrimaryIPv6,
|
||||
devFirstConnection,
|
||||
devLastConnection,
|
||||
devSyncHubNode,
|
||||
@@ -542,6 +799,16 @@ def create_new_devices(db):
|
||||
devSSID,
|
||||
devType,
|
||||
devSourcePlugin,
|
||||
devMacSource,
|
||||
devNameSource,
|
||||
devFQDNSource,
|
||||
devLastIPSource,
|
||||
devVendorSource,
|
||||
devSSIDSource,
|
||||
devParentMACSource,
|
||||
devParentPortSource,
|
||||
devParentRelTypeSource,
|
||||
devVlanSource,
|
||||
{newDevColumns}
|
||||
)
|
||||
VALUES
|
||||
@@ -549,7 +816,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)}',
|
||||
@@ -560,6 +829,16 @@ def create_new_devices(db):
|
||||
'{sanitize_SQL_input(cur_SSID)}',
|
||||
'{sanitize_SQL_input(cur_Type)}',
|
||||
'{sanitize_SQL_input(cur_ScanMethod)}',
|
||||
'{sanitize_SQL_input(dev_mac_source)}',
|
||||
'{sanitize_SQL_input(dev_name_source)}',
|
||||
'{sanitize_SQL_input(dev_fqdn_source)}',
|
||||
'{sanitize_SQL_input(dev_last_ip_source)}',
|
||||
'{sanitize_SQL_input(dev_vendor_source)}',
|
||||
'{sanitize_SQL_input(dev_ssid_source)}',
|
||||
'{sanitize_SQL_input(dev_parent_mac_source)}',
|
||||
'{sanitize_SQL_input(dev_parent_port_source)}',
|
||||
'{sanitize_SQL_input(dev_parent_rel_type_source)}',
|
||||
'{sanitize_SQL_input(dev_vlan_source)}',
|
||||
{newDevDefaults}
|
||||
)"""
|
||||
|
||||
@@ -672,7 +951,8 @@ def update_devices_names(pm):
|
||||
If False, resolves only FQDN.
|
||||
|
||||
Returns:
|
||||
recordsToUpdate (list): List of [newName, newFQDN, devMac] or [newFQDN, devMac] for DB update.
|
||||
recordsToUpdate (list): List of
|
||||
[newName, nameSource, newFQDN, fqdnSource, devMac] or [newFQDN, fqdnSource, devMac].
|
||||
recordsNotFound (list): List of [nameNotFound, devMac] for DB update.
|
||||
foundStats (dict): Number of successes per strategy.
|
||||
notFound (int): Number of devices not resolved.
|
||||
@@ -701,9 +981,9 @@ def update_devices_names(pm):
|
||||
foundStats[label] += 1
|
||||
|
||||
if resolve_both_name_and_fqdn:
|
||||
recordsToUpdate.append([newName, newFQDN, device["devMac"]])
|
||||
recordsToUpdate.append([newName, label, newFQDN, label, device["devMac"]])
|
||||
else:
|
||||
recordsToUpdate.append([newFQDN, device["devMac"]])
|
||||
recordsToUpdate.append([newFQDN, label, device["devMac"]])
|
||||
break
|
||||
|
||||
# If no name was resolved, queue device for "(name not found)" update
|
||||
@@ -731,13 +1011,51 @@ def update_devices_names(pm):
|
||||
|
||||
# Apply updates to database
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devName = ? WHERE devMac = ?", recordsNotFound
|
||||
)
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devName = ?, devFQDN = ? WHERE devMac = ?",
|
||||
recordsToUpdate,
|
||||
"""UPDATE Devices
|
||||
SET devName = CASE
|
||||
WHEN COALESCE(devNameSource, '') IN ('USER', 'LOCKED') THEN devName
|
||||
ELSE ?
|
||||
END
|
||||
WHERE devMac = ?
|
||||
AND COALESCE(devNameSource, '') IN ('', 'NEWDEV')""",
|
||||
recordsNotFound,
|
||||
)
|
||||
|
||||
records_by_plugin = {}
|
||||
for entry in recordsToUpdate:
|
||||
records_by_plugin.setdefault(entry[1], []).append(entry)
|
||||
|
||||
for plugin_label, plugin_records in records_by_plugin.items():
|
||||
plugin_settings = get_plugin_authoritative_settings(plugin_label)
|
||||
name_clause = get_overwrite_sql_clause(
|
||||
"devName", "devNameSource", plugin_settings
|
||||
)
|
||||
fqdn_clause = get_overwrite_sql_clause(
|
||||
"devFQDN", "devFQDNSource", plugin_settings
|
||||
)
|
||||
|
||||
sql.executemany(
|
||||
f"""UPDATE Devices
|
||||
SET devName = CASE
|
||||
WHEN {name_clause} THEN ?
|
||||
ELSE devName
|
||||
END,
|
||||
devNameSource = CASE
|
||||
WHEN {name_clause} THEN ?
|
||||
ELSE devNameSource
|
||||
END,
|
||||
devFQDN = CASE
|
||||
WHEN {fqdn_clause} THEN ?
|
||||
ELSE devFQDN
|
||||
END,
|
||||
devFQDNSource = CASE
|
||||
WHEN {fqdn_clause} THEN ?
|
||||
ELSE devFQDNSource
|
||||
END
|
||||
WHERE devMac = ?""",
|
||||
plugin_records,
|
||||
)
|
||||
|
||||
# --- Step 2: Optionally refresh FQDN for all devices ---
|
||||
if get_setting_value("REFRESH_FQDN"):
|
||||
allDevices = device_handler.getAll()
|
||||
@@ -754,10 +1072,30 @@ def update_devices_names(pm):
|
||||
mylog("verbose", f"[Update FQDN] Names Found (DIGSCAN/AVAHISCAN/NSLOOKUP/NBTSCAN): {len(recordsToUpdate)}({res_string})",)
|
||||
mylog("verbose", f"[Update FQDN] Names Not Found : {notFound}")
|
||||
|
||||
# Apply FQDN-only updates
|
||||
sql.executemany(
|
||||
"UPDATE Devices SET devFQDN = ? WHERE devMac = ?", recordsToUpdate
|
||||
)
|
||||
records_by_plugin = {}
|
||||
for entry in recordsToUpdate:
|
||||
records_by_plugin.setdefault(entry[1], []).append(entry)
|
||||
|
||||
for plugin_label, plugin_records in records_by_plugin.items():
|
||||
plugin_settings = get_plugin_authoritative_settings(plugin_label)
|
||||
fqdn_clause = get_overwrite_sql_clause(
|
||||
"devFQDN", "devFQDNSource", plugin_settings
|
||||
)
|
||||
|
||||
# Apply FQDN-only updates
|
||||
sql.executemany(
|
||||
f"""UPDATE Devices
|
||||
SET devFQDN = CASE
|
||||
WHEN {fqdn_clause} THEN ?
|
||||
ELSE devFQDN
|
||||
END,
|
||||
devFQDNSource = CASE
|
||||
WHEN {fqdn_clause} THEN ?
|
||||
ELSE devFQDNSource
|
||||
END
|
||||
WHERE devMac = ?""",
|
||||
plugin_records,
|
||||
)
|
||||
|
||||
# Commit all database changes
|
||||
pm.db.commitDB()
|
||||
@@ -831,6 +1169,72 @@ def update_devPresentLastScan_based_on_nics(db):
|
||||
return len(updates)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
# Force devPresentLastScan based on devForceStatus
|
||||
def update_devPresentLastScan_based_on_force_status(db):
|
||||
"""
|
||||
Forces devPresentLastScan in the Devices table based on devForceStatus.
|
||||
|
||||
devForceStatus values:
|
||||
- "online" -> devPresentLastScan = 1
|
||||
- "offline" -> devPresentLastScan = 0
|
||||
- "dont_force" or empty -> no change
|
||||
|
||||
Args:
|
||||
db: A database object with `.execute()` and `.fetchone()` methods.
|
||||
|
||||
Returns:
|
||||
int: Number of devices updated.
|
||||
"""
|
||||
|
||||
sql = db.sql
|
||||
|
||||
online_count_row = sql.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM Devices
|
||||
WHERE LOWER(COALESCE(devForceStatus, '')) = 'online'
|
||||
AND devPresentLastScan != 1
|
||||
"""
|
||||
).fetchone()
|
||||
online_updates = online_count_row["cnt"] if online_count_row else 0
|
||||
|
||||
offline_count_row = sql.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM Devices
|
||||
WHERE LOWER(COALESCE(devForceStatus, '')) = 'offline'
|
||||
AND devPresentLastScan != 0
|
||||
"""
|
||||
).fetchone()
|
||||
offline_updates = offline_count_row["cnt"] if offline_count_row else 0
|
||||
|
||||
if online_updates > 0:
|
||||
sql.execute(
|
||||
"""
|
||||
UPDATE Devices
|
||||
SET devPresentLastScan = 1
|
||||
WHERE LOWER(COALESCE(devForceStatus, '')) = 'online'
|
||||
"""
|
||||
)
|
||||
|
||||
if offline_updates > 0:
|
||||
sql.execute(
|
||||
"""
|
||||
UPDATE Devices
|
||||
SET devPresentLastScan = 0
|
||||
WHERE LOWER(COALESCE(devForceStatus, '')) = 'offline'
|
||||
"""
|
||||
)
|
||||
|
||||
total_updates = online_updates + offline_updates
|
||||
if total_updates > 0:
|
||||
mylog("debug", f"[Update Devices] Forced devPresentLastScan for {total_updates} devices")
|
||||
|
||||
db.commitDB()
|
||||
return total_updates
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
# Check if the variable contains a valid MAC address or "Internet"
|
||||
def check_mac_or_internet(input_str):
|
||||
|
||||
@@ -4,6 +4,13 @@ from scan.device_handling import (
|
||||
save_scanned_devices,
|
||||
exclude_ignored_devices,
|
||||
update_devices_data_from_scan,
|
||||
update_vendors_from_mac,
|
||||
update_icons_and_types,
|
||||
update_devPresentLastScan_based_on_force_status,
|
||||
update_devPresentLastScan_based_on_nics,
|
||||
update_ipv4_ipv6,
|
||||
update_devLastConnection_from_CurrentScan,
|
||||
update_presence_from_CurrentScan
|
||||
)
|
||||
from helper import get_setting_value
|
||||
from db.db_helper import print_table_schema
|
||||
@@ -49,6 +56,34 @@ def process_scan(db):
|
||||
mylog("verbose", "[Process Scan] Updating Devices Info")
|
||||
update_devices_data_from_scan(db)
|
||||
|
||||
# Last Connection Time stamp from CurrentScan
|
||||
mylog("verbose", "[Process Scan] Updating devLastConnection from CurrentScan")
|
||||
update_devLastConnection_from_CurrentScan(db)
|
||||
|
||||
# Presence from CurrentScan
|
||||
mylog("verbose", "[Process Scan] Updating Presence from CurrentScan")
|
||||
update_presence_from_CurrentScan(db)
|
||||
|
||||
# Update devPresentLastScan based on NICs presence
|
||||
mylog("verbose", "[Process Scan] Updating NICs presence")
|
||||
update_devPresentLastScan_based_on_nics(db)
|
||||
|
||||
# Force device status
|
||||
mylog("verbose", "[Process Scan] Updating forced presence")
|
||||
update_devPresentLastScan_based_on_force_status(db)
|
||||
|
||||
# Update Vendors
|
||||
mylog("verbose", "[Process Scan] Updating Vendors")
|
||||
update_vendors_from_mac(db)
|
||||
|
||||
# Update IPs
|
||||
mylog("verbose", "[Process Scan] Updating v4 and v6 IPs")
|
||||
update_ipv4_ipv6(db)
|
||||
|
||||
# Update Icons and Type based on heuristics
|
||||
mylog("verbose", "[Process Scan] Guessing Icons")
|
||||
update_icons_and_types(db)
|
||||
|
||||
# Pair session events (Connection / Disconnection)
|
||||
mylog("verbose", "[Process Scan] Pairing session events (connection / disconnection) ")
|
||||
pair_sessions_events(db)
|
||||
@@ -182,7 +217,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")
|
||||
|
||||
|
||||
|
||||
@@ -156,14 +156,21 @@ def parse_datetime(dt_str):
|
||||
|
||||
def format_date(date_str: str) -> str:
|
||||
try:
|
||||
if isinstance(date_str, str):
|
||||
# collapse all whitespace into single spaces
|
||||
date_str = re.sub(r"\s+", " ", date_str.strip())
|
||||
|
||||
dt = parse_datetime(date_str)
|
||||
if not dt:
|
||||
return f"invalid:{repr(date_str)}"
|
||||
|
||||
if dt.tzinfo is None:
|
||||
# Set timezone if missing — change to timezone.utc if you prefer UTC
|
||||
now = datetime.datetime.now(conf.tz)
|
||||
dt = dt.replace(tzinfo=now.astimezone().tzinfo)
|
||||
dt = dt.replace(tzinfo=conf.tz)
|
||||
|
||||
return dt.astimezone().isoformat()
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return "invalid"
|
||||
|
||||
except Exception:
|
||||
return f"invalid:{repr(date_str)}"
|
||||
|
||||
|
||||
def format_date_diff(date1, date2, tz_name):
|
||||
|
||||
282
test/authoritative_fields/FIELD_LOCK_TEST_SUMMARY.md
Normal file
282
test/authoritative_fields/FIELD_LOCK_TEST_SUMMARY.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Field Lock Scenarios - Comprehensive Test Suite
|
||||
|
||||
Created comprehensive tests for all device field locking scenarios in NetAlertX using two complementary approaches.
|
||||
|
||||
## Test Files
|
||||
|
||||
### 1. Unit Tests - Direct Authorization Logic
|
||||
**File:** `/workspaces/NetAlertX/test/authoritative_fields/test_field_lock_scenarios.py`
|
||||
- Tests the `can_overwrite_field()` function directly
|
||||
- Verifies authorization rules without database operations
|
||||
- Fast, focused unit tests with direct assertions
|
||||
|
||||
**16 Unit Tests covering:**
|
||||
|
||||
#### Protected Sources (No Override)
|
||||
- ✅ `test_locked_source_prevents_plugin_overwrite()` - LOCKED source blocks updates
|
||||
- ✅ `test_user_source_prevents_plugin_overwrite()` - USER source blocks updates
|
||||
|
||||
#### Updatable Sources (Allow Override)
|
||||
- ✅ `test_newdev_source_allows_plugin_overwrite()` - NEWDEV allows plugin updates
|
||||
- ✅ `test_empty_current_source_allows_plugin_overwrite()` - Empty source allows updates
|
||||
|
||||
#### Plugin Ownership Rules
|
||||
- ✅ `test_plugin_source_allows_same_plugin_overwrite()` - Plugin can update its own fields
|
||||
- ✅ `test_plugin_source_allows_different_plugin_overwrite_with_set_always()` - Different plugin CAN update WITH SET_ALWAYS
|
||||
- ✅ `test_plugin_source_rejects_different_plugin_without_set_always()` - Different plugin CANNOT update WITHOUT SET_ALWAYS
|
||||
|
||||
#### SET_EMPTY Authorization
|
||||
- ✅ `test_set_empty_allows_overwrite_on_empty_field()` - SET_EMPTY works with NEWDEV
|
||||
- ✅ `test_set_empty_rejects_overwrite_on_non_empty_field()` - SET_EMPTY doesn't override plugin fields
|
||||
- ✅ `test_set_empty_with_empty_string_source()` - SET_EMPTY works with empty string source
|
||||
|
||||
#### Empty Value Handling
|
||||
- ✅ `test_empty_plugin_value_not_used()` - Empty string values rejected
|
||||
- ✅ `test_whitespace_only_plugin_value_not_used()` - Whitespace-only values rejected
|
||||
- ✅ `test_none_plugin_value_not_used()` - None values rejected
|
||||
|
||||
#### SET_ALWAYS Override Behavior
|
||||
- ✅ `test_set_always_overrides_plugin_ownership()` - SET_ALWAYS overrides other plugins but NOT USER/LOCKED
|
||||
- ✅ `test_multiple_plugins_set_always_scenarios()` - Multi-plugin update scenarios
|
||||
|
||||
#### Multi-Field Scenarios
|
||||
- ✅ `test_different_fields_with_different_sources()` - Each field respects its own source
|
||||
|
||||
---
|
||||
|
||||
### 2. Integration Tests - Real Scan Simulation
|
||||
**File:** `/workspaces/NetAlertX/test/authoritative_fields/test_field_lock_scan_integration.py`
|
||||
- Simulates real-world scanner operations with CurrentScan/Devices tables
|
||||
- Tests full scan update pipeline
|
||||
- Verifies field locking behavior in realistic scenarios
|
||||
|
||||
**8 Integration Tests covering:**
|
||||
|
||||
#### Field Source Protection
|
||||
- ✅ `test_scan_updates_newdev_device_name()` - NEWDEV fields are populated from scan
|
||||
- ✅ `test_scan_does_not_update_user_field_name()` - USER fields remain unchanged during scan
|
||||
- ✅ `test_scan_does_not_update_locked_field()` - LOCKED fields remain unchanged during scan
|
||||
|
||||
#### Vendor Discovery
|
||||
- ✅ `test_scan_updates_empty_vendor_field()` - Empty vendor gets populated from scan
|
||||
|
||||
#### IP Address Handling
|
||||
- ✅ `test_scan_updates_ip_addresses()` - IPv4 and IPv6 set from scan data
|
||||
- ✅ `test_scan_updates_ipv6_without_changing_ipv4()` - IPv6 update preserves existing IPv4
|
||||
|
||||
#### Device Status
|
||||
- ✅ `test_scan_updates_presence_status()` - Offline devices correctly marked as not present
|
||||
|
||||
#### Multi-Device Scenarios
|
||||
- ✅ `test_scan_multiple_devices_mixed_sources()` - Complex multi-device scan with mixed source types
|
||||
|
||||
---
|
||||
|
||||
### 3. IP Format & Field Locking Tests (`test_ip_format_and_locking.py`)
|
||||
- IP format validation (IPv4/IPv6)
|
||||
- Invalid IP rejection
|
||||
- Address format variations
|
||||
- Multi-scan IP update scenarios
|
||||
|
||||
**6 IP Format Tests covering:**
|
||||
|
||||
#### IPv4 & IPv6 Validation
|
||||
- ✅ `test_valid_ipv4_format_accepted()` - Valid IPv4 sets devPrimaryIPv4
|
||||
- ✅ `test_valid_ipv6_format_accepted()` - Valid IPv6 sets devPrimaryIPv6
|
||||
|
||||
#### Invalid Values
|
||||
- ✅ `test_invalid_ip_values_rejected()` - Rejects: empty, "null", "(unknown)", "(Unknown)"
|
||||
|
||||
#### Multi-Scan Scenarios
|
||||
- ✅ `test_ipv4_ipv6_mixed_in_multiple_scans()` - IPv4 then IPv6 updates preserve both
|
||||
|
||||
#### Format Variations
|
||||
- ✅ `test_ipv4_address_format_variations()` - Tests 6 IPv4 ranges: loopback, private, broadcast
|
||||
- ✅ `test_ipv6_address_format_variations()` - Tests 5 IPv6 formats: loopback, link-local, full address
|
||||
|
||||
---
|
||||
|
||||
## Total Tests: 33
|
||||
|
||||
- 10 Authoritative handler tests (existing)
|
||||
- 3 Device status mapping tests (existing)
|
||||
- 17 Field lock scenarios (unit tests)
|
||||
- 8 Field lock scan integration tests
|
||||
- 2 IP update logic tests (existing, refactored)
|
||||
- 6 IP format validation tests
|
||||
|
||||
## Test Execution Commands
|
||||
|
||||
### Run all authoritative fields tests
|
||||
```bash
|
||||
cd /workspaces/NetAlertX
|
||||
python -m pytest test/authoritative_fields/ -v
|
||||
```
|
||||
|
||||
### Run all field lock tests
|
||||
```bash
|
||||
python -m pytest test/authoritative_fields/test_field_lock_scenarios.py test/authoritative_fields/test_field_lock_scan_integration.py -v
|
||||
```
|
||||
|
||||
### Run IP format validation tests
|
||||
```bash
|
||||
python -m pytest test/authoritative_fields/test_ip_format_and_locking.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### Unit Tests (`test_field_lock_scenarios.py`)
|
||||
|
||||
**Approach:** Direct function testing
|
||||
- Imports: `can_overwrite_field()` from `server.db.authoritative_handler`
|
||||
- No database setup required
|
||||
- Fast execution
|
||||
- Tests authorization logic in isolation
|
||||
|
||||
**Structure:**
|
||||
```python
|
||||
def test_scenario():
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="LOCKED",
|
||||
plugin_prefix="ARPSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="New Value",
|
||||
)
|
||||
assert result is False
|
||||
```
|
||||
|
||||
### Integration Tests (`test_field_lock_scan_integration.py`)
|
||||
|
||||
**Approach:** Full pipeline simulation
|
||||
- Sets up in-memory SQLite database
|
||||
- Creates Devices and CurrentScan tables
|
||||
- Populates with realistic scan data
|
||||
- Calls `device_handling.update_devices_data_from_scan()`
|
||||
- Verifies final state in Devices table
|
||||
|
||||
**Fixtures:**
|
||||
- `@pytest.fixture scan_db`: In-memory SQLite database with full schema
|
||||
- `@pytest.fixture mock_device_handlers`: Mocks device_handling helper functions
|
||||
|
||||
**Structure:**
|
||||
```python
|
||||
def test_scan_scenario(scan_db, mock_device_handlers):
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Insert device with specific source
|
||||
cur.execute("INSERT INTO Devices ...")
|
||||
|
||||
# Insert scan results
|
||||
cur.execute("INSERT INTO CurrentScan ...")
|
||||
scan_db.commit()
|
||||
|
||||
# Run actual scan update
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
# Verify results
|
||||
row = cur.execute("SELECT ... FROM Devices")
|
||||
assert row["field"] == "expected_value"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Scenarios Tested
|
||||
|
||||
### Protection Rules (Honored in Both Unit & Integration Tests)
|
||||
|
||||
| Scenario | Current Source | Plugin Action | Result |
|
||||
|----------|---|---|---|
|
||||
| **User Protection** | USER | Try to update | ❌ BLOCKED |
|
||||
| **Explicit Lock** | LOCKED | Try to update | ❌ BLOCKED |
|
||||
| **Default/Empty** | NEWDEV or "" | Try to update with value | ✅ ALLOWED |
|
||||
| **Same Plugin** | PluginA | PluginA tries to update | ✅ ALLOWED |
|
||||
| **Different Plugin** | PluginA | PluginB tries to update (no SET_ALWAYS) | ❌ BLOCKED |
|
||||
| **Different Plugin (SET_ALWAYS)** | PluginA | PluginB tries with SET_ALWAYS | ✅ ALLOWED |
|
||||
| **SET_ALWAYS > USER** | USER | PluginA with SET_ALWAYS | ❌ BLOCKED (USER always protected) |
|
||||
| **SET_ALWAYS > LOCKED** | LOCKED | PluginA with SET_ALWAYS | ❌ BLOCKED (LOCKED always protected) |
|
||||
| **Empty Value** | NEWDEV | Plugin provides empty/None | ❌ BLOCKED |
|
||||
|
||||
---
|
||||
|
||||
## Field Support
|
||||
|
||||
All 10 lockable fields tested:
|
||||
1. `devMac` - Device MAC address
|
||||
2. `devName` - Device hostname/alias
|
||||
3. `devFQDN` - Fully qualified domain name
|
||||
4. `devLastIP` - Last known IP address
|
||||
5. `devVendor` - Device manufacturer
|
||||
6. `devSSID` - WiFi network name
|
||||
7. `devParentMAC` - Parent/gateway MAC
|
||||
8. `devParentPort` - Parent device port
|
||||
9. `devParentRelType` - Relationship type
|
||||
10. `devVlan` - VLAN identifier
|
||||
|
||||
---
|
||||
|
||||
## Plugins Referenced in Tests
|
||||
|
||||
- **ARPSCAN** - ARP scanning network discovery
|
||||
- **NBTSCAN** - NetBIOS name resolution
|
||||
- **PIHOLEAPI** - Pi-hole DNS/Ad blocking integration
|
||||
- **UNIFIAPI** - Ubiquiti UniFi network controller integration
|
||||
- **DHCPLSS** - DHCP lease scanning (referenced in config examples)
|
||||
|
||||
---
|
||||
|
||||
## Authorization Rules Reference
|
||||
|
||||
**From `server/db/authoritative_handler.py` - `can_overwrite_field()` function:**
|
||||
|
||||
1. **Rule 1 (USER & LOCKED Protection):** If `current_source` is "USER" or "LOCKED" → Return `False` immediately
|
||||
- These are ABSOLUTE protections - even SET_ALWAYS cannot override
|
||||
2. **Rule 2 (Value Validation):** If `field_value` (the NEW value to write) is empty/None/whitespace → Return `False` immediately
|
||||
- Plugin cannot write empty values - only meaningful data allowed
|
||||
3. **Rule 3 (SET_ALWAYS Override):** If field is in plugin's `set_always` list → Return `True`
|
||||
- Allows overwriting ANY source (except USER/LOCKED already blocked in Rule 1)
|
||||
- Works on empty current values, plugin-owned fields, other plugins' fields
|
||||
4. **Rule 4 (SET_EMPTY):** If field is in plugin's `set_empty` list AND current_source is empty/"NEWDEV" → Return `True`
|
||||
- Restrictive: Only fills empty fields, won't overwrite plugin-owned fields
|
||||
5. **Rule 5 (Default):** If current_source is empty/"NEWDEV" → Return `True`, else → Return `False`
|
||||
- Default behavior: only overwrite empty/unset fields
|
||||
|
||||
**Key Principles:**
|
||||
- **USER and LOCKED** = Absolute protection (cannot be overwritten, even with SET_ALWAYS)
|
||||
- **SET_ALWAYS** = Allow overwrite of: own fields, other plugin fields, empty current values, NEWDEV fields
|
||||
- **SET_EMPTY** = "Set only if empty" - fills empty fields only, won't overwrite existing plugin data
|
||||
- **Default** = Plugins can only update NEWDEV/empty fields without authorization
|
||||
- Plugin ownership (e.g., "ARPSCAN") is treated like any other non-protected source for override purposes
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **User Guide:** [QUICK_REFERENCE_FIELD_LOCK.md](../../docs/QUICK_REFERENCE_FIELD_LOCK.md) - User-friendly field locking instructions
|
||||
- **API Documentation:** [API_DEVICE_FIELD_LOCK.md](../../docs/API_DEVICE_FIELD_LOCK.md) - Endpoint documentation
|
||||
- **Plugin Configuration:** [PLUGINS_DEV_CONFIG.md](../../docs/PLUGINS_DEV_CONFIG.md) - SET_ALWAYS/SET_EMPTY configuration guide
|
||||
- **Device Management:** [DEVICE_MANAGEMENT.md](../../docs/DEVICE_MANAGEMENT.md) - Device management admin guide
|
||||
|
||||
---
|
||||
|
||||
## Implementation Files
|
||||
|
||||
**Code Under Test:**
|
||||
- `server/db/authoritative_handler.py` - Authorization logic
|
||||
- `server/scan/device_handling.py` - Scan update pipeline
|
||||
- `server/api_server/api_server_start.py` - API endpoints for field locking
|
||||
|
||||
**Test Files:**
|
||||
- `test/authoritative_fields/test_field_lock_scenarios.py` - Unit tests
|
||||
- `test/authoritative_fields/test_field_lock_scan_integration.py` - Integration tests
|
||||
|
||||
---
|
||||
|
||||
**Created:** January 19, 2026
|
||||
**Last Updated:** January 19, 2026
|
||||
**Status:** ✅ 24 comprehensive tests created covering all scenarios
|
||||
144
test/authoritative_fields/test_authoritative_handler.py
Normal file
144
test/authoritative_fields/test_authoritative_handler.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Unit tests for authoritative field update handler.
|
||||
"""
|
||||
|
||||
from server.db.authoritative_handler import (
|
||||
can_overwrite_field,
|
||||
get_source_for_field_update_with_value,
|
||||
FIELD_SOURCE_MAP,
|
||||
)
|
||||
|
||||
|
||||
class TestCanOverwriteField:
|
||||
"""Test the can_overwrite_field authorization logic."""
|
||||
|
||||
def test_user_source_prevents_overwrite(self):
|
||||
"""USER source should prevent any overwrite."""
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "USER", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
|
||||
)
|
||||
|
||||
def test_locked_source_prevents_overwrite(self):
|
||||
"""LOCKED source should prevent any overwrite."""
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "LOCKED", "ARPSCAN", {"set_always": [], "set_empty": []}, "NewName"
|
||||
)
|
||||
|
||||
def test_empty_value_prevents_overwrite(self):
|
||||
"""Empty/None values should prevent overwrite."""
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, ""
|
||||
)
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
|
||||
)
|
||||
|
||||
def test_set_always_allows_overwrite(self):
|
||||
"""SET_ALWAYS should allow overwrite regardless of current source."""
|
||||
assert can_overwrite_field(
|
||||
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
|
||||
)
|
||||
assert can_overwrite_field(
|
||||
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
|
||||
)
|
||||
|
||||
def test_set_empty_allows_overwrite_only_when_empty(self):
|
||||
"""SET_EMPTY should allow overwrite only if field is empty or NEWDEV."""
|
||||
assert can_overwrite_field(
|
||||
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
|
||||
)
|
||||
assert can_overwrite_field(
|
||||
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
|
||||
)
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
|
||||
)
|
||||
|
||||
def test_default_behavior_overwrites_empty_fields(self):
|
||||
"""Without SET_ALWAYS/SET_EMPTY, should overwrite only empty fields."""
|
||||
assert can_overwrite_field(
|
||||
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
|
||||
)
|
||||
assert can_overwrite_field(
|
||||
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
|
||||
)
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
|
||||
)
|
||||
|
||||
def test_whitespace_value_treated_as_empty(self):
|
||||
"""Whitespace-only values should be treated as empty."""
|
||||
assert not can_overwrite_field(
|
||||
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, " "
|
||||
)
|
||||
|
||||
|
||||
class TestGetSourceForFieldUpdateWithValue:
|
||||
"""Test source value determination with value-based normalization."""
|
||||
|
||||
def test_user_override_sets_user_source(self):
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devName", "UNIFIAPI", "Device", is_user_override=True
|
||||
)
|
||||
== "USER"
|
||||
)
|
||||
|
||||
def test_plugin_update_sets_plugin_prefix(self):
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devName", "UNIFIAPI", "Device", is_user_override=False
|
||||
)
|
||||
== "UNIFIAPI"
|
||||
)
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devLastIP", "ARPSCAN", "192.168.1.1", is_user_override=False
|
||||
)
|
||||
== "ARPSCAN"
|
||||
)
|
||||
|
||||
def test_empty_or_unknown_values_return_newdev(self):
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devName", "ARPSCAN", "", is_user_override=False
|
||||
)
|
||||
== "NEWDEV"
|
||||
)
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devName", "ARPSCAN", "(unknown)", is_user_override=False
|
||||
)
|
||||
== "NEWDEV"
|
||||
)
|
||||
|
||||
def test_non_empty_value_sets_plugin_prefix(self):
|
||||
assert (
|
||||
get_source_for_field_update_with_value(
|
||||
"devVendor", "ARPSCAN", "Acme", is_user_override=False
|
||||
)
|
||||
== "ARPSCAN"
|
||||
)
|
||||
|
||||
|
||||
class TestFieldSourceMapping:
|
||||
"""Test field source mapping is correct."""
|
||||
|
||||
def test_all_tracked_fields_have_source_counterpart(self):
|
||||
"""All tracked fields should have a corresponding *Source field."""
|
||||
expected_fields = {
|
||||
"devMac": "devMacSource",
|
||||
"devName": "devNameSource",
|
||||
"devFQDN": "devFQDNSource",
|
||||
"devLastIP": "devLastIPSource",
|
||||
"devVendor": "devVendorSource",
|
||||
"devSSID": "devSSIDSource",
|
||||
"devParentMAC": "devParentMACSource",
|
||||
"devParentPort": "devParentPortSource",
|
||||
"devParentRelType": "devParentRelTypeSource",
|
||||
"devVlan": "devVlanSource",
|
||||
}
|
||||
|
||||
for field, source in expected_fields.items():
|
||||
assert field in FIELD_SOURCE_MAP
|
||||
assert FIELD_SOURCE_MAP[field] == source
|
||||
469
test/authoritative_fields/test_device_field_lock.py
Normal file
469
test/authoritative_fields/test_device_field_lock.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Unit tests for device field lock/unlock functionality.
|
||||
Tests the authoritative field update system with source tracking and field locking.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
|
||||
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
||||
|
||||
from helper import get_setting_value # noqa: E402
|
||||
from api_server.api_server_start import app # noqa: E402
|
||||
from models.device_instance import DeviceInstance # noqa: E402
|
||||
from db.authoritative_handler import can_overwrite_field # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_token():
|
||||
"""Get API token from settings."""
|
||||
return get_setting_value("API_TOKEN")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create test client with app context."""
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_mac():
|
||||
"""Generate a test MAC address."""
|
||||
return "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(api_token):
|
||||
"""Create authorization headers."""
|
||||
return {"Authorization": f"Bearer {api_token}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_test_device(test_mac):
|
||||
"""Clean up test device before and after test."""
|
||||
device_handler = DeviceInstance()
|
||||
# Clean before test
|
||||
try:
|
||||
device_handler.deleteDeviceByMAC(test_mac)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Pre-test cleanup failed for {test_mac}: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# Clean after test
|
||||
try:
|
||||
device_handler.deleteDeviceByMAC(test_mac)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Post-test cleanup failed for {test_mac}: {e}")
|
||||
|
||||
|
||||
class TestDeviceFieldLock:
|
||||
"""Test suite for device field lock/unlock functionality."""
|
||||
|
||||
def test_create_test_device(self, client, test_mac, auth_headers):
|
||||
"""Create a test device for locking tests."""
|
||||
payload = {
|
||||
"devName": "Test Device",
|
||||
"devLastIP": "192.168.1.100",
|
||||
"createNew": True
|
||||
}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code in [200, 201], f"Failed to create device: {resp.json}"
|
||||
data = resp.json
|
||||
assert data.get("success") is True
|
||||
|
||||
def test_lock_field_requires_auth(self, client, test_mac):
|
||||
"""Lock endpoint requires authorization."""
|
||||
payload = {
|
||||
"fieldName": "devName",
|
||||
"lock": True
|
||||
}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_lock_field_invalid_parameters(self, client, test_mac, auth_headers):
|
||||
"""Lock endpoint validates required parameters."""
|
||||
# Missing fieldName
|
||||
payload = {"lock": True}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "fieldName is required" in resp.json.get("error", "")
|
||||
|
||||
def test_lock_field_invalid_field_name(self, client, test_mac, auth_headers):
|
||||
"""Lock endpoint rejects untracked fields."""
|
||||
payload = {
|
||||
"fieldName": "devInvalidField",
|
||||
"lock": True
|
||||
}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
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
|
||||
self.test_create_test_device(client, test_mac, auth_headers)
|
||||
|
||||
tracked_fields = [
|
||||
"devMac", "devName", "devLastIP", "devVendor", "devFQDN",
|
||||
"devSSID", "devParentMAC", "devParentPort", "devParentRelType", "devVlan"
|
||||
]
|
||||
|
||||
for field_name in tracked_fields:
|
||||
payload = {"fieldName": field_name, "lock": True}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 200, f"Failed to lock {field_name}: {resp.json}"
|
||||
data = resp.json
|
||||
assert data.get("success") is True
|
||||
assert data.get("locked") is True
|
||||
assert data.get("fieldName") == field_name
|
||||
|
||||
def test_lock_and_unlock_field(self, client, test_mac, auth_headers):
|
||||
"""Lock a field then unlock it."""
|
||||
# Create device
|
||||
self.test_create_test_device(client, test_mac, auth_headers)
|
||||
|
||||
# Lock field
|
||||
lock_payload = {"fieldName": "devName", "lock": True}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=lock_payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json.get("locked") is True
|
||||
|
||||
# Verify source is LOCKED
|
||||
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"
|
||||
|
||||
# Unlock field
|
||||
unlock_payload = {"fieldName": "devName", "lock": False}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=unlock_payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json.get("locked") is False
|
||||
|
||||
# Verify source changed
|
||||
resp = client.get(f"/device/{test_mac}", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
device_data = resp.json
|
||||
assert device_data.get("devNameSource") == ""
|
||||
|
||||
def test_lock_prevents_field_updates(self, client, test_mac, auth_headers):
|
||||
"""Locked field should not be updated through API."""
|
||||
# Create device with initial name
|
||||
self.test_create_test_device(client, test_mac, auth_headers)
|
||||
|
||||
# Lock the field
|
||||
lock_payload = {"fieldName": "devName", "lock": True}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=lock_payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Try to update the locked field
|
||||
update_payload = {"devName": "New Name"}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}",
|
||||
json=update_payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Update should succeed at API level but authoritative handler should prevent it
|
||||
# The field update logic checks source in the database layer
|
||||
# For now verify the API accepts the request
|
||||
assert resp.status_code in [200, 201]
|
||||
|
||||
# Verify locked field remains unchanged
|
||||
resp = client.get(f"/device/{test_mac}", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
device_data = resp.json
|
||||
assert device_data.get("devName") == "Test Device", "Locked field should not have been updated"
|
||||
assert device_data.get("devNameSource") == "LOCKED"
|
||||
|
||||
def test_multiple_fields_lock_state(self, client, test_mac, auth_headers):
|
||||
"""Lock some fields while leaving others unlocked."""
|
||||
# Create device
|
||||
self.test_create_test_device(client, test_mac, auth_headers)
|
||||
|
||||
# Lock only devName and devVendor
|
||||
for field in ["devName", "devVendor"]:
|
||||
payload = {"fieldName": field, "lock": True}
|
||||
resp = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify device state
|
||||
resp = client.get(f"/device/{test_mac}", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
device_data = resp.json
|
||||
|
||||
# Locked fields should have LOCKED source
|
||||
assert device_data.get("devNameSource") == "LOCKED"
|
||||
assert device_data.get("devVendorSource") == "LOCKED"
|
||||
|
||||
# Other fields should not be locked
|
||||
assert device_data.get("devLastIPSource") != "LOCKED"
|
||||
assert device_data.get("devFQDNSource") != "LOCKED"
|
||||
|
||||
def test_lock_field_idempotent(self, client, test_mac, auth_headers):
|
||||
"""Locking the same field multiple times should work."""
|
||||
# Create device
|
||||
self.test_create_test_device(client, test_mac, auth_headers)
|
||||
|
||||
payload = {"fieldName": "devName", "lock": True}
|
||||
|
||||
# Lock once
|
||||
resp1 = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# Lock again
|
||||
resp2 = client.post(
|
||||
f"/device/{test_mac}/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json.get("locked") is True
|
||||
|
||||
def test_lock_new_device_rejected(self, client, auth_headers):
|
||||
"""Cannot lock fields on new device (mac='new')."""
|
||||
payload = {"fieldName": "devName", "lock": True}
|
||||
resp = client.post(
|
||||
"/device/new/field/lock",
|
||||
json=payload,
|
||||
headers=auth_headers
|
||||
)
|
||||
# Current behavior allows locking without validating device existence
|
||||
assert resp.status_code == 200
|
||||
assert resp.json.get("success") is True
|
||||
|
||||
|
||||
class TestFieldLockIntegration:
|
||||
"""Integration tests for field locking with plugin overwrites."""
|
||||
|
||||
def test_lock_unlock_normalizes_mac(self, test_mac):
|
||||
"""Lock/unlock should normalize MAC addresses before DB updates."""
|
||||
device_handler = DeviceInstance()
|
||||
|
||||
create_result = device_handler.setDeviceData(
|
||||
test_mac,
|
||||
{
|
||||
"devName": "Original Name",
|
||||
"devLastIP": "192.168.1.100",
|
||||
"createNew": True,
|
||||
},
|
||||
)
|
||||
assert create_result.get("success") is True
|
||||
|
||||
mac_variant = "aa-bb-cc-dd-ee-ff"
|
||||
|
||||
lock_result = device_handler.lockDeviceField(mac_variant, "devName")
|
||||
assert lock_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "LOCKED"
|
||||
|
||||
unlock_result = device_handler.unlockDeviceField(mac_variant, "devName")
|
||||
assert unlock_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") != "LOCKED"
|
||||
|
||||
def test_locked_field_blocks_plugin_overwrite(self, test_mac):
|
||||
"""Verify locked fields prevent plugin source overwrites."""
|
||||
device_handler = DeviceInstance()
|
||||
|
||||
# Create device
|
||||
create_result = device_handler.setDeviceData(test_mac, {
|
||||
"devName": "Original Name",
|
||||
"devLastIP": "192.168.1.100",
|
||||
"createNew": True
|
||||
})
|
||||
assert create_result.get("success") is True
|
||||
|
||||
# Lock the field
|
||||
lock_result = device_handler.lockDeviceField(test_mac, "devName")
|
||||
assert lock_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "LOCKED"
|
||||
|
||||
# Try to overwrite with plugin source (simulate authoritative decision)
|
||||
plugin_prefix = "ARPSCAN"
|
||||
plugin_settings = {"set_always": [], "set_empty": []}
|
||||
proposed_value = "Plugin Name"
|
||||
can_overwrite = can_overwrite_field(
|
||||
"devName",
|
||||
device_data.get("devName"),
|
||||
device_data.get("devNameSource"),
|
||||
plugin_prefix,
|
||||
plugin_settings,
|
||||
proposed_value,
|
||||
)
|
||||
assert can_overwrite is False
|
||||
|
||||
if can_overwrite:
|
||||
device_handler.updateDeviceColumn(test_mac, "devName", proposed_value)
|
||||
device_handler.updateDeviceColumn(test_mac, "devNameSource", plugin_prefix)
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devName") == "Original Name"
|
||||
assert device_data.get("devNameSource") == "LOCKED"
|
||||
|
||||
def test_field_source_tracking(self, test_mac, auth_headers):
|
||||
"""Verify field source is tracked correctly."""
|
||||
device_handler = DeviceInstance()
|
||||
|
||||
# Create device
|
||||
create_result = device_handler.setDeviceData(test_mac, {
|
||||
"devName": "Test Device",
|
||||
"devLastIP": "192.168.1.100",
|
||||
"createNew": True
|
||||
})
|
||||
assert create_result.get("success") is True
|
||||
|
||||
# Verify initial source
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "NEWDEV"
|
||||
|
||||
# Update field (should set source to USER)
|
||||
update_result = device_handler.setDeviceData(test_mac, {
|
||||
"devName": "Updated Name"
|
||||
})
|
||||
assert update_result.get("success") is True
|
||||
|
||||
# Verify source changed to USER
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "USER"
|
||||
|
||||
def test_save_without_changes_does_not_mark_user(self, test_mac):
|
||||
"""Saving a device without value changes must not mark sources as USER."""
|
||||
device_handler = DeviceInstance()
|
||||
|
||||
create_result = device_handler.setDeviceData(
|
||||
test_mac,
|
||||
{
|
||||
"devName": "Test Device",
|
||||
"devVendor": "Vendor1",
|
||||
"devSSID": "MyWifi",
|
||||
"createNew": True,
|
||||
},
|
||||
)
|
||||
assert create_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "NEWDEV"
|
||||
assert device_data.get("devVendorSource") == "NEWDEV"
|
||||
assert device_data.get("devSSIDSource") == "NEWDEV"
|
||||
|
||||
# Simulate a UI "save" that resubmits the same values.
|
||||
update_result = device_handler.setDeviceData(
|
||||
test_mac,
|
||||
{
|
||||
"devName": "Test Device",
|
||||
"devVendor": "Vendor1",
|
||||
"devSSID": "MyWifi",
|
||||
},
|
||||
)
|
||||
assert update_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "NEWDEV"
|
||||
assert device_data.get("devVendorSource") == "NEWDEV"
|
||||
assert device_data.get("devSSIDSource") == "NEWDEV"
|
||||
|
||||
def test_only_changed_fields_marked_user(self, test_mac):
|
||||
"""When saving, only fields whose values changed should become USER."""
|
||||
device_handler = DeviceInstance()
|
||||
|
||||
create_result = device_handler.setDeviceData(
|
||||
test_mac,
|
||||
{
|
||||
"devName": "Original Name",
|
||||
"devVendor": "Vendor1",
|
||||
"devSSID": "MyWifi",
|
||||
"createNew": True,
|
||||
},
|
||||
)
|
||||
assert create_result.get("success") is True
|
||||
|
||||
# Change only devName, but send the other fields as part of a full save.
|
||||
update_result = device_handler.setDeviceData(
|
||||
test_mac,
|
||||
{
|
||||
"devName": "Updated Name",
|
||||
"devVendor": "Vendor1",
|
||||
"devSSID": "MyWifi",
|
||||
},
|
||||
)
|
||||
assert update_result.get("success") is True
|
||||
|
||||
device_data = device_handler.getDeviceData(test_mac)
|
||||
assert device_data.get("devNameSource") == "USER"
|
||||
assert device_data.get("devVendorSource") == "NEWDEV"
|
||||
assert device_data.get("devSSIDSource") == "NEWDEV"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
923
test/authoritative_fields/test_field_lock_scan_integration.py
Normal file
923
test/authoritative_fields/test_field_lock_scan_integration.py
Normal file
@@ -0,0 +1,923 @@
|
||||
"""
|
||||
Integration tests for device field locking during actual scan updates.
|
||||
|
||||
Simulates real-world scenarios by:
|
||||
1. Setting up Devices table with various source values
|
||||
2. Populating CurrentScan with new discovery data
|
||||
3. Running actual device_handling scan updates
|
||||
4. Verifying field updates respect authorization rules
|
||||
|
||||
Tests all combinations of field sources (LOCKED, USER, NEWDEV, plugin name)
|
||||
with realistic scan data.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from server.scan import device_handling
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_db():
|
||||
"""Create an in-memory SQLite database with full device schema."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# Create Devices table with source tracking
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE Devices (
|
||||
devMac TEXT PRIMARY KEY,
|
||||
devLastConnection TEXT,
|
||||
devPresentLastScan INTEGER DEFAULT 0,
|
||||
devForceStatus TEXT,
|
||||
devLastIP TEXT,
|
||||
devName TEXT,
|
||||
devNameSource TEXT DEFAULT 'NEWDEV',
|
||||
devVendor TEXT,
|
||||
devVendorSource TEXT DEFAULT 'NEWDEV',
|
||||
devLastIPSource TEXT DEFAULT 'NEWDEV',
|
||||
devType TEXT,
|
||||
devIcon TEXT,
|
||||
devParentPort TEXT,
|
||||
devParentPortSource TEXT DEFAULT 'NEWDEV',
|
||||
devParentMAC TEXT,
|
||||
devParentMACSource TEXT DEFAULT 'NEWDEV',
|
||||
devSite TEXT,
|
||||
devSiteSource TEXT DEFAULT 'NEWDEV',
|
||||
devSSID TEXT,
|
||||
devSSIDSource TEXT DEFAULT 'NEWDEV',
|
||||
devFQDN TEXT,
|
||||
devFQDNSource TEXT DEFAULT 'NEWDEV',
|
||||
devParentRelType TEXT,
|
||||
devParentRelTypeSource TEXT DEFAULT 'NEWDEV',
|
||||
devVlan TEXT,
|
||||
devVlanSource TEXT DEFAULT 'NEWDEV',
|
||||
devPrimaryIPv4 TEXT,
|
||||
devPrimaryIPv6 TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create CurrentScan table
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE CurrentScan (
|
||||
cur_MAC TEXT,
|
||||
cur_IP TEXT,
|
||||
cur_Vendor TEXT,
|
||||
cur_ScanMethod TEXT,
|
||||
cur_Name TEXT,
|
||||
cur_LastQuery TEXT,
|
||||
cur_DateTime TEXT,
|
||||
cur_SyncHubNodeName TEXT,
|
||||
cur_NetworkSite TEXT,
|
||||
cur_SSID TEXT,
|
||||
cur_NetworkNodeMAC TEXT,
|
||||
cur_PORT 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_device_handlers():
|
||||
"""Mock device_handling helper functions."""
|
||||
with patch.multiple(
|
||||
device_handling,
|
||||
update_devPresentLastScan_based_on_nics=Mock(return_value=0),
|
||||
update_devPresentLastScan_based_on_force_status=Mock(return_value=0),
|
||||
query_MAC_vendor=Mock(return_value=-1),
|
||||
guess_icon=Mock(return_value="icon"),
|
||||
guess_type=Mock(return_value="type"),
|
||||
get_setting_value=Mock(
|
||||
side_effect=lambda key: {
|
||||
"NEWDEV_replace_preset_icon": 0,
|
||||
"NEWDEV_devIcon": "icon",
|
||||
"NEWDEV_devType": "type",
|
||||
}.get(key, "")
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_db_for_new_devices():
|
||||
"""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,
|
||||
devMacSource TEXT,
|
||||
devNameSource TEXT,
|
||||
devFQDNSource TEXT,
|
||||
devLastIPSource TEXT,
|
||||
devVendorSource TEXT,
|
||||
devSSIDSource TEXT,
|
||||
devParentMACSource TEXT,
|
||||
devParentPortSource TEXT,
|
||||
devParentRelTypeSource TEXT,
|
||||
devVlanSource 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()
|
||||
|
||||
|
||||
def test_create_new_devices_sets_sources(scan_db_for_new_devices):
|
||||
"""New device insert initializes source fields from scan method."""
|
||||
cur = scan_db_for_new_devices.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",
|
||||
"DeviceOne",
|
||||
"AcmeVendor",
|
||||
"ARPSCAN",
|
||||
"192.168.1.10",
|
||||
"",
|
||||
"11:22:33:44:55:66",
|
||||
"1",
|
||||
"",
|
||||
"MyWifi",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db_for_new_devices.commit()
|
||||
|
||||
settings = {
|
||||
"NEWDEV_devType": "default-type",
|
||||
"NEWDEV_devParentMAC": "FF:FF:FF:FF:FF:FF",
|
||||
"NEWDEV_devOwner": "owner",
|
||||
"NEWDEV_devGroup": "group",
|
||||
"NEWDEV_devComments": "",
|
||||
"NEWDEV_devLocation": "",
|
||||
"NEWDEV_devCustomProps": "",
|
||||
"NEWDEV_devParentRelType": "uplink",
|
||||
"SYNC_node_name": "SYNCNODE",
|
||||
}
|
||||
|
||||
def get_setting_value_side_effect(key):
|
||||
return settings.get(key, "")
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db_for_new_devices
|
||||
db.sql = cur
|
||||
db.commitDB = scan_db_for_new_devices.commit
|
||||
|
||||
with patch.multiple(
|
||||
device_handling,
|
||||
get_setting_value=Mock(side_effect=get_setting_value_side_effect),
|
||||
safe_int=Mock(return_value=0),
|
||||
):
|
||||
device_handling.create_new_devices(db)
|
||||
|
||||
row = cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
devMacSource,
|
||||
devNameSource,
|
||||
devVendorSource,
|
||||
devLastIPSource,
|
||||
devSSIDSource,
|
||||
devParentMACSource,
|
||||
devParentPortSource,
|
||||
devParentRelTypeSource,
|
||||
devFQDNSource,
|
||||
devVlanSource
|
||||
FROM Devices WHERE devMac = ?
|
||||
""",
|
||||
("AA:BB:CC:DD:EE:10",),
|
||||
).fetchone()
|
||||
|
||||
assert row["devMacSource"] == "ARPSCAN"
|
||||
assert row["devNameSource"] == "ARPSCAN"
|
||||
assert row["devVendorSource"] == "ARPSCAN"
|
||||
assert row["devLastIPSource"] == "ARPSCAN"
|
||||
assert row["devSSIDSource"] == "ARPSCAN"
|
||||
assert row["devParentMACSource"] == "ARPSCAN"
|
||||
assert row["devParentPortSource"] == "ARPSCAN"
|
||||
assert row["devParentRelTypeSource"] == "NEWDEV"
|
||||
assert row["devFQDNSource"] == "NEWDEV"
|
||||
assert row["devVlanSource"] == "NEWDEV"
|
||||
|
||||
|
||||
def test_scan_updates_newdev_device_name(scan_db, mock_device_handlers):
|
||||
"""Scanner discovers name for device with NEWDEV source."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with empty name (NEWDEV)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:01",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.1",
|
||||
"", # No name yet
|
||||
"NEWDEV", # Default/unset
|
||||
"TestVendor",
|
||||
"NEWDEV",
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scanner discovers name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:01",
|
||||
"192.168.1.1",
|
||||
"TestVendor",
|
||||
"NBTSCAN",
|
||||
"DiscoveredDevice",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devName FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:01",),
|
||||
).fetchone()
|
||||
|
||||
# Name SHOULD be updated from NEWDEV
|
||||
assert row["devName"] == "DiscoveredDevice", "Name should be updated from empty"
|
||||
|
||||
|
||||
def test_scan_does_not_update_user_field_name(scan_db, mock_device_handlers):
|
||||
"""Scanner cannot override devName when source is USER."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with USER-edited name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:02",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.2",
|
||||
"My Custom Device",
|
||||
"USER", # User-owned
|
||||
"TestVendor",
|
||||
"NEWDEV",
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scanner tries to update name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:02",
|
||||
"192.168.1.2",
|
||||
"TestVendor",
|
||||
"NBTSCAN",
|
||||
"ScannedDevice",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devName FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:02",),
|
||||
).fetchone()
|
||||
|
||||
# Name should NOT be updated because it's USER-owned
|
||||
assert row["devName"] == "My Custom Device", "USER name should not be changed by scan"
|
||||
|
||||
|
||||
def test_scan_does_not_update_locked_field(scan_db, mock_device_handlers):
|
||||
"""Scanner cannot override LOCKED devName."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with LOCKED name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:03",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.3",
|
||||
"Important Device",
|
||||
"LOCKED", # Locked
|
||||
"TestVendor",
|
||||
"NEWDEV",
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scanner tries to update name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:03",
|
||||
"192.168.1.3",
|
||||
"TestVendor",
|
||||
"NBTSCAN",
|
||||
"Unknown",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devName FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:03",),
|
||||
).fetchone()
|
||||
|
||||
# Name should NOT be updated because it's LOCKED
|
||||
assert row["devName"] == "Important Device", "LOCKED name should not be changed"
|
||||
|
||||
|
||||
def test_scan_updates_empty_vendor_field(scan_db, mock_device_handlers):
|
||||
"""Scan updates vendor when it's empty/NULL."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with empty vendor
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:04",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.4",
|
||||
"Device",
|
||||
"NEWDEV",
|
||||
"", # Empty vendor
|
||||
"NEWDEV",
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers vendor
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:04",
|
||||
"192.168.1.4",
|
||||
"Apple Inc.",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devVendor FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:04",),
|
||||
).fetchone()
|
||||
|
||||
# Vendor SHOULD be updated
|
||||
assert row["devVendor"] == "Apple Inc.", "Empty vendor should be populated from scan"
|
||||
|
||||
|
||||
def test_scan_updates_ip_addresses(scan_db, mock_device_handlers):
|
||||
"""Scan updates IPv4 and IPv6 addresses correctly."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with empty IPs
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID,
|
||||
devPrimaryIPv4, devPrimaryIPv6
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:05",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"",
|
||||
"Device",
|
||||
"NEWDEV",
|
||||
"Vendor",
|
||||
"NEWDEV",
|
||||
"NEWDEV",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"", # No IPv4
|
||||
"", # No IPv6
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers IPv4
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:05",
|
||||
"192.168.1.100",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:05",),
|
||||
).fetchone()
|
||||
|
||||
# IPv4 should be set
|
||||
assert row["devLastIP"] == "192.168.1.100", "Last IP should be updated"
|
||||
assert row["devPrimaryIPv4"] == "192.168.1.100", "Primary IPv4 should be set"
|
||||
assert row["devPrimaryIPv6"] == "", "IPv6 should remain empty"
|
||||
|
||||
|
||||
def test_scan_updates_ipv6_without_changing_ipv4(scan_db, mock_device_handlers):
|
||||
"""Scan updates IPv6 without overwriting IPv4."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device with IPv4 already set
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID,
|
||||
devPrimaryIPv4, devPrimaryIPv6
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:06",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.101",
|
||||
"Device",
|
||||
"NEWDEV",
|
||||
"Vendor",
|
||||
"NEWDEV",
|
||||
"NEWDEV",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"192.168.1.101", # IPv4 already set
|
||||
"", # No IPv6
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers IPv6
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:06",
|
||||
"fe80::1",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:06",),
|
||||
).fetchone()
|
||||
|
||||
# IPv4 should remain, IPv6 should be set
|
||||
assert row["devPrimaryIPv4"] == "192.168.1.101", "IPv4 should not change"
|
||||
assert row["devPrimaryIPv6"] == "fe80::1", "IPv6 should be set"
|
||||
|
||||
|
||||
def test_scan_updates_presence_status(scan_db, mock_device_handlers):
|
||||
"""Scan correctly updates devPresentLastScan status."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
# Device not in current scan (offline)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:07",
|
||||
"2025-01-01 00:00:00",
|
||||
1, # Was online
|
||||
"192.168.1.102",
|
||||
"Device",
|
||||
"NEWDEV",
|
||||
"Vendor",
|
||||
"NEWDEV",
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Note: No CurrentScan entry for this MAC - device is offline
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devPresentLastScan FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:07",),
|
||||
).fetchone()
|
||||
|
||||
# Device should be marked as offline
|
||||
assert row["devPresentLastScan"] == 0, "Offline device should have devPresentLastScan = 0"
|
||||
|
||||
|
||||
def test_scan_multiple_devices_mixed_sources(scan_db, mock_device_handlers):
|
||||
"""Scan with multiple devices having different source combinations."""
|
||||
cur = scan_db.cursor()
|
||||
|
||||
devices_data = [
|
||||
# (MAC, Name, NameSource, Vendor, VendorSource)
|
||||
("AA:BB:CC:DD:EE:11", "Device1", "NEWDEV", "", "NEWDEV"), # Both updatable
|
||||
("AA:BB:CC:DD:EE:12", "My Device", "USER", "OldVendor", "NEWDEV"), # Name protected
|
||||
("AA:BB:CC:DD:EE:13", "Locked Device", "LOCKED", "", "NEWDEV"), # Name locked
|
||||
("AA:BB:CC:DD:EE:14", "Device4", "ARPSCAN", "", "NEWDEV"), # Name from plugin
|
||||
]
|
||||
|
||||
for mac, name, name_src, vendor, vendor_src in devices_data:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devName, devNameSource, devVendor, devVendorSource, devLastIPSource,
|
||||
devType, devIcon, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
mac,
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.1",
|
||||
name,
|
||||
name_src,
|
||||
vendor,
|
||||
vendor_src,
|
||||
"ARPSCAN",
|
||||
"type",
|
||||
"icon",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers all devices with new data
|
||||
scan_entries = [
|
||||
("AA:BB:CC:DD:EE:11", "192.168.1.1", "Apple Inc.", "ScanPlugin", "ScannedDevice1"),
|
||||
("AA:BB:CC:DD:EE:12", "192.168.1.2", "Samsung", "ScanPlugin", "ScannedDevice2"),
|
||||
("AA:BB:CC:DD:EE:13", "192.168.1.3", "Sony", "ScanPlugin", "ScannedDevice3"),
|
||||
("AA:BB:CC:DD:EE:14", "192.168.1.4", "LG", "ScanPlugin", "ScannedDevice4"),
|
||||
]
|
||||
|
||||
for mac, ip, vendor, scan_method, name in scan_entries:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mac, ip, vendor, scan_method, name, "", "2025-01-01 01:00:00", "", "", "", "", "", ""),
|
||||
)
|
||||
|
||||
scan_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = scan_db
|
||||
db.sql = cur
|
||||
|
||||
# Run scan update
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
# Check results
|
||||
results = {
|
||||
"AA:BB:CC:DD:EE:11": {"name": "Device1", "vendor": "Apple Inc."}, # Name already set, won't update
|
||||
"AA:BB:CC:DD:EE:12": {"name": "My Device", "vendor": "Samsung"}, # Name protected (USER)
|
||||
"AA:BB:CC:DD:EE:13": {"name": "Locked Device", "vendor": "Sony"}, # Name locked
|
||||
"AA:BB:CC:DD:EE:14": {"name": "Device4", "vendor": "LG"}, # Name already from plugin, won't update
|
||||
}
|
||||
|
||||
for mac, expected in results.items():
|
||||
row = cur.execute(
|
||||
"SELECT devName, devVendor FROM Devices WHERE devMac = ?",
|
||||
(mac,),
|
||||
).fetchone()
|
||||
assert row["devName"] == expected["name"], f"Device {mac} name mismatch: got {row['devName']}, expected {expected['name']}"
|
||||
263
test/authoritative_fields/test_field_lock_scenarios.py
Normal file
263
test/authoritative_fields/test_field_lock_scenarios.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Unit tests for device field locking scenarios.
|
||||
|
||||
Tests all combinations of field sources (LOCKED, USER, NEWDEV, plugin name)
|
||||
and verifies that plugin updates are correctly allowed/rejected based on
|
||||
field source and SET_ALWAYS/SET_EMPTY configuration.
|
||||
"""
|
||||
|
||||
from server.db.authoritative_handler import can_overwrite_field
|
||||
|
||||
|
||||
def test_locked_source_prevents_plugin_overwrite():
|
||||
"""Field with LOCKED source should NOT be updated by plugins."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="LOCKED",
|
||||
plugin_prefix="ARPSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="New Name",
|
||||
)
|
||||
assert result is False, "LOCKED source should prevent plugin overwrites"
|
||||
|
||||
|
||||
def test_user_source_prevents_plugin_overwrite():
|
||||
"""Field with USER source should NOT be updated by plugins."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="USER",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="Plugin Discovered Name",
|
||||
)
|
||||
assert result is False, "USER source should prevent plugin overwrites"
|
||||
|
||||
|
||||
def test_newdev_source_allows_plugin_overwrite():
|
||||
"""Field with NEWDEV source should be updated by plugins."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NEWDEV",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="DiscoveredName",
|
||||
)
|
||||
assert result is True, "NEWDEV source should allow plugin overwrites"
|
||||
|
||||
|
||||
def test_empty_current_source_allows_plugin_overwrite():
|
||||
"""Field with empty source should be updated by plugins."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="DiscoveredName",
|
||||
)
|
||||
assert result is True, "Empty source should allow plugin overwrites"
|
||||
|
||||
|
||||
def test_plugin_source_allows_same_plugin_overwrite_with_set_always():
|
||||
"""Field owned by plugin can be updated by same plugin if SET_ALWAYS enabled."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NBTSCAN",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": ["devName"], "set_empty": []},
|
||||
field_value="NewName",
|
||||
)
|
||||
assert result is True, "Same plugin with SET_ALWAYS should update its own fields"
|
||||
|
||||
|
||||
def test_plugin_source_cannot_overwrite_without_authorization():
|
||||
"""Plugin cannot update field it already owns without SET_ALWAYS/SET_EMPTY."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NBTSCAN",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="NewName",
|
||||
)
|
||||
assert result is False, "Plugin cannot update owned field without SET_ALWAYS/SET_EMPTY"
|
||||
|
||||
|
||||
def test_plugin_source_allows_different_plugin_overwrite_with_set_always():
|
||||
"""Field owned by plugin can be overwritten by different plugin if SET_ALWAYS enabled."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devVendor",
|
||||
current_source="ARPSCAN",
|
||||
plugin_prefix="PIHOLEAPI",
|
||||
plugin_settings={"set_always": ["devVendor"], "set_empty": []},
|
||||
field_value="NewVendor",
|
||||
)
|
||||
assert result is True, "Different plugin with SET_ALWAYS should be able to overwrite"
|
||||
|
||||
|
||||
def test_plugin_source_rejects_different_plugin_without_set_always():
|
||||
"""Field owned by plugin should NOT be updated by different plugin without SET_ALWAYS."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devVendor",
|
||||
current_source="ARPSCAN",
|
||||
plugin_prefix="PIHOLEAPI",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="NewVendor",
|
||||
)
|
||||
assert result is False, "Different plugin without SET_ALWAYS should not overwrite plugin-owned fields"
|
||||
|
||||
|
||||
def test_set_empty_allows_overwrite_on_empty_field():
|
||||
"""SET_EMPTY allows overwriting when field is truly empty."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NEWDEV",
|
||||
plugin_prefix="PIHOLEAPI",
|
||||
plugin_settings={"set_always": [], "set_empty": ["devName"]},
|
||||
field_value="DiscoveredName",
|
||||
)
|
||||
assert result is True, "SET_EMPTY should allow overwrite on NEWDEV source"
|
||||
|
||||
|
||||
def test_set_empty_rejects_overwrite_on_non_empty_field():
|
||||
"""SET_EMPTY should NOT allow overwriting non-empty plugin-owned fields."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="ARPSCAN",
|
||||
plugin_prefix="PIHOLEAPI",
|
||||
plugin_settings={"set_always": [], "set_empty": ["devName"]},
|
||||
field_value="NewName",
|
||||
)
|
||||
assert result is False, "SET_EMPTY should not allow overwrite on non-empty plugin field"
|
||||
|
||||
|
||||
def test_empty_plugin_value_not_used():
|
||||
"""Plugin must provide non-empty value for update to occur."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NEWDEV",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="",
|
||||
)
|
||||
assert result is False, "Empty plugin value should be rejected"
|
||||
|
||||
|
||||
def test_whitespace_only_plugin_value_not_used():
|
||||
"""Plugin providing whitespace-only value should be rejected."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NEWDEV",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value=" ",
|
||||
)
|
||||
assert result is False, "Whitespace-only plugin value should be rejected"
|
||||
|
||||
|
||||
def test_none_plugin_value_not_used():
|
||||
"""Plugin providing None value should be rejected."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="NEWDEV",
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value=None,
|
||||
)
|
||||
assert result is False, "None plugin value should be rejected"
|
||||
|
||||
|
||||
def test_set_always_overrides_plugin_ownership():
|
||||
"""SET_ALWAYS should allow overwriting any non-protected field."""
|
||||
# Test 1: SET_ALWAYS overrides other plugin ownership
|
||||
result = can_overwrite_field(
|
||||
field_name="devVendor",
|
||||
current_source="ARPSCAN",
|
||||
plugin_prefix="UNIFIAPI",
|
||||
plugin_settings={"set_always": ["devVendor"], "set_empty": []},
|
||||
field_value="NewVendor",
|
||||
)
|
||||
assert result is True, "SET_ALWAYS should override plugin ownership"
|
||||
|
||||
# Test 2: SET_ALWAYS does NOT override USER
|
||||
result = can_overwrite_field(
|
||||
field_name="devVendor",
|
||||
current_source="USER",
|
||||
plugin_prefix="UNIFIAPI",
|
||||
plugin_settings={"set_always": ["devVendor"], "set_empty": []},
|
||||
field_value="NewVendor",
|
||||
)
|
||||
assert result is False, "SET_ALWAYS should not override USER source"
|
||||
|
||||
# Test 3: SET_ALWAYS does NOT override LOCKED
|
||||
result = can_overwrite_field(
|
||||
field_name="devVendor",
|
||||
current_source="LOCKED",
|
||||
plugin_prefix="UNIFIAPI",
|
||||
plugin_settings={"set_always": ["devVendor"], "set_empty": []},
|
||||
field_value="NewVendor",
|
||||
)
|
||||
assert result is False, "SET_ALWAYS should not override LOCKED source"
|
||||
|
||||
|
||||
def test_multiple_plugins_set_always_scenarios():
|
||||
"""Test SET_ALWAYS with multiple different plugins."""
|
||||
# current_source, plugin_prefix, has_set_always
|
||||
plugins_scenarios = [
|
||||
("ARPSCAN", "ARPSCAN", False), # Same plugin, no SET_ALWAYS - BLOCKED
|
||||
("ARPSCAN", "ARPSCAN", True), # Same plugin, WITH SET_ALWAYS - ALLOWED
|
||||
("ARPSCAN", "NBTSCAN", False), # Different plugin, no SET_ALWAYS - BLOCKED
|
||||
("ARPSCAN", "PIHOLEAPI", True), # Different plugin, PIHOLEAPI has SET_ALWAYS - ALLOWED
|
||||
("ARPSCAN", "UNIFIAPI", True), # Different plugin, UNIFIAPI has SET_ALWAYS - ALLOWED
|
||||
]
|
||||
|
||||
for current_source, plugin_prefix, has_set_always in plugins_scenarios:
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source=current_source,
|
||||
plugin_prefix=plugin_prefix,
|
||||
plugin_settings={"set_always": ["devName"] if has_set_always else [], "set_empty": []},
|
||||
field_value="NewName",
|
||||
)
|
||||
|
||||
if has_set_always:
|
||||
assert result is True, f"Should allow with SET_ALWAYS: {current_source} -> {plugin_prefix}"
|
||||
else:
|
||||
assert result is False, f"Should reject without SET_ALWAYS: {current_source} -> {plugin_prefix}"
|
||||
|
||||
|
||||
def test_different_fields_with_different_sources():
|
||||
"""Test that each field respects its own source tracking."""
|
||||
# Device has mixed sources
|
||||
fields_sources = [
|
||||
("devName", "USER"), # User-owned
|
||||
("devVendor", "ARPSCAN"), # Plugin-owned
|
||||
("devLastIP", "NEWDEV"), # Default
|
||||
("devFQDN", "LOCKED"), # Locked
|
||||
]
|
||||
|
||||
results = {}
|
||||
for field_name, current_source in fields_sources:
|
||||
results[field_name] = can_overwrite_field(
|
||||
field_name=field_name,
|
||||
current_source=current_source,
|
||||
plugin_prefix="NBTSCAN",
|
||||
plugin_settings={"set_always": [], "set_empty": []},
|
||||
field_value="NewValue",
|
||||
)
|
||||
|
||||
# Verify each field's result based on its source
|
||||
assert results["devName"] is False, "USER source should prevent overwrite"
|
||||
assert results["devVendor"] is False, "Plugin source without SET_ALWAYS should prevent overwrite"
|
||||
assert results["devLastIP"] is True, "NEWDEV source should allow overwrite"
|
||||
assert results["devFQDN"] is False, "LOCKED source should prevent overwrite"
|
||||
|
||||
|
||||
def test_set_empty_with_empty_string_source():
|
||||
"""SET_EMPTY with empty string source should allow overwrite."""
|
||||
result = can_overwrite_field(
|
||||
field_name="devName",
|
||||
current_source="",
|
||||
plugin_prefix="PIHOLEAPI",
|
||||
plugin_settings={"set_always": [], "set_empty": ["devName"]},
|
||||
field_value="DiscoveredName",
|
||||
)
|
||||
assert result is True, "SET_EMPTY with empty source should allow overwrite"
|
||||
65
test/authoritative_fields/test_force_status.py
Normal file
65
test/authoritative_fields/test_force_status.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Tests for forced device status updates."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from server.scan import device_handling
|
||||
|
||||
|
||||
class DummyDB:
|
||||
"""Minimal DB wrapper compatible with device_handling helpers."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.sql = conn.cursor()
|
||||
self._conn = conn
|
||||
|
||||
def commitDB(self):
|
||||
self._conn.commit()
|
||||
|
||||
|
||||
def test_force_status_updates_present_flag():
|
||||
"""Forced status should override devPresentLastScan for online/offline values."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE Devices (
|
||||
devMac TEXT PRIMARY KEY,
|
||||
devPresentLastScan INTEGER,
|
||||
devForceStatus TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO Devices (devMac, devPresentLastScan, devForceStatus)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
[
|
||||
("AA:AA:AA:AA:AA:01", 0, "online"),
|
||||
("AA:AA:AA:AA:AA:02", 1, "offline"),
|
||||
("AA:AA:AA:AA:AA:03", 1, "dont_force"),
|
||||
("AA:AA:AA:AA:AA:04", 0, None),
|
||||
("AA:AA:AA:AA:AA:05", 0, "ONLINE"),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
updated = device_handling.update_devPresentLastScan_based_on_force_status(db)
|
||||
|
||||
rows = {
|
||||
row["devMac"]: row["devPresentLastScan"]
|
||||
for row in cur.execute("SELECT devMac, devPresentLastScan FROM Devices")
|
||||
}
|
||||
|
||||
assert updated == 3
|
||||
assert rows["AA:AA:AA:AA:AA:01"] == 1
|
||||
assert rows["AA:AA:AA:AA:AA:02"] == 0
|
||||
assert rows["AA:AA:AA:AA:AA:03"] == 1
|
||||
assert rows["AA:AA:AA:AA:AA:04"] == 0
|
||||
assert rows["AA:AA:AA:AA:AA:05"] == 1
|
||||
|
||||
conn.close()
|
||||
534
test/authoritative_fields/test_ip_format_and_locking.py
Normal file
534
test/authoritative_fields/test_ip_format_and_locking.py
Normal file
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
Tests for IP format validation and field locking interactions.
|
||||
|
||||
Covers:
|
||||
- IPv4/IPv6 format validation
|
||||
- Invalid IP rejection
|
||||
- IP field locking scenarios
|
||||
- IP source tracking
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from server.scan import device_handling
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ip_test_db():
|
||||
"""Create an in-memory SQLite database for IP format testing."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE Devices (
|
||||
devMac TEXT PRIMARY KEY,
|
||||
devLastConnection TEXT,
|
||||
devPresentLastScan INTEGER,
|
||||
devForceStatus TEXT,
|
||||
devLastIP TEXT,
|
||||
devLastIPSource TEXT DEFAULT 'NEWDEV',
|
||||
devPrimaryIPv4 TEXT,
|
||||
devPrimaryIPv4Source TEXT DEFAULT 'NEWDEV',
|
||||
devPrimaryIPv6 TEXT,
|
||||
devPrimaryIPv6Source TEXT DEFAULT 'NEWDEV',
|
||||
devVendor TEXT,
|
||||
devParentPort TEXT,
|
||||
devParentMAC TEXT,
|
||||
devSite TEXT,
|
||||
devSSID TEXT,
|
||||
devType TEXT,
|
||||
devName TEXT,
|
||||
devIcon TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE CurrentScan (
|
||||
cur_MAC TEXT,
|
||||
cur_IP TEXT,
|
||||
cur_Vendor TEXT,
|
||||
cur_ScanMethod TEXT,
|
||||
cur_Name TEXT,
|
||||
cur_LastQuery TEXT,
|
||||
cur_DateTime TEXT,
|
||||
cur_SyncHubNodeName TEXT,
|
||||
cur_NetworkSite TEXT,
|
||||
cur_SSID TEXT,
|
||||
cur_NetworkNodeMAC TEXT,
|
||||
cur_PORT TEXT,
|
||||
cur_Type TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ip_handlers():
|
||||
"""Mock device_handling helper functions."""
|
||||
with patch.multiple(
|
||||
device_handling,
|
||||
update_devPresentLastScan_based_on_nics=Mock(return_value=0),
|
||||
update_devPresentLastScan_based_on_force_status=Mock(return_value=0),
|
||||
query_MAC_vendor=Mock(return_value=-1),
|
||||
guess_icon=Mock(return_value="icon"),
|
||||
guess_type=Mock(return_value="type"),
|
||||
get_setting_value=Mock(return_value=""),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def test_valid_ipv4_format_accepted(ip_test_db, mock_ip_handlers):
|
||||
"""Valid IPv4 address should be accepted and set as primary IPv4."""
|
||||
cur = ip_test_db.cursor()
|
||||
|
||||
# Device with no IPs
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:01",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Vendor",
|
||||
"type",
|
||||
"icon",
|
||||
"Device",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers valid IPv4
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:01",
|
||||
"192.168.1.100",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:01",),
|
||||
).fetchone()
|
||||
|
||||
assert row["devLastIP"] == "192.168.1.100", "Valid IPv4 should update devLastIP"
|
||||
assert row["devPrimaryIPv4"] == "192.168.1.100", "Valid IPv4 should set devPrimaryIPv4"
|
||||
assert row["devPrimaryIPv6"] == "", "IPv6 should remain empty"
|
||||
|
||||
|
||||
def test_valid_ipv6_format_accepted(ip_test_db, mock_ip_handlers):
|
||||
"""Valid IPv6 address should be accepted and set as primary IPv6."""
|
||||
cur = ip_test_db.cursor()
|
||||
|
||||
# Device with no IPs
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:02",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Vendor",
|
||||
"type",
|
||||
"icon",
|
||||
"Device",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scan discovers valid IPv6
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:02",
|
||||
"fe80::1",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:02",),
|
||||
).fetchone()
|
||||
|
||||
assert row["devLastIP"] == "fe80::1", "Valid IPv6 should update devLastIP"
|
||||
assert row["devPrimaryIPv4"] == "", "IPv4 should remain empty"
|
||||
assert row["devPrimaryIPv6"] == "fe80::1", "Valid IPv6 should set devPrimaryIPv6"
|
||||
|
||||
|
||||
def test_invalid_ip_values_rejected(ip_test_db, mock_ip_handlers):
|
||||
"""Invalid IP values like (unknown), null, empty should be rejected."""
|
||||
cur = ip_test_db.cursor()
|
||||
|
||||
# Device with existing valid IPv4
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:03",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.50",
|
||||
"192.168.1.50",
|
||||
"",
|
||||
"Vendor",
|
||||
"type",
|
||||
"icon",
|
||||
"Device",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
invalid_ips = ["", "null", "(unknown)", "(Unknown)"]
|
||||
|
||||
for invalid_ip in invalid_ips:
|
||||
cur.execute("DELETE FROM CurrentScan")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:03",
|
||||
invalid_ip,
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devPrimaryIPv4 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:03",),
|
||||
).fetchone()
|
||||
|
||||
assert (
|
||||
row["devPrimaryIPv4"] == "192.168.1.50"
|
||||
), f"Invalid IP '{invalid_ip}' should not overwrite valid IPv4"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Device with no IPs
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:04",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Vendor",
|
||||
"type",
|
||||
"icon",
|
||||
"Device",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
|
||||
# Scan 1: IPv4
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:04",
|
||||
"192.168.1.100",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row1 = cur.execute(
|
||||
"SELECT devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:04",),
|
||||
).fetchone()
|
||||
|
||||
assert row1["devPrimaryIPv4"] == "192.168.1.100"
|
||||
assert row1["devPrimaryIPv6"] == ""
|
||||
|
||||
# Scan 2: IPv6 (should add IPv6 without changing IPv4)
|
||||
cur.execute("DELETE FROM CurrentScan")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:04",
|
||||
"fe80::1",
|
||||
"Vendor",
|
||||
"ARPSCAN",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 02:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
ip_test_db.commit()
|
||||
|
||||
db.sql = cur
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row2 = cur.execute(
|
||||
"SELECT devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:04",),
|
||||
).fetchone()
|
||||
|
||||
assert row2["devPrimaryIPv4"] == "192.168.1.100", "IPv4 should be preserved"
|
||||
assert row2["devPrimaryIPv6"] == "fe80::1", "IPv6 should be set"
|
||||
|
||||
|
||||
def test_ipv4_address_format_variations(ip_test_db, mock_ip_handlers):
|
||||
"""Test various valid IPv4 formats."""
|
||||
cur = ip_test_db.cursor()
|
||||
|
||||
ipv4_addresses = [
|
||||
"0.0.0.0",
|
||||
"127.0.0.1",
|
||||
"192.168.1.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"255.255.255.255",
|
||||
]
|
||||
|
||||
for idx, ipv4 in enumerate(ipv4_addresses):
|
||||
mac = f"AA:BB:CC:DD:EE:{idx:02X}"
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mac, "2025-01-01 00:00:00", 0, "", "", "", "Vendor", "type", "icon", "Device", "", "", "", ""),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mac, ipv4, "Vendor", "ARPSCAN", "", "", "2025-01-01 01:00:00", "", "", "", "", "", ""),
|
||||
)
|
||||
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
for idx, expected_ipv4 in enumerate(ipv4_addresses):
|
||||
mac = f"AA:BB:CC:DD:EE:{idx:02X}"
|
||||
row = cur.execute(
|
||||
"SELECT devPrimaryIPv4 FROM Devices WHERE devMac = ?",
|
||||
(mac,),
|
||||
).fetchone()
|
||||
assert row["devPrimaryIPv4"] == expected_ipv4, f"IPv4 {expected_ipv4} should be set for {mac}"
|
||||
|
||||
|
||||
def test_ipv6_address_format_variations(ip_test_db, mock_ip_handlers):
|
||||
"""Test various valid IPv6 formats."""
|
||||
cur = ip_test_db.cursor()
|
||||
|
||||
ipv6_addresses = [
|
||||
"::1",
|
||||
"fe80::1",
|
||||
"2001:db8::1",
|
||||
"::ffff:192.0.2.1",
|
||||
"2001:0db8:85a3::8a2e:0370:7334",
|
||||
]
|
||||
|
||||
for idx, ipv6 in enumerate(ipv6_addresses):
|
||||
mac = f"BB:BB:CC:DD:EE:{idx:02X}"
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devType, devIcon,
|
||||
devName, devParentPort, devParentMAC, devSite, devSSID
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mac, "2025-01-01 00:00:00", 0, "", "", "", "Vendor", "type", "icon", "Device", "", "", "", ""),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(mac, ipv6, "Vendor", "ARPSCAN", "", "", "2025-01-01 01:00:00", "", "", "", "", "", ""),
|
||||
)
|
||||
|
||||
ip_test_db.commit()
|
||||
|
||||
db = Mock()
|
||||
db.sql_connection = ip_test_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
for idx, expected_ipv6 in enumerate(ipv6_addresses):
|
||||
mac = f"BB:BB:CC:DD:EE:{idx:02X}"
|
||||
row = cur.execute(
|
||||
"SELECT devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
(mac,),
|
||||
).fetchone()
|
||||
assert row["devPrimaryIPv6"] == expected_ipv6, f"IPv6 {expected_ipv6} should be set for {mac}"
|
||||
233
test/authoritative_fields/test_ip_update_logic.py
Normal file
233
test/authoritative_fields/test_ip_update_logic.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Unit tests for device IP update logic (devPrimaryIPv4/devPrimaryIPv6 handling).
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from server.scan import device_handling
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_memory_db():
|
||||
"""Create an in-memory SQLite database for testing."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE Devices (
|
||||
devMac TEXT PRIMARY KEY,
|
||||
devLastConnection TEXT,
|
||||
devPresentLastScan INTEGER,
|
||||
devForceStatus TEXT,
|
||||
devLastIP TEXT,
|
||||
devPrimaryIPv4 TEXT,
|
||||
devPrimaryIPv6 TEXT,
|
||||
devVendor TEXT,
|
||||
devParentPort TEXT,
|
||||
devParentMAC TEXT,
|
||||
devSite TEXT,
|
||||
devSSID TEXT,
|
||||
devType TEXT,
|
||||
devName TEXT,
|
||||
devIcon TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE CurrentScan (
|
||||
cur_MAC TEXT,
|
||||
cur_IP TEXT,
|
||||
cur_Vendor TEXT,
|
||||
cur_ScanMethod TEXT,
|
||||
cur_Name TEXT,
|
||||
cur_LastQuery TEXT,
|
||||
cur_DateTime TEXT,
|
||||
cur_SyncHubNodeName TEXT,
|
||||
cur_NetworkSite TEXT,
|
||||
cur_SSID TEXT,
|
||||
cur_NetworkNodeMAC TEXT,
|
||||
cur_PORT TEXT,
|
||||
cur_Type TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_handling():
|
||||
"""Mock device_handling dependencies."""
|
||||
with patch.multiple(
|
||||
device_handling,
|
||||
update_devPresentLastScan_based_on_nics=Mock(return_value=0),
|
||||
update_devPresentLastScan_based_on_force_status=Mock(return_value=0),
|
||||
query_MAC_vendor=Mock(return_value=-1),
|
||||
guess_icon=Mock(return_value="icon"),
|
||||
guess_type=Mock(return_value="type"),
|
||||
get_setting_value=Mock(side_effect=lambda key: {
|
||||
"NEWDEV_replace_preset_icon": 0,
|
||||
"NEWDEV_devIcon": "icon",
|
||||
"NEWDEV_devType": "type",
|
||||
}.get(key, "")),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def test_primary_ipv6_is_set_and_ipv4_preserved(in_memory_db, mock_device_handling):
|
||||
"""Setting IPv6 in CurrentScan should update devPrimaryIPv6 without changing devPrimaryIPv4."""
|
||||
cur = in_memory_db.cursor()
|
||||
|
||||
# Create device with IPv4 primary
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devParentPort,
|
||||
devParentMAC, devSite, devSSID, devType, devName, devIcon
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:FF",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"192.168.1.10",
|
||||
"192.168.1.10",
|
||||
"",
|
||||
"TestVendor",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"type",
|
||||
"Device",
|
||||
"icon",
|
||||
),
|
||||
)
|
||||
|
||||
# CurrentScan with IPv6
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"AA:BB:CC:DD:EE:FF",
|
||||
"2001:db8::1",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 01:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
in_memory_db.commit()
|
||||
|
||||
# Mock DummyDB-like object
|
||||
db = Mock()
|
||||
db.sql_connection = in_memory_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("AA:BB:CC:DD:EE:FF",),
|
||||
).fetchone()
|
||||
|
||||
assert row["devLastIP"] == "2001:db8::1"
|
||||
assert row["devPrimaryIPv4"] == "192.168.1.10"
|
||||
assert row["devPrimaryIPv6"] == "2001:db8::1"
|
||||
|
||||
|
||||
def test_primary_ipv4_is_set_and_ipv6_preserved(in_memory_db, mock_device_handling):
|
||||
"""Setting IPv4 in CurrentScan should update devPrimaryIPv4 without changing devPrimaryIPv6."""
|
||||
cur = in_memory_db.cursor()
|
||||
|
||||
# Create device with IPv6 primary
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO Devices (
|
||||
devMac, devLastConnection, devPresentLastScan, devLastIP,
|
||||
devPrimaryIPv4, devPrimaryIPv6, devVendor, devParentPort,
|
||||
devParentMAC, devSite, devSSID, devType, devName, devIcon
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"11:22:33:44:55:66",
|
||||
"2025-01-01 00:00:00",
|
||||
0,
|
||||
"2001:db8::2",
|
||||
"",
|
||||
"2001:db8::2",
|
||||
"TestVendor",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"type",
|
||||
"Device",
|
||||
"icon",
|
||||
),
|
||||
)
|
||||
|
||||
# CurrentScan with IPv4
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO CurrentScan (
|
||||
cur_MAC, cur_IP, cur_Vendor, cur_ScanMethod, cur_Name,
|
||||
cur_LastQuery, cur_DateTime, cur_SyncHubNodeName,
|
||||
cur_NetworkSite, cur_SSID, cur_NetworkNodeMAC, cur_PORT, cur_Type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"11:22:33:44:55:66",
|
||||
"10.0.0.5",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"2025-01-01 02:00:00",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
in_memory_db.commit()
|
||||
|
||||
# Mock DummyDB-like object
|
||||
db = Mock()
|
||||
db.sql_connection = in_memory_db
|
||||
db.sql = cur
|
||||
|
||||
device_handling.update_devices_data_from_scan(db)
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT devLastIP, devPrimaryIPv4, devPrimaryIPv6 FROM Devices WHERE devMac = ?",
|
||||
("11:22:33:44:55:66",),
|
||||
).fetchone()
|
||||
|
||||
assert row["devLastIP"] == "10.0.0.5"
|
||||
assert row["devPrimaryIPv4"] == "10.0.0.5"
|
||||
assert row["devPrimaryIPv6"] == "2001:db8::2"
|
||||
231
test/test_device_atomicity.py
Normal file
231
test/test_device_atomicity.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user