mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2025-12-07 09:36:05 -08:00
Compare commits
31 Commits
pr-1279
...
c38758d61a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38758d61a | ||
|
|
6034b12af6 | ||
|
|
972654dc78 | ||
|
|
ec417b0dac | ||
|
|
2e9352dc12 | ||
|
|
566b263d0a | ||
|
|
61b42b4fea | ||
|
|
a45de018fb | ||
|
|
bfe6987867 | ||
|
|
b6567ab5fc | ||
|
|
f71c2fbe94 | ||
|
|
aeb03f50ba | ||
|
|
734db423ee | ||
|
|
4f47dbfe14 | ||
|
|
d23bf45310 | ||
|
|
9c366881f1 | ||
|
|
9dd482618b | ||
|
|
84cc01566d | ||
|
|
ac7b912b45 | ||
|
|
62852f1b2f | ||
|
|
b659a0f06d | ||
|
|
fb3620a378 | ||
|
|
9d56e13818 | ||
|
|
43c5a11271 | ||
|
|
ac957ce599 | ||
|
|
3567906fcd | ||
|
|
be6801d98f | ||
|
|
bb9b242d0a | ||
|
|
5f27d3b9aa | ||
|
|
93af0e9d19 | ||
|
|
398e2a896f |
@@ -80,8 +80,9 @@ ENV SYSTEM_SERVICES=/services
|
|||||||
ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts
|
ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts
|
||||||
ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config
|
ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config
|
||||||
ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
||||||
ENV SYSTEM_NGINX_CONFIG_FILE=${SYSTEM_NGINX_CONFIG}/nginx.conf
|
ENV SYSTEM_NGINX_CONFIG_TEMPLATE=${SYSTEM_NGINX_CONFIG}/netalertx.conf.template
|
||||||
ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config
|
ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config
|
||||||
|
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_FOLDER=${SYSTEM_SERVICES_CONFIG}/php
|
||||||
ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d
|
ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d
|
||||||
ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond
|
ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond
|
||||||
@@ -138,6 +139,9 @@ RUN install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FO
|
|||||||
sh -c "find ${NETALERTX_APP} -type f \( -name '*.sh' -o -name 'speedtest-cli' \) \
|
sh -c "find ${NETALERTX_APP} -type f \( -name '*.sh' -o -name 'speedtest-cli' \) \
|
||||||
-exec chmod 750 {} \;"
|
-exec chmod 750 {} \;"
|
||||||
|
|
||||||
|
# Copy version information into the image
|
||||||
|
COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .VERSION ${NETALERTX_APP}/.VERSION
|
||||||
|
|
||||||
# Copy the virtualenv from the builder stage
|
# Copy the virtualenv from the builder stage
|
||||||
COPY --from=builder --chown=20212:20212 ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
COPY --from=builder --chown=20212:20212 ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
# DO NOT MODIFY THIS FILE DIRECTLY. IT IS AUTO-GENERATED BY .devcontainer/scripts/generate-configs.sh
|
|
||||||
# Generated from: install/production-filesystem/services/config/nginx/netalertx.conf.template
|
|
||||||
|
|
||||||
# Set number of worker processes automatically based on number of CPU cores.
|
|
||||||
worker_processes auto;
|
|
||||||
|
|
||||||
# Enables the use of JIT for regular expressions to speed-up their processing.
|
|
||||||
pcre_jit on;
|
|
||||||
|
|
||||||
# Configures default error logger.
|
|
||||||
error_log /tmp/log/nginx-error.log warn;
|
|
||||||
|
|
||||||
pid /tmp/run/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
|
||||||
# The maximum number of simultaneous connections that can be opened by
|
|
||||||
# a worker process.
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
# Mapping of temp paths for various nginx modules.
|
|
||||||
client_body_temp_path /tmp/nginx/client_body;
|
|
||||||
proxy_temp_path /tmp/nginx/proxy;
|
|
||||||
fastcgi_temp_path /tmp/nginx/fastcgi;
|
|
||||||
uwsgi_temp_path /tmp/nginx/uwsgi;
|
|
||||||
scgi_temp_path /tmp/nginx/scgi;
|
|
||||||
|
|
||||||
# Includes mapping of file name extensions to MIME types of responses
|
|
||||||
# and defines the default type.
|
|
||||||
include /services/config/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
# Name servers used to resolve names of upstream servers into addresses.
|
|
||||||
# It's also needed when using tcpsocket and udpsocket in Lua modules.
|
|
||||||
#resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001];
|
|
||||||
|
|
||||||
# Don't tell nginx version to the clients. Default is 'on'.
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
# Specifies the maximum accepted body size of a client request, as
|
|
||||||
# indicated by the request header Content-Length. If the stated content
|
|
||||||
# length is greater than this size, then the client receives the HTTP
|
|
||||||
# error code 413. Set to 0 to disable. Default is '1m'.
|
|
||||||
client_max_body_size 1m;
|
|
||||||
|
|
||||||
# Sendfile copies data between one FD and other from within the kernel,
|
|
||||||
# which is more efficient than read() + write(). Default is off.
|
|
||||||
sendfile on;
|
|
||||||
|
|
||||||
# Causes nginx to attempt to send its HTTP response head in one packet,
|
|
||||||
# instead of using partial frames. Default is 'off'.
|
|
||||||
tcp_nopush on;
|
|
||||||
|
|
||||||
|
|
||||||
# Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2.
|
|
||||||
# TIP: If you're not obligated to support ancient clients, remove TLSv1.1.
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
|
|
||||||
# Path of the file with Diffie-Hellman parameters for EDH ciphers.
|
|
||||||
# TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048`
|
|
||||||
#ssl_dhparam /etc/ssl/nginx/dh2048.pem;
|
|
||||||
|
|
||||||
# Specifies that our cipher suits should be preferred over client ciphers.
|
|
||||||
# Default is 'off'.
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
|
|
||||||
# Enables a shared SSL cache with size that can hold around 8000 sessions.
|
|
||||||
# Default is 'none'.
|
|
||||||
ssl_session_cache shared:SSL:2m;
|
|
||||||
|
|
||||||
# Specifies a time during which a client may reuse the session parameters.
|
|
||||||
# Default is '5m'.
|
|
||||||
ssl_session_timeout 1h;
|
|
||||||
|
|
||||||
# Disable TLS session tickets (they are insecure). Default is 'on'.
|
|
||||||
ssl_session_tickets off;
|
|
||||||
|
|
||||||
|
|
||||||
# Enable gzipping of responses.
|
|
||||||
gzip on;
|
|
||||||
|
|
||||||
# Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'.
|
|
||||||
gzip_vary on;
|
|
||||||
|
|
||||||
|
|
||||||
# Specifies the main log format.
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
||||||
|
|
||||||
# Sets the path, format, and configuration for a buffered log write.
|
|
||||||
access_log /tmp/log/nginx-access.log main;
|
|
||||||
|
|
||||||
|
|
||||||
# Virtual host config
|
|
||||||
server {
|
|
||||||
listen 0.0.0.0:20211 default_server;
|
|
||||||
large_client_header_buffers 4 16k;
|
|
||||||
root /app/front;
|
|
||||||
index index.php;
|
|
||||||
add_header X-Forwarded-Prefix "/app" always;
|
|
||||||
|
|
||||||
location ~* \.php$ {
|
|
||||||
# Set Cache-Control header to prevent caching on the first load
|
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
fastcgi_pass unix:/tmp/run/php.sock;
|
|
||||||
include /services/config/nginx/fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
|
||||||
|
|
||||||
fastcgi_param PHP_VALUE "xdebug.remote_enable=1";
|
|
||||||
fastcgi_connect_timeout 75;
|
|
||||||
fastcgi_send_timeout 600;
|
|
||||||
fastcgi_read_timeout 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,33 +30,4 @@ cat "${DEVCONTAINER_DIR}/resources/devcontainer-Dockerfile" >> "$OUT_FILE"
|
|||||||
|
|
||||||
echo "Generated $OUT_FILE using root dir $ROOT_DIR" >&2
|
echo "Generated $OUT_FILE using root dir $ROOT_DIR" >&2
|
||||||
|
|
||||||
# Generate devcontainer nginx config from production template
|
|
||||||
echo "Generating devcontainer nginx config"
|
|
||||||
NGINX_TEMPLATE="${ROOT_DIR}/install/production-filesystem/services/config/nginx/netalertx.conf.template"
|
|
||||||
NGINX_OUT="${DEVCONTAINER_DIR}/resources/devcontainer-overlay/services/config/nginx/netalertx.conf.template"
|
|
||||||
|
|
||||||
# Create output directory if it doesn't exist
|
|
||||||
mkdir -p "$(dirname "$NGINX_OUT")"
|
|
||||||
|
|
||||||
# Start with header comment
|
|
||||||
cat > "$NGINX_OUT" << 'EOF'
|
|
||||||
# DO NOT MODIFY THIS FILE DIRECTLY. IT IS AUTO-GENERATED BY .devcontainer/scripts/generate-configs.sh
|
|
||||||
# Generated from: install/production-filesystem/services/config/nginx/netalertx.conf.template
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Process the template: replace listen directive and inject Xdebug params
|
|
||||||
sed 's/${LISTEN_ADDR}:${PORT}/0.0.0.0:20211/g' "$NGINX_TEMPLATE" | \
|
|
||||||
awk '
|
|
||||||
/fastcgi_param SCRIPT_NAME \$fastcgi_script_name;/ {
|
|
||||||
print $0
|
|
||||||
print ""
|
|
||||||
print " fastcgi_param PHP_VALUE \"xdebug.remote_enable=1\";"
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{ print }
|
|
||||||
' >> "$NGINX_OUT"
|
|
||||||
|
|
||||||
echo "Generated $NGINX_OUT from $NGINX_TEMPLATE" >&2
|
|
||||||
|
|
||||||
echo "Done."
|
echo "Done."
|
||||||
@@ -50,9 +50,6 @@ sudo chmod 777 /tmp/log /tmp/api /tmp/run /tmp/nginx
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
sudo rm -rf "${SYSTEM_NGINX_CONFIG}/conf.active"
|
|
||||||
sudo ln -s "${SYSTEM_SERVICES_ACTIVE_CONFIG}" "${SYSTEM_NGINX_CONFIG}/conf.active"
|
|
||||||
|
|
||||||
sudo rm -rf /entrypoint.d
|
sudo rm -rf /entrypoint.d
|
||||||
sudo ln -s "${SOURCE_DIR}/install/production-filesystem/entrypoint.d" /entrypoint.d
|
sudo ln -s "${SOURCE_DIR}/install/production-filesystem/entrypoint.d" /entrypoint.d
|
||||||
|
|
||||||
@@ -67,6 +64,7 @@ for dir in \
|
|||||||
"${SYSTEM_SERVICES_RUN_LOG}" \
|
"${SYSTEM_SERVICES_RUN_LOG}" \
|
||||||
"${SYSTEM_SERVICES_ACTIVE_CONFIG}" \
|
"${SYSTEM_SERVICES_ACTIVE_CONFIG}" \
|
||||||
"${NETALERTX_PLUGINS_LOG}" \
|
"${NETALERTX_PLUGINS_LOG}" \
|
||||||
|
"${SYSTEM_SERVICES_RUN_TMP}" \
|
||||||
"/tmp/nginx/client_body" \
|
"/tmp/nginx/client_body" \
|
||||||
"/tmp/nginx/proxy" \
|
"/tmp/nginx/proxy" \
|
||||||
"/tmp/nginx/fastcgi" \
|
"/tmp/nginx/fastcgi" \
|
||||||
@@ -75,9 +73,6 @@ for dir in \
|
|||||||
sudo install -d -m 777 "${dir}"
|
sudo install -d -m 777 "${dir}"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Create nginx temp subdirs with permissions
|
|
||||||
sudo mkdir -p "${SYSTEM_SERVICES_RUN_TMP}/client_body" "${SYSTEM_SERVICES_RUN_TMP}/proxy" "${SYSTEM_SERVICES_RUN_TMP}/fastcgi" "${SYSTEM_SERVICES_RUN_TMP}/uwsgi" "${SYSTEM_SERVICES_RUN_TMP}/scgi"
|
|
||||||
sudo chmod -R 777 "${SYSTEM_SERVICES_RUN_TMP}"
|
|
||||||
|
|
||||||
for var in "${LOG_FILES[@]}"; do
|
for var in "${LOG_FILES[@]}"; do
|
||||||
path=${!var}
|
path=${!var}
|
||||||
|
|||||||
1
.github/workflows/code_checks.yml
vendored
1
.github/workflows/code_checks.yml
vendored
@@ -38,4 +38,3 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
echo "🔍 Checking Python syntax..."
|
echo "🔍 Checking Python syntax..."
|
||||||
find . -name "*.py" -print0 | xargs -0 -n1 python3 -m py_compile
|
find . -name "*.py" -print0 | xargs -0 -n1 python3 -m py_compile
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/docker_dev.yml
vendored
4
.github/workflows/docker_dev.yml
vendored
@@ -3,12 +3,12 @@ name: docker
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- next_release
|
- main
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- next_release
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_dev:
|
docker_dev:
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ ENV SYSTEM_SERVICES=/services
|
|||||||
ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts
|
ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts
|
||||||
ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config
|
ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config
|
||||||
ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx
|
||||||
ENV SYSTEM_NGINX_CONFIG_FILE=${SYSTEM_NGINX_CONFIG}/nginx.conf
|
ENV SYSTEM_NGINX_CONFIG_TEMPLATE=${SYSTEM_NGINX_CONFIG}/netalertx.conf.template
|
||||||
ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config
|
ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config
|
||||||
|
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_FOLDER=${SYSTEM_SERVICES_CONFIG}/php
|
||||||
ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d
|
ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d
|
||||||
ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond
|
ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ Get visibility of what's going on on your WIFI/LAN network and enable presence d
|
|||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> ⚠️ **Important:** The documentation has been recently updated and some instructions may have changed.
|
||||||
|
> If you are using the currently live production image, please follow the instructions on [Docker Hub](https://hub.docker.com/r/jokobsk/netalertx) for building and running the container.
|
||||||
|
> These docs reflect the latest development version and may differ from the production image.
|
||||||
|
|
||||||
Start NetAlertX in seconds with Docker:
|
Start NetAlertX in seconds with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/home/pi/pialert/config
|
- /local/path/config:/home/pi/pialert/config
|
||||||
- local/path/db:/home/pi/pialert/db
|
- /local/path/db:/home/pi/pialert/db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/home/pi/pialert/front/log
|
- /local/path/logs:/home/pi/pialert/front/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -104,10 +104,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/data/config # 🆕 This has changed
|
- /local/path/config:/data/config # 🆕 This has changed
|
||||||
- local/path/db:/data/db # 🆕 This has changed
|
- /local/path/db:/data/db # 🆕 This has changed
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/tmp/log # 🆕 This has changed
|
- /local/path/logs:/tmp/log # 🆕 This has changed
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -131,10 +131,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config/pialert.conf:/home/pi/pialert/config/pialert.conf
|
- /local/path/config/pialert.conf:/home/pi/pialert/config/pialert.conf
|
||||||
- local/path/db/pialert.db:/home/pi/pialert/db/pialert.db
|
- /local/path/db/pialert.db:/home/pi/pialert/db/pialert.db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/home/pi/pialert/front/log
|
- /local/path/logs:/home/pi/pialert/front/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -150,10 +150,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config/app.conf:/data/config/app.conf # 🆕 This has changed
|
- /local/path/config/app.conf:/data/config/app.conf # 🆕 This has changed
|
||||||
- local/path/db/app.db:/data/db/app.db # 🆕 This has changed
|
- /local/path/db/app.db:/data/db/app.db # 🆕 This has changed
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/tmp/log # 🆕 This has changed
|
- /local/path/logs:/tmp/log # 🆕 This has changed
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -190,10 +190,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/data/config
|
- /local/path/config:/data/config
|
||||||
- local/path/db:/data/db
|
- /local/path/db:/data/db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/tmp/log
|
- /local/path/logs:/tmp/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -207,10 +207,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/data/config
|
- /local/path/config:/data/config
|
||||||
- local/path/db:/data/db
|
- /local/path/db:/data/db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/tmp/log
|
- /local/path/logs:/tmp/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -234,10 +234,10 @@ services:
|
|||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/data/config
|
- /local/path/config:/data/config
|
||||||
- local/path/db:/data/db
|
- /local/path/db:/data/db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
- local/path/logs:/tmp/log
|
- /local/path/logs:/tmp/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
@@ -253,11 +253,19 @@ services:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -it --rm --name netalertx --user "0" \
|
docker run -it --rm --name netalertx --user "0" \
|
||||||
-v local/path/config:/data/config \
|
-v /local/path/config:/data/config \
|
||||||
-v local/path/db:/data/db \
|
-v /local/path/db:/data/db \
|
||||||
ghcr.io/jokob-sk/netalertx:latest
|
ghcr.io/jokob-sk/netalertx:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
..or alternatively execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R 20211:20211 /local/path/config
|
||||||
|
sudo chown -R 20211:20211 /local/path/db
|
||||||
|
sudo chmod -R a+rwx /local/path/
|
||||||
|
```
|
||||||
|
|
||||||
7. Stop the container
|
7. Stop the container
|
||||||
8. Update the `docker-compose.yml` as per example below.
|
8. Update the `docker-compose.yml` as per example below.
|
||||||
|
|
||||||
@@ -273,10 +281,10 @@ services:
|
|||||||
- NET_BIND_SERVICE # 🆕 New line
|
- NET_BIND_SERVICE # 🆕 New line
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- local/path/config:/data/config
|
- /local/path/config:/data/config
|
||||||
- local/path/db:/data/db
|
- /local/path/db:/data/db
|
||||||
# (optional) useful for debugging if you have issues setting up the container
|
# (optional) useful for debugging if you have issues setting up the container
|
||||||
#- local/path/logs:/tmp/log
|
#- /local/path/logs:/tmp/log
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Berlin
|
- TZ=Europe/Berlin
|
||||||
- PORT=20211
|
- PORT=20211
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
|
|||||||
| `LUCIRPC` | [luci_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | |
|
| `LUCIRPC` | [luci_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | |
|
||||||
| `MAINT` | [maintenance](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | |
|
| `MAINT` | [maintenance](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | |
|
||||||
| `MQTT` | [_publisher_mqtt](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | |
|
| `MQTT` | [_publisher_mqtt](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | |
|
||||||
|
| `MTSCAN` | [mikrotik_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/mikrotik_scan/) | 🔍 | Mikrotik device import & sync | | |
|
||||||
| `NBTSCAN` | [nbtscan_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nbtscan_scan/) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | |
|
| `NBTSCAN` | [nbtscan_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nbtscan_scan/) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | |
|
||||||
| `NEWDEV` | [newdev_template](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/newdev_template/) | ⚙ | New device template | | Yes |
|
| `NEWDEV` | [newdev_template](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/newdev_template/) | ⚙ | New device template | | Yes |
|
||||||
| `NMAP` | [nmap_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_scan/) | ♻ | Nmap port scanning & discovery | | |
|
| `NMAP` | [nmap_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_scan/) | ♻ | Nmap port scanning & discovery | | |
|
||||||
@@ -74,6 +75,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
|
|||||||
| `OMDSDN` | [omada_sdn_imp](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_imp/) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | |
|
| `OMDSDN` | [omada_sdn_imp](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_imp/) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | |
|
||||||
| `OMDSDNOPENAPI` | [omada_sdn_openapi](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_openapi/) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | |
|
| `OMDSDNOPENAPI` | [omada_sdn_openapi](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_openapi/) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | |
|
||||||
| `PIHOLE` | [pihole_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync | | |
|
| `PIHOLE` | [pihole_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync | | |
|
||||||
|
| `PIHOLEAPI` | [pihole_api_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_api_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync via API v6+ | | |
|
||||||
| `PUSHSAFER` | [_publisher_pushsafer](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushsafer/) | ▶️ | Pushsafer notifications | | |
|
| `PUSHSAFER` | [_publisher_pushsafer](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushsafer/) | ▶️ | Pushsafer notifications | | |
|
||||||
| `PUSHOVER` | [_publisher_pushover](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushover/) | ▶️ | Pushover notifications | | |
|
| `PUSHOVER` | [_publisher_pushover](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushover/) | ▶️ | Pushover notifications | | |
|
||||||
| `SETPWD` | [set_password](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/set_password/) | ⚙ | Set password | | Yes |
|
| `SETPWD` | [set_password](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/set_password/) | ⚙ | Set password | | Yes |
|
||||||
|
|||||||
@@ -497,11 +497,39 @@ function isValidBase64(str) {
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Utility function to check if the value is already Base64
|
// Utility function to check if the value is already Base64
|
||||||
function isBase64(value) {
|
function isBase64(value) {
|
||||||
const base64Regex =
|
if (typeof value !== "string" || value.trim() === "") return false;
|
||||||
/^(?:[A-Za-z0-9+\/]{4})*?(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/;
|
|
||||||
return base64Regex.test(value);
|
// Must have valid length
|
||||||
|
if (value.length % 4 !== 0) return false;
|
||||||
|
|
||||||
|
// Valid Base64 characters
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
if (!base64Regex.test(value)) return false;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = atob(value);
|
||||||
|
|
||||||
|
// Re-encode
|
||||||
|
const reencoded = btoa(decoded);
|
||||||
|
|
||||||
|
if (reencoded !== value) return false;
|
||||||
|
|
||||||
|
// Extra verification:
|
||||||
|
// Ensure decoding didn't silently drop bytes (atob bug)
|
||||||
|
// Encode raw bytes: check if large char codes exist (invalid UTF-16)
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
const code = decoded.charCodeAt(i);
|
||||||
|
if (code > 255) return false; // invalid binary byte
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
function isValidJSON(jsonString) {
|
function isValidJSON(jsonString) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -462,10 +462,17 @@
|
|||||||
|
|
||||||
switch (orderTopologyBy[0]) {
|
switch (orderTopologyBy[0]) {
|
||||||
case "Name":
|
case "Name":
|
||||||
const nameCompare = a.devName.localeCompare(b.devName);
|
// ensuring string
|
||||||
return nameCompare !== 0 ? nameCompare : parsePort(a.devParentPort) - parsePort(b.devParentPort);
|
const nameA = (a.devName ?? "").toString();
|
||||||
|
const nameB = (b.devName ?? "").toString();
|
||||||
|
const nameCompare = nameA.localeCompare(nameB);
|
||||||
|
return nameCompare !== 0
|
||||||
|
? nameCompare
|
||||||
|
: parsePort(a.devParentPort) - parsePort(b.devParentPort);
|
||||||
|
|
||||||
case "Port":
|
case "Port":
|
||||||
return parsePort(a.devParentPort) - parsePort(b.devParentPort);
|
return parsePort(a.devParentPort) - parsePort(b.devParentPort);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return a.rowid - b.rowid;
|
return a.rowid - b.rowid;
|
||||||
}
|
}
|
||||||
|
|||||||
2
front/php/templates/language/ar_ar.json
Executable file → Normal file
2
front/php/templates/language/ar_ar.json
Executable file → Normal file
@@ -761,4 +761,4 @@
|
|||||||
"settings_system_label": "تسمية النظام",
|
"settings_system_label": "تسمية النظام",
|
||||||
"settings_update_item_warning": "تحذير تحديث العنصر",
|
"settings_update_item_warning": "تحذير تحديث العنصر",
|
||||||
"test_event_tooltip": "تلميح اختبار الحدث"
|
"test_event_tooltip": "تلميح اختبار الحدث"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from const import confFileName, logPath
|
|||||||
from utils.datetime_utils import timeNowDB
|
from utils.datetime_utils import timeNowDB
|
||||||
from plugin_helper import Plugin_Objects
|
from plugin_helper import Plugin_Objects
|
||||||
from logger import mylog, Logger
|
from logger import mylog, Logger
|
||||||
from helper import timeNowTZ, get_setting_value
|
from helper import get_setting_value
|
||||||
from models.notification_instance import NotificationInstance
|
from models.notification_instance import NotificationInstance
|
||||||
from database import DB
|
from database import DB
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from const import confFileName, logPath
|
|||||||
from plugin_helper import Plugin_Objects
|
from plugin_helper import Plugin_Objects
|
||||||
from utils.datetime_utils import timeNowDB
|
from utils.datetime_utils import timeNowDB
|
||||||
from logger import mylog, Logger
|
from logger import mylog, Logger
|
||||||
from helper import timeNowTZ, get_setting_value, hide_email
|
from helper import get_setting_value, hide_email
|
||||||
from models.notification_instance import NotificationInstance
|
from models.notification_instance import NotificationInstance
|
||||||
from database import DB
|
from database import DB
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
|
|||||||
@@ -419,6 +419,41 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"function": "IP_MATCH_NAME",
|
||||||
|
"type": {
|
||||||
|
"dataType": "boolean",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [
|
||||||
|
{
|
||||||
|
"type": "checkbox"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": true,
|
||||||
|
"options": [],
|
||||||
|
"localized": [
|
||||||
|
"name",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Name IP match"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "If checked, the application will guess the name also by IPs, not only MACs. This approach works if your IPs are mostly static."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"function": "replace_preset_icon",
|
"function": "replace_preset_icon",
|
||||||
"type": {
|
"type": {
|
||||||
|
|||||||
133
front/plugins/pihole_api_scan/README.md
Normal file
133
front/plugins/pihole_api_scan/README.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
## Overview - PIHOLEAPI Plugin — Pi-hole v6 Device Import
|
||||||
|
|
||||||
|
The **PIHOLEAPI** plugin lets NetAlertX import network devices directly from a **Pi-hole v6** instance.
|
||||||
|
This turns Pi-hole into an additional discovery source, helping NetAlertX stay aware of devices seen by your DNS server.
|
||||||
|
|
||||||
|
The plugin connects to your Pi-hole’s API and retrieves:
|
||||||
|
|
||||||
|
* MAC addresses
|
||||||
|
* IP addresses
|
||||||
|
* Hostnames (if available)
|
||||||
|
* Vendor info
|
||||||
|
* Last-seen timestamps
|
||||||
|
|
||||||
|
NetAlertX then uses this information to match or create devices in your system.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Some tip.
|
||||||
|
|
||||||
|
### Quick setup guide
|
||||||
|
|
||||||
|
* You are running **Pi-hole v6** or newer.
|
||||||
|
* The Web UI password in **Pi-hole** is set.
|
||||||
|
* Local network devices appear under **Settings → Network** in Pi-hole.
|
||||||
|
|
||||||
|
No additional Pi-hole configuration is required.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
- Head to **Settings** > **Plugin name** to adjust the default values.
|
||||||
|
|
||||||
|
| Setting Key | Description |
|
||||||
|
| ---------------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| **PIHOLEAPI_URL** | Your Pi-hole base URL. |
|
||||||
|
| **PIHOLEAPI_PASSWORD** | The Web UI base64 encoded (en-/decoding handled by the app) admin password. |
|
||||||
|
| **PIHOLEAPI_SSL_VERIFY** | Whether to verify HTTPS certificates. Disable only for self-signed certificates. |
|
||||||
|
| **PIHOLEAPI_RUN_TIMEOUT** | Request timeout in seconds. |
|
||||||
|
| **PIHOLEAPI_API_MAXCLIENTS** | Maximum number of devices to request from Pi-hole. Defaults are usually fine. |
|
||||||
|
|
||||||
|
### Example Configuration
|
||||||
|
|
||||||
|
| Setting Key | Sample Value |
|
||||||
|
| ---------------------------- | -------------------------------------------------- |
|
||||||
|
| **PIHOLEAPI_URL** | `http://pi.hole/` |
|
||||||
|
| **PIHOLEAPI_PASSWORD** | `passw0rd` |
|
||||||
|
| **PIHOLEAPI_SSL_VERIFY** | `true` |
|
||||||
|
| **PIHOLEAPI_RUN_TIMEOUT** | `30` |
|
||||||
|
| **PIHOLEAPI_API_MAXCLIENTS** | `500` |
|
||||||
|
|
||||||
|
### ⚠️ Troubleshooting
|
||||||
|
|
||||||
|
Below are the most common issues and how to resolve them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### ❌ Authentication failed
|
||||||
|
|
||||||
|
Check the following:
|
||||||
|
|
||||||
|
* The Pi-hole URL is correct and includes a trailing slash
|
||||||
|
|
||||||
|
* `http://192.168.1.10/` ✔
|
||||||
|
* `http://192.168.1.10/admin` ❌
|
||||||
|
* Your Pi-hole password is correct
|
||||||
|
* You are using **Pi-hole v6**, not v5
|
||||||
|
* SSL verification matches your setup (disable for self-signed certificates)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### ❌ Connection error
|
||||||
|
|
||||||
|
Usually caused by:
|
||||||
|
|
||||||
|
* Wrong URL
|
||||||
|
* Wrong HTTP/HTTPS selection
|
||||||
|
* Timeout too low
|
||||||
|
|
||||||
|
Try:
|
||||||
|
|
||||||
|
```
|
||||||
|
PIHOLEAPI_URL = http://<pi-hole-ip>/
|
||||||
|
PIHOLEAPI_RUN_TIMEOUT = 60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### ❌ No devices imported
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
* Pi-hole shows devices under **Settings → Network**
|
||||||
|
* NetAlertX logs contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
[PIHOLEAPI] Pi-hole API returned data
|
||||||
|
```
|
||||||
|
|
||||||
|
If nothing appears:
|
||||||
|
|
||||||
|
* Pi-hole might be returning empty results
|
||||||
|
* Your network interface list may be empty
|
||||||
|
* A firewall or reverse proxy is blocking access
|
||||||
|
|
||||||
|
Try enabling debug logging:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOG_LEVEL = debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-run the plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### ❌ Wrong or missing hostnames
|
||||||
|
|
||||||
|
Pi-hole only reports names it knows from:
|
||||||
|
|
||||||
|
* Local DNS
|
||||||
|
* DHCP leases
|
||||||
|
* Previously seen queries
|
||||||
|
|
||||||
|
If names are missing, confirm they appear in Pi-hole’s own UI first.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Additional notes, limitations, Author info.
|
||||||
|
|
||||||
|
- Version: 1.0.0
|
||||||
|
- Author: `jokob-sk`, `leiweibau`
|
||||||
|
- Release Date: `11-2025`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
476
front/plugins/pihole_api_scan/config.json
Normal file
476
front/plugins/pihole_api_scan/config.json
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
{
|
||||||
|
"code_name": "pihole_api_scan",
|
||||||
|
"unique_prefix": "PIHOLEAPI",
|
||||||
|
"plugin_type": "device_scanner",
|
||||||
|
"execution_order" : "Layer_0",
|
||||||
|
"enabled": true,
|
||||||
|
"data_source": "script",
|
||||||
|
"mapped_to_table": "CurrentScan",
|
||||||
|
"data_filters": [
|
||||||
|
{
|
||||||
|
"compare_column": "Object_PrimaryID",
|
||||||
|
"compare_operator": "==",
|
||||||
|
"compare_field_id": "txtMacFilter",
|
||||||
|
"compare_js_template": "'{value}'.toString()",
|
||||||
|
"compare_use_quotes": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"show_ui": true,
|
||||||
|
"localized": ["display_name", "description", "icon"],
|
||||||
|
"display_name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "PiHole API scan"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Imports devices from PiHole via APIv6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "<i class=\"fa fa-search\"></i>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"params": [],
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"function": "RUN",
|
||||||
|
"events": ["run"],
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{ "elementType": "select", "elementOptions": [], "transformers": [] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"default_value": "disabled",
|
||||||
|
"options": [
|
||||||
|
"disabled",
|
||||||
|
"once",
|
||||||
|
"schedule",
|
||||||
|
"always_after_scan"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "When to run"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "When the plugin should run. Good options are <code>always_after_scan</code>, <code>schedule</code>."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "RUN_SCHD",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "span",
|
||||||
|
"elementOptions": [
|
||||||
|
{
|
||||||
|
"cssClasses": "input-group-addon validityCheck"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"getStringKey": "Gen_ValidIcon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformers": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [
|
||||||
|
{
|
||||||
|
"onChange": "validateRegex(this)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "*/5 * * * *",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Schedule"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Only enabled if you select <code>schedule</code> in the <a href=\"#SYNC_RUN\"><code>SYNC_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. Will be run NEXT time the time passes."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "URL",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{ "elementType": "input", "elementOptions": [], "transformers": [] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"maxLength": 50,
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Setting name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "URL to your PiHole instance, for example <code>http://pi.hole:8080/</code>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "PASSWORD",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "type": "password" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "PiHole WEB UI password."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "VERIFY_SSL",
|
||||||
|
"type": {
|
||||||
|
"dataType": "boolean",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "type": "checkbox" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": false,
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Verify SSL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Enable TLS support. Disable if you are using a self-signed certificate."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "API_MAXCLIENTS",
|
||||||
|
"type": {
|
||||||
|
"dataType": "integer",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "type": "number" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": 500,
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Max Clients"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Maximum number of devices to import."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "CMD",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "readonly": "true" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "python3 /app/front/plugins/pihole_api_scan/pihole_api_scan.py",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Command to run. This can not be changed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "RUN_TIMEOUT",
|
||||||
|
"type": {
|
||||||
|
"dataType": "integer",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "type": "number" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": 30,
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Run timeout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"database_column_definitions": [
|
||||||
|
{
|
||||||
|
"column": "Index",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "none",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Index"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Object_PrimaryID",
|
||||||
|
"mapped_to_column": "cur_MAC",
|
||||||
|
"css_classes": "col-sm-3",
|
||||||
|
"show": true,
|
||||||
|
"type": "device_name_mac",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "MAC (name)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Object_SecondaryID",
|
||||||
|
"mapped_to_column": "cur_IP",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "device_ip",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "IP"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Watched_Value1",
|
||||||
|
"mapped_to_column": "cur_Name",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Name"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Watched_Value2",
|
||||||
|
"mapped_to_column": "cur_Vendor",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Vendor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Watched_Value3",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Last Query"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Watched_Value4",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": false,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "N/A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Dummy",
|
||||||
|
"mapped_to_column": "cur_ScanMethod",
|
||||||
|
"mapped_to_column_data": {
|
||||||
|
"value": "PIHOLEAPI"
|
||||||
|
},
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": false,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Scan method"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "DateTimeCreated",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Created"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "DateTimeChanged",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Changed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Status",
|
||||||
|
"css_classes": "col-sm-1",
|
||||||
|
"show": true,
|
||||||
|
"type": "replace",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"equals": "watched-not-changed",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "watched-changed",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "new",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "missing-in-last-scan",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Status"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
298
front/plugins/pihole_api_scan/pihole_api_scan.py
Normal file
298
front/plugins/pihole_api_scan/pihole_api_scan.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
NetAlertX plugin: PIHOLEAPI
|
||||||
|
Imports devices from Pi-hole v6 API (Network endpoints) into NetAlertX plugin results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import datetime
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from requests.packages.urllib3.exceptions import InsecureRequestWarning
|
||||||
|
|
||||||
|
# --- NetAlertX plugin bootstrap (match example) ---
|
||||||
|
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
|
||||||
|
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
||||||
|
|
||||||
|
pluginName = 'PIHOLEAPI'
|
||||||
|
|
||||||
|
from plugin_helper import Plugin_Objects, is_mac
|
||||||
|
from logger import mylog, Logger
|
||||||
|
from helper import get_setting_value
|
||||||
|
from const import logPath
|
||||||
|
import conf
|
||||||
|
from pytz import timezone
|
||||||
|
|
||||||
|
# Setup timezone & logger using standard NAX helpers
|
||||||
|
conf.tz = timezone(get_setting_value('TIMEZONE'))
|
||||||
|
Logger(get_setting_value('LOG_LEVEL'))
|
||||||
|
|
||||||
|
LOG_PATH = logPath + '/plugins'
|
||||||
|
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
|
||||||
|
|
||||||
|
plugin_objects = Plugin_Objects(RESULT_FILE)
|
||||||
|
|
||||||
|
# --- Global state for session ---
|
||||||
|
PIHOLEAPI_URL = None
|
||||||
|
PIHOLEAPI_PASSWORD = None
|
||||||
|
PIHOLEAPI_SES_VALID = False
|
||||||
|
PIHOLEAPI_SES_SID = None
|
||||||
|
PIHOLEAPI_SES_CSRF = None
|
||||||
|
PIHOLEAPI_API_MAXCLIENTS = None
|
||||||
|
PIHOLEAPI_VERIFY_SSL = True
|
||||||
|
PIHOLEAPI_RUN_TIMEOUT = 10
|
||||||
|
VERSION_DATE = "NAX-PIHOLEAPI-1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def pihole_api_auth():
|
||||||
|
"""Authenticate to Pi-hole v6 API and populate session globals."""
|
||||||
|
|
||||||
|
global PIHOLEAPI_SES_VALID, PIHOLEAPI_SES_SID, PIHOLEAPI_SES_CSRF
|
||||||
|
|
||||||
|
if not PIHOLEAPI_URL:
|
||||||
|
mylog('none', [f'[{pluginName}] PIHOLEAPI_URL not configured — skipping.'])
|
||||||
|
return False
|
||||||
|
|
||||||
|
# handle SSL verification setting - disable insecure warnings only when PIHOLEAPI_VERIFY_SSL=False
|
||||||
|
if not PIHOLEAPI_VERIFY_SSL:
|
||||||
|
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"User-Agent": "NetAlertX/" + VERSION_DATE
|
||||||
|
}
|
||||||
|
data = {"password": PIHOLEAPI_PASSWORD}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(PIHOLEAPI_URL + 'api/auth', headers=headers, json=data, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
mylog('none', [f'[{pluginName}] Pi-hole auth request timed out. Try increasing PIHOLEAPI_RUN_TIMEOUT.'])
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
mylog('none', [f'[{pluginName}] Connection error during Pi-hole auth. Check PIHOLEAPI_URL and PIHOLEAPI_PASSWORD'])
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
mylog('none', [f'[{pluginName}] Unexpected auth error: {e}'])
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_json = resp.json()
|
||||||
|
except Exception:
|
||||||
|
mylog('none', [f'[{pluginName}] Unable to parse Pi-hole auth response JSON.'])
|
||||||
|
return False
|
||||||
|
|
||||||
|
session_data = response_json.get('session', {})
|
||||||
|
|
||||||
|
if session_data.get('valid', False):
|
||||||
|
PIHOLEAPI_SES_VALID = True
|
||||||
|
PIHOLEAPI_SES_SID = session_data.get('sid')
|
||||||
|
# csrf might not be present if no password set
|
||||||
|
PIHOLEAPI_SES_CSRF = session_data.get('csrf')
|
||||||
|
mylog('verbose', [f'[{pluginName}] Authenticated to Pi-hole (sid present).'])
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
mylog('none', [f'[{pluginName}] Pi-hole auth required or failed.'])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def pihole_api_deauth():
|
||||||
|
"""Logout from Pi-hole v6 API (best-effort)."""
|
||||||
|
global PIHOLEAPI_SES_VALID, PIHOLEAPI_SES_SID, PIHOLEAPI_SES_CSRF
|
||||||
|
|
||||||
|
if not PIHOLEAPI_URL:
|
||||||
|
return
|
||||||
|
if not PIHOLEAPI_SES_SID:
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {"X-FTL-SID": PIHOLEAPI_SES_SID}
|
||||||
|
try:
|
||||||
|
requests.delete(PIHOLEAPI_URL + 'api/auth', headers=headers, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
# ignore errors on logout
|
||||||
|
pass
|
||||||
|
PIHOLEAPI_SES_VALID = False
|
||||||
|
PIHOLEAPI_SES_SID = None
|
||||||
|
PIHOLEAPI_SES_CSRF = None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_pihole_interface_data():
|
||||||
|
"""Return dict mapping mac -> [ipv4 addresses] from Pi-hole interfaces endpoint."""
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
if not PIHOLEAPI_SES_VALID:
|
||||||
|
return result
|
||||||
|
|
||||||
|
headers = {"X-FTL-SID": PIHOLEAPI_SES_SID}
|
||||||
|
if PIHOLEAPI_SES_CSRF:
|
||||||
|
headers["X-FTL-CSRF"] = PIHOLEAPI_SES_CSRF
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(PIHOLEAPI_URL + 'api/network/interfaces', headers=headers, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
mylog('none', [f'[{pluginName}] Failed to fetch Pi-hole interfaces: {e}'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
for interface in data.get('interfaces', []):
|
||||||
|
mac_address = interface.get('address')
|
||||||
|
if not mac_address or mac_address == "00:00:00:00:00:00":
|
||||||
|
continue
|
||||||
|
addrs = []
|
||||||
|
for addr in interface.get('addresses', []):
|
||||||
|
if addr.get('family') == 'inet':
|
||||||
|
a = addr.get('address')
|
||||||
|
if a:
|
||||||
|
addrs.append(a)
|
||||||
|
if addrs:
|
||||||
|
result[mac_address] = addrs
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_pihole_network_devices():
|
||||||
|
"""Return list of devices from Pi-hole v6 API (devices endpoint)."""
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
|
||||||
|
# return empty list if no session available
|
||||||
|
if not PIHOLEAPI_SES_VALID:
|
||||||
|
return devices
|
||||||
|
|
||||||
|
# prepare headers
|
||||||
|
headers = {"X-FTL-SID": PIHOLEAPI_SES_SID}
|
||||||
|
if PIHOLEAPI_SES_CSRF:
|
||||||
|
headers["X-FTL-CSRF"] = PIHOLEAPI_SES_CSRF
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'max_devices': str(PIHOLEAPI_API_MAXCLIENTS),
|
||||||
|
'max_addresses': '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(PIHOLEAPI_URL + 'api/network/devices', headers=headers, params=params, verify=PIHOLEAPI_VERIFY_SSL, timeout=PIHOLEAPI_RUN_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
mylog('debug', [f'[{pluginName}] Pi-hole API returned data: {json.dumps(data)}'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
mylog('none', [f'[{pluginName}] Failed to fetch Pi-hole devices: {e}'])
|
||||||
|
return devices
|
||||||
|
|
||||||
|
# The API returns 'devices' list
|
||||||
|
return data.get('devices', [])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def gather_device_entries():
|
||||||
|
"""
|
||||||
|
Build a list of device entries suitable for Plugin_Objects.add_object.
|
||||||
|
Each entry is a dict with: mac, ip, name, macVendor, lastQuery
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
iface_map = get_pihole_interface_data()
|
||||||
|
devices = get_pihole_network_devices()
|
||||||
|
now_ts = int(datetime.datetime.now().timestamp())
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
hwaddr = device.get('hwaddr')
|
||||||
|
if not hwaddr or hwaddr == "00:00:00:00:00:00":
|
||||||
|
continue
|
||||||
|
|
||||||
|
macVendor = device.get('macVendor', '')
|
||||||
|
lastQuery = device.get('lastQuery')
|
||||||
|
# 'ips' is a list of dicts: {ip, name}
|
||||||
|
for ip_info in device.get('ips', []):
|
||||||
|
ip = ip_info.get('ip')
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = ip_info.get('name') or '(unknown)'
|
||||||
|
|
||||||
|
# mark active if ip present on local interfaces
|
||||||
|
for mac, iplist in iface_map.items():
|
||||||
|
if ip in iplist:
|
||||||
|
lastQuery = str(now_ts)
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
'mac': hwaddr.lower(),
|
||||||
|
'ip': ip,
|
||||||
|
'name': name,
|
||||||
|
'macVendor': macVendor,
|
||||||
|
'lastQuery': str(lastQuery) if lastQuery is not None else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
"""Main plugin entrypoint."""
|
||||||
|
global PIHOLEAPI_URL, PIHOLEAPI_PASSWORD, PIHOLEAPI_API_MAXCLIENTS, PIHOLEAPI_VERIFY_SSL, PIHOLEAPI_RUN_TIMEOUT
|
||||||
|
|
||||||
|
mylog('verbose', [f'[{pluginName}] start script.'])
|
||||||
|
|
||||||
|
# Load settings from NAX config
|
||||||
|
PIHOLEAPI_URL = get_setting_value('PIHOLEAPI_URL')
|
||||||
|
|
||||||
|
# ensure trailing slash
|
||||||
|
if not PIHOLEAPI_URL.endswith('/'):
|
||||||
|
PIHOLEAPI_URL += '/'
|
||||||
|
|
||||||
|
PIHOLEAPI_PASSWORD = get_setting_value('PIHOLEAPI_PASSWORD')
|
||||||
|
PIHOLEAPI_API_MAXCLIENTS = get_setting_value('PIHOLEAPI_API_MAXCLIENTS')
|
||||||
|
# Accept boolean or string "True"/"False"
|
||||||
|
PIHOLEAPI_VERIFY_SSL = get_setting_value('PIHOLEAPI_SSL_VERIFY')
|
||||||
|
PIHOLEAPI_RUN_TIMEOUT = get_setting_value('PIHOLEAPI_RUN_TIMEOUT')
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
if not pihole_api_auth():
|
||||||
|
mylog('none', [f'[{pluginName}] Authentication failed — no devices imported.'])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_entries = gather_device_entries()
|
||||||
|
|
||||||
|
if not device_entries:
|
||||||
|
mylog('verbose', [f'[{pluginName}] No devices found on Pi-hole.'])
|
||||||
|
else:
|
||||||
|
for entry in device_entries:
|
||||||
|
|
||||||
|
if is_mac(entry['mac']):
|
||||||
|
# Map to Plugin_Objects fields
|
||||||
|
mylog('verbose', [f'[{pluginName}] found: {entry['name']}|{entry['mac']}|{entry['ip']}'])
|
||||||
|
|
||||||
|
plugin_objects.add_object(
|
||||||
|
primaryId=str(entry['mac']),
|
||||||
|
secondaryId=str(entry['ip']),
|
||||||
|
watched1=str(entry['name']),
|
||||||
|
watched2=str(entry['macVendor']),
|
||||||
|
watched3=str(entry['lastQuery']),
|
||||||
|
watched4="",
|
||||||
|
extra=pluginName,
|
||||||
|
foreignKey=str(entry['mac'])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mylog('verbose', [f'[{pluginName}] Skipping invalid MAC: {entry['name']}|{entry['mac']}|{entry['ip']}'])
|
||||||
|
|
||||||
|
# Write result file for NetAlertX to ingest
|
||||||
|
plugin_objects.write_result_file()
|
||||||
|
mylog('verbose', [f'[{pluginName}] Script finished. Imported {len(device_entries)} entries.'])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Deauth best-effort
|
||||||
|
pihole_api_deauth()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -44,3 +44,4 @@ More Info:
|
|||||||
|
|
||||||
Report Date: 2021-12-08 12:30
|
Report Date: 2021-12-08 12:30
|
||||||
Server: Synology-NAS
|
Server: Synology-NAS
|
||||||
|
Link: netalertx.com
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
<!--
|
|
||||||
#---------------------------------------------------------------------------------#
|
|
||||||
# NetAlertX #
|
|
||||||
# Open Source Network Guard / WIFI & LAN intrusion detector #
|
|
||||||
# #
|
|
||||||
# report_template.html - Back module. Template to email reporting in HTML format #
|
|
||||||
#---------------------------------------------------------------------------------#
|
|
||||||
-->
|
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
<head></head>
|
<head></head>
|
||||||
<body>
|
<body>
|
||||||
@@ -20,11 +11,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td height=200 valign=top style="padding: 10px">
|
<td height=200 valign=top style="padding: 10px">
|
||||||
<NEW_DEVICES_TABLE>
|
NEW_DEVICES_TABLE
|
||||||
<DOWN_DEVICES_TABLE>
|
DOWN_DEVICES_TABLE
|
||||||
<DOWN_RECONNECTED_TABLE>
|
DOWN_RECONNECTED_TABLE
|
||||||
<EVENTS_TABLE>
|
EVENTS_TABLE
|
||||||
<PLUGINS_TABLE>
|
PLUGINS_TABLE
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -34,11 +25,11 @@
|
|||||||
<table width=100% bgcolor=#3c8dbc cellpadding=5px cellspacing=0 style="font-size: 10px; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px;">
|
<table width=100% bgcolor=#3c8dbc cellpadding=5px cellspacing=0 style="font-size: 10px; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width=50% style="text-align:center;color: white;" bgcolor="#3c8dbc">
|
<td width=50% style="text-align:center;color: white;" bgcolor="#3c8dbc">
|
||||||
<NEW_VERSION>
|
NEW_VERSION
|
||||||
| Sent: <REPORT_DATE>
|
| Sent: REPORT_DATE
|
||||||
| Server: <SERVER_NAME>
|
| Server: <a href="REPORT_DASHBOARD_URL" target="_blank" style="color:#ffffff;">SERVER_NAME</a>
|
||||||
| Built: <BUILD_DATE>
|
| Built: BUILD_DATE
|
||||||
| Version: <BUILD_VERSION>
|
| Version: BUILD_VERSION
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<NEW_DEVICES_TABLE>
|
NEW_DEVICES_TABLE
|
||||||
<DOWN_DEVICES_TABLE>
|
DOWN_DEVICES_TABLE
|
||||||
<DOWN_RECONNECTED_TABLE>
|
DOWN_RECONNECTED_TABLE
|
||||||
<EVENTS_TABLE>
|
EVENTS_TABLE
|
||||||
<PLUGINS_TABLE>
|
PLUGINS_TABLE
|
||||||
|
|
||||||
Report Date: <REPORT_DATE>
|
Report Date: REPORT_DATE
|
||||||
Server: <SERVER_NAME>
|
Server: SERVER_NAME
|
||||||
<NEW_VERSION>
|
Link: REPORT_DASHBOARD_URL
|
||||||
|
NEW_VERSION
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo "Initializing php-fpm..."
|
echo "Initializing php-fpm..."
|
||||||
# Set up PHP-FPM directories and socket configuration
|
# Set up PHP-FPM directories and socket configuration
|
||||||
install -d -o netalertx -g netalertx /services/config/run
|
|
||||||
|
|
||||||
|
|
||||||
echo "php-fpm initialized."
|
echo "php-fpm initialized."
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/tmp/nginx/active-config
|
|
||||||
@@ -5,8 +5,6 @@ set -euo pipefail
|
|||||||
LOG_DIR=${NETALERTX_LOG}
|
LOG_DIR=${NETALERTX_LOG}
|
||||||
RUN_DIR=${SYSTEM_SERVICES_RUN}
|
RUN_DIR=${SYSTEM_SERVICES_RUN}
|
||||||
TMP_DIR=/tmp/nginx
|
TMP_DIR=/tmp/nginx
|
||||||
SYSTEM_NGINX_CONFIG_TEMPLATE="/services/config/nginx/netalertx.conf.template"
|
|
||||||
SYSTEM_NGINX_CONFIG_FILE="/services/config/nginx/conf.active/netalertx.conf"
|
|
||||||
|
|
||||||
# Create directories if they don't exist
|
# Create directories if they don't exist
|
||||||
mkdir -p "${LOG_DIR}" "${RUN_DIR}" "${TMP_DIR}"
|
mkdir -p "${LOG_DIR}" "${RUN_DIR}" "${TMP_DIR}"
|
||||||
@@ -33,9 +31,9 @@ done
|
|||||||
|
|
||||||
TEMP_CONFIG_FILE=$(mktemp "${TMP_DIR}/netalertx.conf.XXXXXX")
|
TEMP_CONFIG_FILE=$(mktemp "${TMP_DIR}/netalertx.conf.XXXXXX")
|
||||||
if envsubst '${LISTEN_ADDR} ${PORT}' < "${SYSTEM_NGINX_CONFIG_TEMPLATE}" > "${TEMP_CONFIG_FILE}" 2>/dev/null; then
|
if envsubst '${LISTEN_ADDR} ${PORT}' < "${SYSTEM_NGINX_CONFIG_TEMPLATE}" > "${TEMP_CONFIG_FILE}" 2>/dev/null; then
|
||||||
mv "${TEMP_CONFIG_FILE}" "${SYSTEM_NGINX_CONFIG_FILE}"
|
mv "${TEMP_CONFIG_FILE}" "${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}"
|
||||||
else
|
else
|
||||||
echo "Note: Unable to write to ${SYSTEM_NGINX_CONFIG_FILE}. Using default configuration."
|
echo "Note: Unable to write to ${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}. Using default configuration."
|
||||||
rm -f "${TEMP_CONFIG_FILE}"
|
rm -f "${TEMP_CONFIG_FILE}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -49,10 +47,10 @@ chmod -R 777 "/tmp/nginx" 2>/dev/null || true
|
|||||||
|
|
||||||
# Execute nginx with overrides
|
# Execute nginx with overrides
|
||||||
# echo the full nginx command then run it
|
# echo the full nginx command then run it
|
||||||
echo "Starting /usr/sbin/nginx -p \"${RUN_DIR}/\" -c \"${SYSTEM_NGINX_CONFIG_FILE}\" -g \"error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;\" &"
|
echo "Starting /usr/sbin/nginx -p \"${RUN_DIR}/\" -c \"${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}\" -g \"error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;\" &"
|
||||||
/usr/sbin/nginx \
|
/usr/sbin/nginx \
|
||||||
-p "${RUN_DIR}/" \
|
-p "${RUN_DIR}/" \
|
||||||
-c "${SYSTEM_NGINX_CONFIG_FILE}" \
|
-c "${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}" \
|
||||||
-g "error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;" &
|
-g "error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;" &
|
||||||
nginx_pid=$!
|
nginx_pid=$!
|
||||||
|
|
||||||
|
|||||||
@@ -154,26 +154,24 @@ def main():
|
|||||||
# Name resolution
|
# Name resolution
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
# run plugins before notification processing (e.g. Plugins to discover device names)
|
# Check if new devices found (created by process_scan)
|
||||||
pm.run_plugin_scripts("before_name_updates")
|
|
||||||
|
|
||||||
# Resolve devices names
|
|
||||||
mylog("debug", "[Main] Resolve devices names")
|
|
||||||
update_devices_names(pm)
|
|
||||||
|
|
||||||
# --------
|
|
||||||
# Reporting
|
|
||||||
|
|
||||||
# Check if new devices found
|
|
||||||
sql.execute(sql_new_devices)
|
sql.execute(sql_new_devices)
|
||||||
newDevices = sql.fetchall()
|
newDevices = sql.fetchall()
|
||||||
db.commitDB()
|
db.commitDB()
|
||||||
|
|
||||||
# new devices were found
|
# If new devices were found, run all plugins registered to be run when new devices are found
|
||||||
|
# Run these before name resolution so plugins like NSLOOKUP that are configured
|
||||||
|
# for `on_new_device` can populate names used in the notifications below.
|
||||||
if len(newDevices) > 0:
|
if len(newDevices) > 0:
|
||||||
# run all plugins registered to be run when new devices are found
|
|
||||||
pm.run_plugin_scripts("on_new_device")
|
pm.run_plugin_scripts("on_new_device")
|
||||||
|
|
||||||
|
# run plugins before notification processing (e.g. Plugins to discover device names)
|
||||||
|
pm.run_plugin_scripts("before_name_updates")
|
||||||
|
|
||||||
|
# Resolve devices names (will pick up results from on_new_device plugins above)
|
||||||
|
mylog("debug", "[Main] Resolve devices names")
|
||||||
|
update_devices_names(pm)
|
||||||
|
|
||||||
# Notification handling
|
# Notification handling
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ def graphql_endpoint():
|
|||||||
if not is_authorized():
|
if not is_authorized():
|
||||||
msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.'
|
msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.'
|
||||||
mylog('verbose', [msg])
|
mylog('verbose', [msg])
|
||||||
return jsonify({"success": False, "message": msg}), 401
|
return jsonify({"success": False, "message": msg, "error": "Forbidden"}), 401
|
||||||
|
|
||||||
# Retrieve and log request data
|
# Retrieve and log request data
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
|||||||
|
|
||||||
def handle_sync_get():
|
def handle_sync_get():
|
||||||
"""Handle GET requests for SYNC (NODE → HUB)."""
|
"""Handle GET requests for SYNC (NODE → HUB)."""
|
||||||
file_path = INSTALL_PATH + "/api/table_devices.json"
|
|
||||||
|
# get all dwevices from the api endpoint
|
||||||
|
api_path = os.environ.get('NETALERTX_API', '/tmp/api')
|
||||||
|
|
||||||
|
file_path = f"/{api_path}/table_devices.json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ def importConfigs(pm, db, all_plugins):
|
|||||||
# Check if app was upgraded
|
# Check if app was upgraded
|
||||||
|
|
||||||
buildTimestamp, new_version = getBuildTimeStampAndVersion()
|
buildTimestamp, new_version = getBuildTimeStampAndVersion()
|
||||||
prev_version = conf.VERSION
|
prev_version = conf.VERSION if conf.VERSION != '' else "unknown"
|
||||||
|
|
||||||
mylog('debug', [f"[Config] buildTimestamp | prev_version | .VERSION file: '{buildTimestamp}|{prev_version}|{new_version}'"])
|
mylog('debug', [f"[Config] buildTimestamp | prev_version | .VERSION file: '{buildTimestamp}|{prev_version}|{new_version}'"])
|
||||||
|
|
||||||
@@ -684,7 +684,7 @@ def importConfigs(pm, db, all_plugins):
|
|||||||
# ccd(key, default, config_dir, name, inputtype, options, group, events=None, desc="", setJsonMetadata=None, overrideTemplate=None, forceDefault=False)
|
# ccd(key, default, config_dir, name, inputtype, options, group, events=None, desc="", setJsonMetadata=None, overrideTemplate=None, forceDefault=False)
|
||||||
ccd('VERSION', new_version , c_d, '_KEEP_', '_KEEP_', '_KEEP_', '_KEEP_', None, "_KEEP_", None, None, True)
|
ccd('VERSION', new_version , c_d, '_KEEP_', '_KEEP_', '_KEEP_', '_KEEP_', None, "_KEEP_", None, None, True)
|
||||||
|
|
||||||
write_notification(f'[Upgrade] : App upgraded from {prev_version} to {new_version} 🚀 Please clear the cache: <ol> <li>Click OK below</li> <li>Clear the browser cache (shift + browser refresh button)</li> <li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li><li>Go to Settings and click Save</li> </ol> Check out new features and what has changed in the <a href="https://github.com/jokob-sk/NetAlertX/releases" target="_blank">📓 release notes</a>.', 'interrupt', timeNowDB())
|
write_notification(f'[Upgrade] : App upgraded from <code>{prev_version}</code> to <code>{new_version}</code> 🚀 Please clear the cache: <ol> <li>Click OK below</li> <li>Clear the browser cache (shift + browser refresh button)</li> <li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li><li>Go to Settings and click Save</li> </ol> Check out new features and what has changed in the <a href="https://github.com/jokob-sk/NetAlertX/releases" target="_blank">📓 release notes</a>.', 'interrupt', timeNowDB())
|
||||||
|
|
||||||
|
|
||||||
# -----------------
|
# -----------------
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ sys.path.extend([f"{INSTALL_PATH}/server"])
|
|||||||
|
|
||||||
from const import apiPath
|
from const import apiPath
|
||||||
from logger import mylog
|
from logger import mylog
|
||||||
from helper import (
|
|
||||||
timeNowTZ,
|
|
||||||
)
|
|
||||||
|
|
||||||
import conf
|
import conf
|
||||||
from const import applicationPath, logPath, apiPath, confFileName, reportTemplatesPath
|
from const import applicationPath, logPath, apiPath, confFileName, reportTemplatesPath
|
||||||
@@ -23,6 +20,9 @@ from logger import mylog
|
|||||||
from utils.datetime_utils import timeNowDB
|
from utils.datetime_utils import timeNowDB
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATION_API_FILE = apiPath + 'user_notifications.json'
|
||||||
|
|
||||||
|
|
||||||
# Show Frontend User Notification
|
# Show Frontend User Notification
|
||||||
def write_notification(content, level="alert", timestamp=None):
|
def write_notification(content, level="alert", timestamp=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
|||||||
sys.path.extend([f"{INSTALL_PATH}/server"])
|
sys.path.extend([f"{INSTALL_PATH}/server"])
|
||||||
|
|
||||||
from helper import (
|
from helper import (
|
||||||
get_timezone_offset,
|
|
||||||
get_setting_value,
|
get_setting_value,
|
||||||
)
|
)
|
||||||
from logger import mylog
|
from logger import mylog
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from helper import (
|
|||||||
removeDuplicateNewLines,
|
removeDuplicateNewLines,
|
||||||
write_file,
|
write_file,
|
||||||
get_setting_value,
|
get_setting_value,
|
||||||
get_timezone_offset,
|
getBuildTimeStampAndVersion,
|
||||||
)
|
)
|
||||||
from messaging.in_app import write_notification
|
from messaging.in_app import write_notification
|
||||||
from utils.datetime_utils import timeNowDB, get_timezone_offset
|
from utils.datetime_utils import timeNowDB, get_timezone_offset
|
||||||
@@ -26,6 +26,7 @@ from utils.datetime_utils import timeNowDB, get_timezone_offset
|
|||||||
class NotificationInstance:
|
class NotificationInstance:
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.serverUrl = get_setting_value("REPORT_DASHBOARD_URL")
|
||||||
|
|
||||||
# Create Notifications table if missing
|
# Create Notifications table if missing
|
||||||
self.db.sql.execute("""CREATE TABLE IF NOT EXISTS "Notifications" (
|
self.db.sql.execute("""CREATE TABLE IF NOT EXISTS "Notifications" (
|
||||||
@@ -109,83 +110,71 @@ class NotificationInstance:
|
|||||||
if conf.newVersionAvailable:
|
if conf.newVersionAvailable:
|
||||||
newVersionText = "🚀A new version is available."
|
newVersionText = "🚀A new version is available."
|
||||||
|
|
||||||
mail_text = mail_text.replace("<NEW_VERSION>", newVersionText)
|
mail_text = mail_text.replace("NEW_VERSION", newVersionText)
|
||||||
mail_html = mail_html.replace("<NEW_VERSION>", newVersionText)
|
mail_html = mail_html.replace("NEW_VERSION", newVersionText)
|
||||||
|
|
||||||
# Report "REPORT_DATE" in Header & footer
|
# Report "REPORT_DATE" in Header & footer
|
||||||
timeFormated = timeNowDB()
|
timeFormated = timeNowDB()
|
||||||
mail_text = mail_text.replace('<REPORT_DATE>', timeFormated)
|
mail_text = mail_text.replace("REPORT_DATE", timeFormated)
|
||||||
mail_html = mail_html.replace('<REPORT_DATE>', timeFormated)
|
mail_html = mail_html.replace("REPORT_DATE", timeFormated)
|
||||||
|
|
||||||
# Report "SERVER_NAME" in Header & footer
|
# Report "SERVER_NAME" in Header & footer
|
||||||
mail_text = mail_text.replace("<SERVER_NAME>", socket.gethostname())
|
mail_text = mail_text.replace("SERVER_NAME", socket.gethostname())
|
||||||
mail_html = mail_html.replace("<SERVER_NAME>", socket.gethostname())
|
mail_html = mail_html.replace("SERVER_NAME", socket.gethostname())
|
||||||
|
|
||||||
# Report "VERSION" in Header & footer
|
# Report "VERSION" in Header & footer
|
||||||
try:
|
buildTimestamp, newBuildVersion = getBuildTimeStampAndVersion()
|
||||||
VERSIONFILE = subprocess.check_output(
|
|
||||||
["php", applicationPath + "/front/php/templates/version.php"],
|
|
||||||
timeout=5,
|
|
||||||
).decode("utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
mylog("debug", [f"[Notification] Unable to read version.php: {e}"])
|
|
||||||
VERSIONFILE = "unknown"
|
|
||||||
|
|
||||||
mail_text = mail_text.replace("<BUILD_VERSION>", VERSIONFILE)
|
mail_text = mail_text.replace("BUILD_VERSION", newBuildVersion)
|
||||||
mail_html = mail_html.replace("<BUILD_VERSION>", VERSIONFILE)
|
mail_html = mail_html.replace("BUILD_VERSION", newBuildVersion)
|
||||||
|
|
||||||
# Report "BUILD" in Header & footer
|
# Report "BUILD" in Header & footer
|
||||||
try:
|
mail_text = mail_text.replace("BUILD_DATE", str(buildTimestamp))
|
||||||
BUILDFILE = subprocess.check_output(
|
mail_html = mail_html.replace("BUILD_DATE", str(buildTimestamp))
|
||||||
["php", applicationPath + "/front/php/templates/build.php"],
|
|
||||||
timeout=5,
|
|
||||||
).decode("utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
mylog("debug", [f"[Notification] Unable to read build.php: {e}"])
|
|
||||||
BUILDFILE = "unknown"
|
|
||||||
|
|
||||||
mail_text = mail_text.replace("<BUILD_DATE>", BUILDFILE)
|
# Report "REPORT_DASHBOARD_URL" in footer
|
||||||
mail_html = mail_html.replace("<BUILD_DATE>", BUILDFILE)
|
mail_text = mail_text.replace("REPORT_DASHBOARD_URL", self.serverUrl)
|
||||||
|
mail_html = mail_html.replace("REPORT_DASHBOARD_URL", self.serverUrl)
|
||||||
|
|
||||||
# Start generating the TEXT & HTML notification messages
|
# Start generating the TEXT & HTML notification messages
|
||||||
# new_devices
|
# new_devices
|
||||||
# ---
|
# ---
|
||||||
html, text = construct_notifications(self.JSON, "new_devices")
|
html, text = construct_notifications(self.JSON, "new_devices")
|
||||||
|
|
||||||
mail_text = mail_text.replace("<NEW_DEVICES_TABLE>", text + "\n")
|
mail_text = mail_text.replace("NEW_DEVICES_TABLE", text + "\n")
|
||||||
mail_html = mail_html.replace("<NEW_DEVICES_TABLE>", html)
|
mail_html = mail_html.replace("NEW_DEVICES_TABLE", html)
|
||||||
mylog("verbose", ["[Notification] New Devices sections done."])
|
mylog("verbose", ["[Notification] New Devices sections done."])
|
||||||
|
|
||||||
# down_devices
|
# down_devices
|
||||||
# ---
|
# ---
|
||||||
html, text = construct_notifications(self.JSON, "down_devices")
|
html, text = construct_notifications(self.JSON, "down_devices")
|
||||||
|
|
||||||
mail_text = mail_text.replace("<DOWN_DEVICES_TABLE>", text + "\n")
|
mail_text = mail_text.replace("DOWN_DEVICES_TABLE", text + "\n")
|
||||||
mail_html = mail_html.replace("<DOWN_DEVICES_TABLE>", html)
|
mail_html = mail_html.replace("DOWN_DEVICES_TABLE", html)
|
||||||
mylog("verbose", ["[Notification] Down Devices sections done."])
|
mylog("verbose", ["[Notification] Down Devices sections done."])
|
||||||
|
|
||||||
# down_reconnected
|
# down_reconnected
|
||||||
# ---
|
# ---
|
||||||
html, text = construct_notifications(self.JSON, "down_reconnected")
|
html, text = construct_notifications(self.JSON, "down_reconnected")
|
||||||
|
|
||||||
mail_text = mail_text.replace("<DOWN_RECONNECTED_TABLE>", text + "\n")
|
mail_text = mail_text.replace("DOWN_RECONNECTED_TABLE", text + "\n")
|
||||||
mail_html = mail_html.replace("<DOWN_RECONNECTED_TABLE>", html)
|
mail_html = mail_html.replace("DOWN_RECONNECTED_TABLE", html)
|
||||||
mylog("verbose", ["[Notification] Reconnected Down Devices sections done."])
|
mylog("verbose", ["[Notification] Reconnected Down Devices sections done."])
|
||||||
|
|
||||||
# events
|
# events
|
||||||
# ---
|
# ---
|
||||||
html, text = construct_notifications(self.JSON, "events")
|
html, text = construct_notifications(self.JSON, "events")
|
||||||
|
|
||||||
mail_text = mail_text.replace("<EVENTS_TABLE>", text + "\n")
|
mail_text = mail_text.replace("EVENTS_TABLE", text + "\n")
|
||||||
mail_html = mail_html.replace("<EVENTS_TABLE>", html)
|
mail_html = mail_html.replace("EVENTS_TABLE", html)
|
||||||
mylog("verbose", ["[Notification] Events sections done."])
|
mylog("verbose", ["[Notification] Events sections done."])
|
||||||
|
|
||||||
# plugins
|
# plugins
|
||||||
# ---
|
# ---
|
||||||
html, text = construct_notifications(self.JSON, "plugins")
|
html, text = construct_notifications(self.JSON, "plugins")
|
||||||
|
|
||||||
mail_text = mail_text.replace("<PLUGINS_TABLE>", text + "\n")
|
mail_text = mail_text.replace("PLUGINS_TABLE", text + "\n")
|
||||||
mail_html = mail_html.replace("<PLUGINS_TABLE>", html)
|
mail_html = mail_html.replace("PLUGINS_TABLE", html)
|
||||||
|
|
||||||
mylog("verbose", ["[Notification] Plugins sections done."])
|
mylog("verbose", ["[Notification] Plugins sections done."])
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,18 @@ class NameResolver:
|
|||||||
raw = result[0][0]
|
raw = result[0][0]
|
||||||
return ResolvedName(raw, self.clean_device_name(raw, False))
|
return ResolvedName(raw, self.clean_device_name(raw, False))
|
||||||
|
|
||||||
# Check by IP
|
# Check name by IP if enabled
|
||||||
sql.execute(f"""
|
if get_setting_value('NEWDEV_IP_MATCH_NAME'):
|
||||||
SELECT Watched_Value2 FROM Plugins_Objects
|
|
||||||
WHERE Plugin = '{plugin}' AND Object_SecondaryID = '{pIP}'
|
sql.execute(f"""
|
||||||
""")
|
SELECT Watched_Value2 FROM Plugins_Objects
|
||||||
result = sql.fetchall()
|
WHERE Plugin = '{plugin}' AND Object_SecondaryID = '{pIP}'
|
||||||
# self.db.commitDB() # Issue #1251: Optimize name resolution lookup
|
""")
|
||||||
if result:
|
result = sql.fetchall()
|
||||||
raw = result[0][0]
|
# self.db.commitDB() # Issue #1251: Optimize name resolution lookup
|
||||||
return ResolvedName(raw, self.clean_device_name(raw, True))
|
if result:
|
||||||
|
raw = result[0][0]
|
||||||
|
return ResolvedName(raw, self.clean_device_name(raw, True))
|
||||||
|
|
||||||
return nameNotFound
|
return nameNotFound
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ def test_graphql_post_unauthorized(client):
|
|||||||
query = {"query": "{ devices { devName devMac } }"}
|
query = {"query": "{ devices { devName devMac } }"}
|
||||||
resp = client.post("/graphql", json=query)
|
resp = client.post("/graphql", json=query)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "Unauthorized access attempt" in resp.json.get("error", "")
|
assert "Unauthorized access attempt" in resp.json.get("message", "")
|
||||||
|
assert "Forbidden" in resp.json.get("error", "")
|
||||||
|
|
||||||
# --- DEVICES TESTS ---
|
# --- DEVICES TESTS ---
|
||||||
|
|
||||||
@@ -166,5 +167,4 @@ def test_graphql_post_langstrings_all_languages(client, api_token):
|
|||||||
assert data["enStrings"]["count"] >= 1
|
assert data["enStrings"]["count"] >= 1
|
||||||
assert data["deStrings"]["count"] >= 1
|
assert data["deStrings"]["count"] >= 1
|
||||||
# Ensure langCode matches
|
# Ensure langCode matches
|
||||||
assert all(e["langCode"] == "en_us" for e in data["enStrings"]["langStrings"])
|
assert all(e["langCode"] == "en_us" for e in data["enStrings"]["langStrings"])
|
||||||
assert all(e["langCode"] == "de_de" for e in data["deStrings"]["langStrings"])
|
|
||||||
@@ -64,7 +64,7 @@ def test_wakeonlan_device(client, api_token, test_mac):
|
|||||||
|
|
||||||
# 5. Conditional assertions based on MAC
|
# 5. Conditional assertions based on MAC
|
||||||
if device_mac.lower() == 'internet' or device_mac == test_mac:
|
if device_mac.lower() == 'internet' or device_mac == test_mac:
|
||||||
# For athe dummy "internet" or test MAC, expect a 400 response
|
# For the dummy "internet" or test MAC, expect a 400 response
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
else:
|
else:
|
||||||
# For any other MAC, expect a 200 response
|
# For any other MAC, expect a 200 response
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ class TestSafeConditionBuilder:
|
|||||||
|
|
||||||
# Simple pattern matching for common conditions
|
# Simple pattern matching for common conditions
|
||||||
# Pattern 1: AND/OR column operator value
|
# Pattern 1: AND/OR column operator value
|
||||||
pattern1 = r'^\s*(AND|OR)?\s+(\w+)\s+(=|!=|<>|<|>|<=|>=|LIKE|NOT\s+LIKE)\s+\'([^\']*)\'\s*$'
|
pattern1 = r"^\s*(AND|OR)?\s+(\w+)\s+(=|!=|<>|<|>|<=|>=|LIKE|NOT\s+LIKE)\s+'(.+?)'\s*$"
|
||||||
|
|
||||||
match1 = re.match(pattern1, condition, re.IGNORECASE)
|
match1 = re.match(pattern1, condition, re.IGNORECASE)
|
||||||
|
|
||||||
if match1:
|
if match1:
|
||||||
@@ -229,21 +230,6 @@ class TestSafeConditionBuilderSecurity(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn('Invalid operator', str(context.exception))
|
self.assertIn('Invalid operator', str(context.exception))
|
||||||
|
|
||||||
def test_sql_injection_attempts(self):
|
|
||||||
"""Test that various SQL injection attempts are blocked."""
|
|
||||||
injection_attempts = [
|
|
||||||
"'; DROP TABLE Devices; --",
|
|
||||||
"' UNION SELECT * FROM Settings --",
|
|
||||||
"' OR 1=1 --",
|
|
||||||
"'; INSERT INTO Events VALUES(1,2,3); --",
|
|
||||||
"' AND (SELECT COUNT(*) FROM sqlite_master) > 0 --",
|
|
||||||
]
|
|
||||||
|
|
||||||
for injection in injection_attempts:
|
|
||||||
with self.subTest(injection=injection):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
self.builder.build_safe_condition(f"AND devName = '{injection}'")
|
|
||||||
|
|
||||||
def test_legacy_condition_compatibility(self):
|
def test_legacy_condition_compatibility(self):
|
||||||
"""Test backward compatibility with legacy condition formats."""
|
"""Test backward compatibility with legacy condition formats."""
|
||||||
# Test simple condition
|
# Test simple condition
|
||||||
@@ -262,13 +248,20 @@ class TestSafeConditionBuilderSecurity(unittest.TestCase):
|
|||||||
self.assertEqual(params, {})
|
self.assertEqual(params, {})
|
||||||
|
|
||||||
def test_parameter_generation(self):
|
def test_parameter_generation(self):
|
||||||
"""Test that parameters are generated correctly."""
|
"""Test that parameters are generated correctly and do not leak between calls."""
|
||||||
# Test multiple parameters
|
# First condition
|
||||||
sql1, params1 = self.builder.build_safe_condition("AND devName = 'Device1'")
|
sql1, params1 = self.builder.build_safe_condition("AND devName = 'Device1'")
|
||||||
|
self.assertEqual(len(params1), 1)
|
||||||
|
self.assertIn("Device1", params1.values())
|
||||||
|
|
||||||
|
# Second condition
|
||||||
sql2, params2 = self.builder.build_safe_condition("AND devName = 'Device2'")
|
sql2, params2 = self.builder.build_safe_condition("AND devName = 'Device2'")
|
||||||
|
self.assertEqual(len(params2), 1)
|
||||||
# Each should have unique parameter names
|
self.assertIn("Device2", params2.values())
|
||||||
self.assertNotEqual(list(params1.keys())[0], list(params2.keys())[0])
|
|
||||||
|
# Ensure no leakage between calls
|
||||||
|
self.assertNotEqual(params1, params2)
|
||||||
|
|
||||||
|
|
||||||
def test_xss_prevention(self):
|
def test_xss_prevention(self):
|
||||||
"""Test that XSS-like payloads in device names are handled safely."""
|
"""Test that XSS-like payloads in device names are handled safely."""
|
||||||
|
|||||||
@@ -168,23 +168,6 @@ class TestSafeConditionBuilder(unittest.TestCase):
|
|||||||
self.assertIn('Connected', params.values())
|
self.assertIn('Connected', params.values())
|
||||||
self.assertIn('Disconnected', params.values())
|
self.assertIn('Disconnected', params.values())
|
||||||
|
|
||||||
def test_event_type_filter_whitelist(self):
|
|
||||||
"""Test that event type filter enforces whitelist."""
|
|
||||||
# Valid event types
|
|
||||||
valid_types = ['Connected', 'New Device']
|
|
||||||
sql, params = self.builder.build_event_type_filter(valid_types)
|
|
||||||
self.assertEqual(len(params), 2)
|
|
||||||
|
|
||||||
# Mix of valid and invalid event types
|
|
||||||
mixed_types = ['Connected', 'InvalidEventType', 'Device Down']
|
|
||||||
sql, params = self.builder.build_event_type_filter(mixed_types)
|
|
||||||
self.assertEqual(len(params), 2) # Only valid types should be included
|
|
||||||
|
|
||||||
# All invalid event types
|
|
||||||
invalid_types = ['InvalidType1', 'InvalidType2']
|
|
||||||
sql, params = self.builder.build_event_type_filter(invalid_types)
|
|
||||||
self.assertEqual(sql, "")
|
|
||||||
self.assertEqual(params, {})
|
|
||||||
|
|
||||||
|
|
||||||
class TestDatabaseParameterSupport(unittest.TestCase):
|
class TestDatabaseParameterSupport(unittest.TestCase):
|
||||||
@@ -267,10 +250,21 @@ class TestReportingSecurityIntegration(unittest.TestCase):
|
|||||||
# Verify that get_table_as_json was called with parameters
|
# Verify that get_table_as_json was called with parameters
|
||||||
self.mock_db.get_table_as_json.assert_called()
|
self.mock_db.get_table_as_json.assert_called()
|
||||||
call_args = self.mock_db.get_table_as_json.call_args
|
call_args = self.mock_db.get_table_as_json.call_args
|
||||||
|
|
||||||
# Should have been called with both query and parameters
|
# Should be query + params
|
||||||
self.assertEqual(len(call_args[0]), 1) # Query argument
|
self.assertEqual(len(call_args[0]), 2)
|
||||||
self.assertEqual(len(call_args[1]), 1) # Parameters keyword argument
|
|
||||||
|
query, params = call_args[0]
|
||||||
|
|
||||||
|
# Ensure the SQL contains the column
|
||||||
|
self.assertIn("devName =", query)
|
||||||
|
|
||||||
|
# Ensure a named parameter is used
|
||||||
|
self.assertRegex(query, r":param_\d+")
|
||||||
|
|
||||||
|
# Ensure the parameter dict has the correct value (using actual param name)
|
||||||
|
self.assertEqual(list(params.values())[0], "TestDevice")
|
||||||
|
|
||||||
|
|
||||||
@patch('messaging.reporting.get_setting_value')
|
@patch('messaging.reporting.get_setting_value')
|
||||||
def test_events_section_security(self, mock_get_setting):
|
def test_events_section_security(self, mock_get_setting):
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import copy
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
|
from _pytest.outcomes import Skipped
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -29,6 +35,55 @@ CONTAINER_PATHS = {
|
|||||||
|
|
||||||
TMPFS_ROOT = "/tmp:uid=20211,gid=20211,mode=1700,rw,noexec,nosuid,nodev,async,noatime,nodiratime"
|
TMPFS_ROOT = "/tmp:uid=20211,gid=20211,mode=1700,rw,noexec,nosuid,nodev,async,noatime,nodiratime"
|
||||||
|
|
||||||
|
DEFAULT_HTTP_PORT = int(os.environ.get("NETALERTX_DEFAULT_HTTP_PORT", "20211"))
|
||||||
|
COMPOSE_PORT_WAIT_TIMEOUT = int(os.environ.get("NETALERTX_COMPOSE_PORT_WAIT_TIMEOUT", "180"))
|
||||||
|
COMPOSE_SETTLE_WAIT_SECONDS = int(os.environ.get("NETALERTX_COMPOSE_SETTLE_WAIT", "15"))
|
||||||
|
PREFERRED_CUSTOM_PORTS = (22111, 22112)
|
||||||
|
HOST_ADDR_ENV = os.environ.get("NETALERTX_HOST_ADDRS", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_host_addresses() -> tuple[str, ...]:
|
||||||
|
"""Return candidate loopback addresses for reaching host-mode containers."""
|
||||||
|
|
||||||
|
candidates: list[str] = ["127.0.0.1"]
|
||||||
|
|
||||||
|
if HOST_ADDR_ENV:
|
||||||
|
env_addrs = [addr.strip() for addr in HOST_ADDR_ENV.split(",") if addr.strip()]
|
||||||
|
candidates.extend(env_addrs)
|
||||||
|
|
||||||
|
ip_cmd = shutil.which("ip")
|
||||||
|
if ip_cmd:
|
||||||
|
try:
|
||||||
|
route_proc = subprocess.run(
|
||||||
|
[ip_cmd, "-4", "route", "show", "default"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
route_proc = None
|
||||||
|
if route_proc and route_proc.returncode == 0 and route_proc.stdout:
|
||||||
|
match = re.search(r"default\s+via\s+(?P<gateway>\S+)", route_proc.stdout)
|
||||||
|
if match:
|
||||||
|
gateway = match.group("gateway")
|
||||||
|
candidates.append(gateway)
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[str] = set()
|
||||||
|
deduped: list[str] = []
|
||||||
|
for addr in candidates:
|
||||||
|
if addr not in seen:
|
||||||
|
deduped.append(addr)
|
||||||
|
seen.add(addr)
|
||||||
|
|
||||||
|
return tuple(deduped)
|
||||||
|
|
||||||
|
|
||||||
|
HOST_ADDRESS_CANDIDATES = _discover_host_addresses()
|
||||||
|
LAST_PORT_SUCCESSES: dict[int, str] = {}
|
||||||
|
|
||||||
pytestmark = [pytest.mark.docker, pytest.mark.compose]
|
pytestmark = [pytest.mark.docker, pytest.mark.compose]
|
||||||
|
|
||||||
IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test")
|
IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test")
|
||||||
@@ -151,12 +206,142 @@ def _extract_conflict_container_name(output: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _port_is_free(port: int) -> bool:
|
||||||
|
"""Return True if a TCP port is available on localhost."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
sock.bind(("127.0.0.1", port))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_ports(ports: Iterable[int], timeout: int = COMPOSE_PORT_WAIT_TIMEOUT) -> None:
|
||||||
|
"""Block until every port in the iterable accepts TCP connections or timeout expires."""
|
||||||
|
|
||||||
|
remaining = set(ports)
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
last_errors: dict[int, dict[str, BaseException]] = {port: {} for port in remaining}
|
||||||
|
|
||||||
|
while remaining and time.time() < deadline:
|
||||||
|
ready: list[int] = []
|
||||||
|
for port in list(remaining):
|
||||||
|
for addr in HOST_ADDRESS_CANDIDATES:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((addr, port), timeout=2):
|
||||||
|
ready.append(port)
|
||||||
|
LAST_PORT_SUCCESSES[port] = addr
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
last_errors.setdefault(port, {})[addr] = exc
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
for port in ready:
|
||||||
|
remaining.discard(port)
|
||||||
|
if remaining:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
details: list[str] = []
|
||||||
|
for port in sorted(remaining):
|
||||||
|
addr_errors = last_errors.get(port, {})
|
||||||
|
if addr_errors:
|
||||||
|
error_summary = ", ".join(f"{addr}: {err}" for addr, err in addr_errors.items())
|
||||||
|
else:
|
||||||
|
error_summary = "no connection attempts recorded"
|
||||||
|
details.append(f"{port} -> {error_summary}")
|
||||||
|
raise TimeoutError(
|
||||||
|
"Ports did not become ready before timeout: " + "; ".join(details)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_custom_ports() -> tuple[int, int]:
|
||||||
|
"""Choose a pair of non-default ports, preferring the standard high test pair when free."""
|
||||||
|
preferred_http, preferred_graphql = PREFERRED_CUSTOM_PORTS
|
||||||
|
if _port_is_free(preferred_http) and _port_is_free(preferred_graphql):
|
||||||
|
return preferred_http, preferred_graphql
|
||||||
|
|
||||||
|
# Fall back to scanning ephemeral range for the first free consecutive pair.
|
||||||
|
for port in range(30000, 60000, 2):
|
||||||
|
if _port_is_free(port) and _port_is_free(port + 1):
|
||||||
|
return port, port + 1
|
||||||
|
|
||||||
|
raise RuntimeError("Unable to locate two free high ports for compose testing")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_port_check_hook(ports: tuple[int, ...]) -> Callable[[], None]:
|
||||||
|
"""Return a callback that waits for the provided ports to accept TCP connections."""
|
||||||
|
|
||||||
|
def _hook() -> None:
|
||||||
|
for port in ports:
|
||||||
|
LAST_PORT_SUCCESSES.pop(port, None)
|
||||||
|
time.sleep(COMPOSE_SETTLE_WAIT_SECONDS)
|
||||||
|
_wait_for_ports(ports, timeout=COMPOSE_PORT_WAIT_TIMEOUT)
|
||||||
|
|
||||||
|
return _hook
|
||||||
|
|
||||||
|
|
||||||
|
def _write_normal_startup_compose(
|
||||||
|
base_dir: pathlib.Path,
|
||||||
|
project_name: str,
|
||||||
|
env_overrides: dict[str, str] | None,
|
||||||
|
) -> pathlib.Path:
|
||||||
|
"""Generate a compose file for the normal startup scenario with optional environment overrides."""
|
||||||
|
|
||||||
|
compose_config = copy.deepcopy(COMPOSE_CONFIGS["normal_startup"])
|
||||||
|
service = compose_config["services"]["netalertx"]
|
||||||
|
|
||||||
|
data_volume_name = f"{project_name}_data"
|
||||||
|
service["volumes"][0]["source"] = data_volume_name
|
||||||
|
|
||||||
|
if env_overrides:
|
||||||
|
service_env = service.setdefault("environment", {})
|
||||||
|
service_env.update(env_overrides)
|
||||||
|
|
||||||
|
compose_config["volumes"] = {data_volume_name: {}}
|
||||||
|
|
||||||
|
compose_file = base_dir / "docker-compose.yml"
|
||||||
|
with open(compose_file, "w") as f:
|
||||||
|
yaml.dump(compose_config, f)
|
||||||
|
|
||||||
|
return compose_file
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_ports_ready(
|
||||||
|
result: subprocess.CompletedProcess,
|
||||||
|
project_name: str,
|
||||||
|
ports: tuple[int, ...],
|
||||||
|
) -> str:
|
||||||
|
"""Validate the post-up hook succeeded and return sanitized compose logs for further assertions."""
|
||||||
|
|
||||||
|
post_error = getattr(result, "post_up_error", None)
|
||||||
|
clean_output = ANSI_ESCAPE.sub("", result.output)
|
||||||
|
port_hosts = {port: LAST_PORT_SUCCESSES.get(port) for port in ports}
|
||||||
|
result.port_hosts = port_hosts # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if post_error:
|
||||||
|
pytest.fail(
|
||||||
|
"Port readiness check failed for project"
|
||||||
|
f" {project_name} on ports {ports}: {post_error}\n"
|
||||||
|
f"Compose logs:\n{clean_output}"
|
||||||
|
)
|
||||||
|
|
||||||
|
port_summary = ", ".join(
|
||||||
|
f"{port}@{addr if addr else 'unresolved'}" for port, addr in port_hosts.items()
|
||||||
|
)
|
||||||
|
print(f"[compose port hosts] {project_name}: {port_summary}")
|
||||||
|
|
||||||
|
return clean_output
|
||||||
|
|
||||||
|
|
||||||
def _run_docker_compose(
|
def _run_docker_compose(
|
||||||
compose_file: pathlib.Path,
|
compose_file: pathlib.Path,
|
||||||
project_name: str,
|
project_name: str,
|
||||||
timeout: int = 5,
|
timeout: int = 5,
|
||||||
env_vars: dict | None = None,
|
env_vars: dict | None = None,
|
||||||
detached: bool = False,
|
detached: bool = False,
|
||||||
|
post_up: Callable[[], None] | None = None,
|
||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
"""Run docker compose up and capture output."""
|
"""Run docker compose up and capture output."""
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -219,10 +404,21 @@ def _run_docker_compose(
|
|||||||
continue
|
continue
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
post_up_exc: BaseException | None = None
|
||||||
|
skip_exc: Skipped | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if detached:
|
if detached:
|
||||||
up_result = _run_with_conflict_retry(up_cmd, timeout)
|
up_result = _run_with_conflict_retry(up_cmd, timeout)
|
||||||
|
|
||||||
|
if post_up:
|
||||||
|
try:
|
||||||
|
post_up()
|
||||||
|
except Skipped as exc:
|
||||||
|
skip_exc = exc
|
||||||
|
except BaseException as exc: # noqa: BLE001 - bubble the root cause through the result payload
|
||||||
|
post_up_exc = exc
|
||||||
|
|
||||||
logs_cmd = cmd + ["logs"]
|
logs_cmd = cmd + ["logs"]
|
||||||
logs_result = subprocess.run(
|
logs_result = subprocess.run(
|
||||||
logs_cmd,
|
logs_cmd,
|
||||||
@@ -255,6 +451,9 @@ def _run_docker_compose(
|
|||||||
|
|
||||||
# Combine stdout and stderr
|
# Combine stdout and stderr
|
||||||
result.output = result.stdout + result.stderr
|
result.output = result.stdout + result.stderr
|
||||||
|
result.post_up_error = post_up_exc # type: ignore[attr-defined]
|
||||||
|
if skip_exc is not None:
|
||||||
|
raise skip_exc
|
||||||
|
|
||||||
# Surface command context and IO for any caller to aid debugging
|
# Surface command context and IO for any caller to aid debugging
|
||||||
print("\n[compose command]", " ".join(up_cmd))
|
print("\n[compose command]", " ".join(up_cmd))
|
||||||
@@ -339,43 +538,34 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None:
|
|||||||
"""
|
"""
|
||||||
base_dir = tmp_path / "normal_startup"
|
base_dir = tmp_path / "normal_startup"
|
||||||
base_dir.mkdir()
|
base_dir.mkdir()
|
||||||
|
default_http_port = DEFAULT_HTTP_PORT
|
||||||
|
default_ports = (default_http_port,)
|
||||||
|
if not _port_is_free(default_http_port):
|
||||||
|
pytest.skip(
|
||||||
|
"Default NetAlertX ports are already bound on this host; "
|
||||||
|
"skipping compose normal-startup validation."
|
||||||
|
)
|
||||||
|
|
||||||
project_name = "netalertx-normal"
|
default_dir = base_dir / "default"
|
||||||
|
default_dir.mkdir()
|
||||||
|
default_project = "netalertx-normal-default"
|
||||||
|
|
||||||
# Create compose file mirroring production docker-compose.yml
|
default_compose_file = _write_normal_startup_compose(default_dir, default_project, None)
|
||||||
compose_config = copy.deepcopy(COMPOSE_CONFIGS["normal_startup"])
|
default_result = _run_docker_compose(
|
||||||
service = compose_config["services"]["netalertx"]
|
default_compose_file,
|
||||||
|
default_project,
|
||||||
|
timeout=60,
|
||||||
|
detached=True,
|
||||||
|
post_up=_make_port_check_hook(default_ports),
|
||||||
|
)
|
||||||
|
default_output = _assert_ports_ready(default_result, default_project, default_ports)
|
||||||
|
|
||||||
data_volume_name = f"{project_name}_data"
|
assert "Startup pre-checks" in default_output
|
||||||
|
assert "❌" not in default_output
|
||||||
service["volumes"][0]["source"] = data_volume_name
|
|
||||||
|
|
||||||
service.setdefault("environment", {})
|
|
||||||
service["environment"].update({
|
|
||||||
"PORT": "22111",
|
|
||||||
"GRAPHQL_PORT": "22112",
|
|
||||||
})
|
|
||||||
|
|
||||||
compose_config["volumes"] = {
|
|
||||||
data_volume_name: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
compose_file = base_dir / "docker-compose.yml"
|
|
||||||
with open(compose_file, 'w') as f:
|
|
||||||
yaml.dump(compose_config, f)
|
|
||||||
|
|
||||||
# Run docker compose
|
|
||||||
result = _run_docker_compose(compose_file, project_name, detached=True)
|
|
||||||
|
|
||||||
clean_output = ANSI_ESCAPE.sub("", result.output)
|
|
||||||
|
|
||||||
# Check that startup completed without critical issues and mounts table shows success
|
|
||||||
assert "Startup pre-checks" in clean_output
|
|
||||||
assert "❌" not in clean_output
|
|
||||||
|
|
||||||
data_line = ""
|
data_line = ""
|
||||||
data_parts: list[str] = []
|
data_parts: list[str] = []
|
||||||
for line in clean_output.splitlines():
|
for line in default_output.splitlines():
|
||||||
if CONTAINER_PATHS['data'] not in line or '|' not in line:
|
if CONTAINER_PATHS['data'] not in line or '|' not in line:
|
||||||
continue
|
continue
|
||||||
parts = [segment.strip() for segment in line.split('|')]
|
parts = [segment.strip() for segment in line.split('|')]
|
||||||
@@ -387,15 +577,46 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
assert data_line, "Expected /data row in mounts table"
|
assert data_line, "Expected /data row in mounts table"
|
||||||
|
assert data_parts[1] == CONTAINER_PATHS['data'], f"Unexpected path column in /data row: {data_parts}"
|
||||||
|
assert data_parts[2] == "✅" and data_parts[3] == "✅", (
|
||||||
|
f"Unexpected mount row values for /data: {data_parts[2:4]}"
|
||||||
|
)
|
||||||
|
|
||||||
parts = data_parts
|
assert "Write permission denied" not in default_output
|
||||||
assert parts[1] == CONTAINER_PATHS['data'], f"Unexpected path column in /data row: {parts}"
|
assert "CRITICAL" not in default_output
|
||||||
assert parts[2] == "✅" and parts[3] == "✅", f"Unexpected mount row values for /data: {parts[2:4]}"
|
assert "⚠️" not in default_output
|
||||||
|
|
||||||
# Ensure no critical errors or permission problems surfaced
|
custom_http, custom_graphql = _select_custom_ports()
|
||||||
assert "Write permission denied" not in clean_output
|
assert custom_http != default_http_port
|
||||||
assert "CRITICAL" not in clean_output
|
custom_ports = (custom_http,)
|
||||||
assert "⚠️" not in clean_output
|
|
||||||
|
custom_dir = base_dir / "custom"
|
||||||
|
custom_dir.mkdir()
|
||||||
|
custom_project = "netalertx-normal-custom"
|
||||||
|
|
||||||
|
custom_compose_file = _write_normal_startup_compose(
|
||||||
|
custom_dir,
|
||||||
|
custom_project,
|
||||||
|
{
|
||||||
|
"PORT": str(custom_http),
|
||||||
|
"GRAPHQL_PORT": str(custom_graphql),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_result = _run_docker_compose(
|
||||||
|
custom_compose_file,
|
||||||
|
custom_project,
|
||||||
|
timeout=60,
|
||||||
|
detached=True,
|
||||||
|
post_up=_make_port_check_hook(custom_ports),
|
||||||
|
)
|
||||||
|
custom_output = _assert_ports_ready(custom_result, custom_project, custom_ports)
|
||||||
|
|
||||||
|
assert "Startup pre-checks" in custom_output
|
||||||
|
assert "❌" not in custom_output
|
||||||
|
assert "Write permission denied" not in custom_output
|
||||||
|
assert "CRITICAL" not in custom_output
|
||||||
|
assert "⚠️" not in custom_output
|
||||||
|
|
||||||
|
|
||||||
def test_ram_disk_mount_analysis_compose(tmp_path: pathlib.Path) -> None:
|
def test_ram_disk_mount_analysis_compose(tmp_path: pathlib.Path) -> None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime, timedelta
|
|||||||
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
|
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
|
||||||
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
||||||
|
|
||||||
from helper import timeNowTZ, get_setting_value
|
from helper import get_setting_value
|
||||||
from api_server.api_server_start import app
|
from api_server.api_server_start import app
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -43,7 +43,9 @@ def test_graphql_post_unauthorized(client):
|
|||||||
query = {"query": "{ devices { devName devMac } }"}
|
query = {"query": "{ devices { devName devMac } }"}
|
||||||
resp = client.post("/graphql", json=query)
|
resp = client.post("/graphql", json=query)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "Unauthorized access attempt" in resp.json.get("error", "")
|
# Check either error field or message field for the unauthorized text
|
||||||
|
error_text = resp.json.get("error", "") or resp.json.get("message", "")
|
||||||
|
assert "Unauthorized" in error_text or "Forbidden" in error_text
|
||||||
|
|
||||||
def test_graphql_post_devices(client, api_token):
|
def test_graphql_post_devices(client, api_token):
|
||||||
"""POST /graphql with a valid token should return device data"""
|
"""POST /graphql with a valid token should return device data"""
|
||||||
|
|||||||
Reference in New Issue
Block a user