diff --git a/core/build-image.sh b/core/build-image.sh index 9bdd5e7be3..5e4691d4f1 100755 --- a/core/build-image.sh +++ b/core/build-image.sh @@ -70,6 +70,17 @@ buildah add "${container}" api-server/api-server-logs /usr/local/bin/api-server- buildah add "${container}" ui/dist /var/lib/nethserver/cluster/ui buildah add "${container}" api-moduled/api-moduled /usr/local/bin/api-moduled buildah add "${container}" install.sh /var/lib/nethserver/node/install.sh + +# Support tunnel client (WebSocket-based support, coexists with OpenVPN-based don) +if [[ -f support-tunnel/tunnel-client-linux-amd64 ]]; then + buildah add "${container}" support-tunnel/tunnel-client-linux-amd64 /var/lib/nethserver/node/support-tunnel/tunnel-client +fi +if [[ -d support-tunnel/users.d ]]; then + buildah add "${container}" support-tunnel/users.d /var/lib/nethserver/node/support-tunnel/users.d +fi +if [[ -d support-tunnel/diagnostics.d ]]; then + buildah add "${container}" support-tunnel/diagnostics.d /var/lib/nethserver/node/support-tunnel/diagnostics.d +fi core_env_file=$(mktemp) cleanup_list+=("${core_env_file}") printf "CORE_IMAGE=${repobase}/core:%s\n" "${IMAGETAG:-latest}" >> "${core_env_file}" diff --git a/core/imageroot/etc/systemd/system/support-tunnel.service b/core/imageroot/etc/systemd/system/support-tunnel.service new file mode 100644 index 0000000000..ccfac3c839 --- /dev/null +++ b/core/imageroot/etc/systemd/system/support-tunnel.service @@ -0,0 +1,27 @@ +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +[Unit] +Description=Support tunnel client +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=/var/lib/nethserver/support-tunnel/support.env +ExecStart=/usr/local/bin/tunnel-client \ + --url ${SUPPORT_URL} \ + --key ${SYSTEM_KEY} \ + --secret ${SYSTEM_SECRET} \ + --node-id ${NODE_ID} \ + --redis-addr ${REDIS_ADDR} \ + --users-dir ${USERS_DIR} \ + --diagnostics-dir ${DIAGNOSTICS_DIR} \ + --exclude ${EXCLUDE_PATTERNS} \ + --tls-insecure=${TLS_INSECURE} \ + --users-state-file /var/lib/nethserver/support-tunnel/users-state.json +Restart=no +RuntimeMaxSec=7d +SyslogIdentifier=support-tunnel diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/20get_status b/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/20get_status new file mode 100755 index 0000000000..37c1a29304 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/20get_status @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import agent +import agent.tasks + +rdb = agent.redis_connect(privileged=True) + +# Discover all cluster nodes +node_ids = [] +for key in rdb.scan_iter("node/*/environment"): + node_ids.append(key.split("/")[1]) + +if not node_ids: + json.dump({"active": False, "nodes": []}, fp=sys.stdout) + sys.exit(0) + +# Fan out get-tunnel-client-status to all nodes in parallel +tasks = [] +for node_id in node_ids: + tasks.append({ + "agent_id": f"node/{node_id}", + "action": "get-tunnel-client-status", + "data": {}, + }) + +results = agent.tasks.runp( + tasks, + endpoint="redis://cluster-leader", + progress_callback=agent.get_progress_callback(10, 90), +) + +nodes_status = [] +for result in results: + if isinstance(result, dict) and "output" in result: + nodes_status.append(result["output"]) + +any_active = any(n.get("active", False) for n in nodes_status) + +json.dump({ + "active": any_active, + "nodes": nodes_status, +}, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/validate-output.json b/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/validate-output.json new file mode 100644 index 0000000000..d3e41db97b --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-tunnel-all-nodes/validate-output.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "get-tunnel-all-nodes output", + "type": "object", + "properties": { + "active": { "type": "boolean" }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "active": { "type": "boolean" }, + "session_id": { "type": "string" }, + "started_at": { "type": "string" }, + "node_id": { "type": "string" } + } + } + } + } +} diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/10validate b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/10validate new file mode 100755 index 0000000000..5c9451f0a0 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/10validate @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import agent + +rdb = agent.redis_connect(use_replica=True) +tunnel_config = rdb.hgetall("cluster/support_tunnel") + +if not tunnel_config.get("key") or not tunnel_config.get("secret"): + agent.set_status('validation-failed') + json.dump([{ + "field": "support_tunnel", + "parameter": "support_tunnel", + "value": "", + "error": "tunnel_credentials_required", + }], fp=sys.stdout) + sys.exit(2) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/20start_all_nodes b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/20start_all_nodes new file mode 100755 index 0000000000..4ead958a16 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/20start_all_nodes @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import agent +import agent.tasks + +rdb = agent.redis_connect(privileged=True) + +# Discover all cluster nodes +node_ids = [] +for key in rdb.scan_iter("node/*/environment"): + node_ids.append(key.split("/")[1]) + +if not node_ids: + print("ERROR: no cluster nodes found", file=sys.stderr) + sys.exit(1) + +# Fan out start-tunnel-client to all nodes in parallel +tasks = [] +for node_id in node_ids: + tasks.append({ + "agent_id": f"node/{node_id}", + "action": "start-tunnel-client", + "data": {}, + }) + +results = agent.tasks.runp( + tasks, + endpoint="redis://cluster-leader", + progress_callback=agent.get_progress_callback(10, 90), +) + +# Collect per-node results +nodes = [] +errors = 0 +for i, result in enumerate(results): + node_id = node_ids[i] + if isinstance(result, Exception) or (isinstance(result, dict) and result.get("error")): + errors += 1 + nodes.append({ + "node_id": node_id, + "connected": False, + "error": str(result), + }) + elif isinstance(result, dict) and "output" in result: + output = result["output"] + if isinstance(output, str): + try: + output = json.loads(output) + except json.JSONDecodeError: + output = {} + nodes.append(output) + else: + nodes.append({ + "node_id": node_id, + "connected": False, + "error": "unexpected result", + }) + +if errors > 0: + print(f"Warning: {errors} node(s) failed to start tunnel client", file=sys.stderr) + +all_connected = all(n.get("connected", False) for n in nodes) +total_services = sum(n.get("services", 0) for n in nodes) + +json.dump({ + "connected": all_connected, + "total_services": total_services, + "nodes_started": len(node_ids) - errors, + "nodes_failed": errors, + "total_nodes": len(node_ids), + "nodes": nodes, +}, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/validate-output.json b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/validate-output.json new file mode 100644 index 0000000000..9d9a9e6eeb --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/start-tunnel-all-nodes/validate-output.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "start-tunnel-all-nodes output", + "type": "object", + "properties": { + "nodes_started": { "type": "integer" }, + "nodes_failed": { "type": "integer" }, + "total_nodes": { "type": "integer" } + } +} diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/stop-tunnel-all-nodes/10stop_all_nodes b/core/imageroot/var/lib/nethserver/cluster/actions/stop-tunnel-all-nodes/10stop_all_nodes new file mode 100755 index 0000000000..24420769ef --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/stop-tunnel-all-nodes/10stop_all_nodes @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import agent +import agent.tasks + +rdb = agent.redis_connect(privileged=True) + +# Discover all cluster nodes +node_ids = [] +for key in rdb.scan_iter("node/*/environment"): + node_ids.append(key.split("/")[1]) + +if not node_ids: + json.dump({"nodes_stopped": 0, "nodes_failed": 0}, fp=sys.stdout) + sys.exit(0) + +# Fan out stop-tunnel-client to all nodes in parallel +tasks = [] +for node_id in node_ids: + tasks.append({ + "agent_id": f"node/{node_id}", + "action": "stop-tunnel-client", + "data": {}, + }) + +errors = agent.tasks.runp_brief( + tasks, + endpoint="redis://cluster-leader", + progress_callback=agent.get_progress_callback(10, 90), +) + +if errors > 0: + print(f"Warning: {errors} node(s) failed to stop tunnel client", file=sys.stderr) + +json.dump({ + "nodes_stopped": len(node_ids) - errors, + "nodes_failed": errors, +}, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/20get_status b/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/20get_status new file mode 100755 index 0000000000..eefc56283e --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/20get_status @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import subprocess +import sys +import os +import agent + +rdb = agent.redis_connect(use_replica=True) + +# Read NODE_ID from environment +node_id = os.environ.get("NODE_ID", "1") + +# Check if the systemd service is active +result = subprocess.run( + ["systemctl", "is-active", "support-tunnel.service"], + capture_output=True, text=True +) +active = result.stdout.strip() == "active" + +# Get PID from systemd +pid = 0 +if active: + pid_result = subprocess.run( + ["systemctl", "show", "-p", "MainPID", "--value", "support-tunnel.service"], + capture_output=True, text=True + ) + try: + pid = int(pid_result.stdout.strip()) + except ValueError: + pass + +# Read state from Redis +session = rdb.hgetall(f"node/{node_id}/tunnel") + +json.dump({ + "active": active, + "node_id": node_id, + "support_url": session.get("support_url", ""), + "system_key": session.get("system_key", ""), + "connected": session.get("connected", "") == "true", + "services": int(session.get("services_count", "0") or "0"), + "pid": pid, +}, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/validate-output.json b/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/validate-output.json new file mode 100644 index 0000000000..5544f3ef8f --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/get-tunnel-client-status/validate-output.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "get-tunnel-client-status output", + "type": "object", + "properties": { + "active": { "type": "boolean" }, + "session_id": { "type": "string" }, + "started_at": { "type": "string" }, + "node_id": { "type": "string" } + } +} diff --git a/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/50start_tunnel_client b/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/50start_tunnel_client new file mode 100755 index 0000000000..70756637ef --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/50start_tunnel_client @@ -0,0 +1,118 @@ +#!/bin/bash + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +set -e + +# Save original stdout for action output, redirect rest to stderr +exec 3>&1 1>&2 + +SUPPORT_DIR=/var/lib/nethserver/support-tunnel +mkdir -p "$SUPPORT_DIR" + +# Extract tunnel-client binary and plugins from core image +runagent -m node install-support-tunnel + +# Read tunnel configuration from cluster/support_tunnel Redis hash. +# Use the local replica as default user (no auth, readable by any agent). +# The node agent context sets REDISCLI_AUTH which restricts access to cluster/* keys, +# so we unset it to use the default user on the local replica. +# Set these on the leader with: redis-cli HSET cluster/support_tunnel url key secret <...> +unset REDISCLI_AUTH +SUPPORT_URL=$(redis-cli -h 127.0.0.1 -p 6379 HGET cluster/support_tunnel url) +SYSTEM_KEY=$(redis-cli -h 127.0.0.1 -p 6379 HGET cluster/support_tunnel key) +SYSTEM_SECRET=$(redis-cli -h 127.0.0.1 -p 6379 HGET cluster/support_tunnel secret) +EXCLUDE_PATTERNS=$(redis-cli -h 127.0.0.1 -p 6379 HGET cluster/support_tunnel exclude_patterns) +TLS_INSECURE=$(redis-cli -h 127.0.0.1 -p 6379 HGET cluster/support_tunnel tls_insecure) + +if [[ -z "$SYSTEM_KEY" || -z "$SYSTEM_SECRET" ]]; then + echo "ERROR: missing tunnel credentials. Set them with:" >&2 + echo " redis-cli HSET cluster/support_tunnel url key secret <...>" >&2 + exit 1 +fi + +if [[ -z "$SUPPORT_URL" ]]; then + SUPPORT_URL="wss://support.nethesis.it/api/tunnel" +fi + +# Detect NODE_ID from node environment +source /var/lib/nethserver/node/state/environment +NODE_ID="${NODE_ID:-1}" + +# Write environment file for the systemd service. +# This file is regenerated on every start from cluster/support_tunnel Redis hash. +# To change settings, use: redis-cli HSET cluster/support_tunnel +# Available fields: url, key, secret, exclude_patterns, tls_insecure +cat > "$SUPPORT_DIR/support.env" < +SUPPORT_URL=${SUPPORT_URL} +SYSTEM_KEY=${SYSTEM_KEY} +SYSTEM_SECRET=${SYSTEM_SECRET} +NODE_ID=${NODE_ID} +REDIS_ADDR=127.0.0.1:6379 +USERS_DIR=${SUPPORT_DIR}/users.d +DIAGNOSTICS_DIR=${SUPPORT_DIR}/diagnostics.d +EXCLUDE_PATTERNS=${EXCLUDE_PATTERNS} +TLS_INSECURE=${TLS_INSECURE:-false} +USERS_STATE_FILE=${SUPPORT_DIR}/users-state.json +EOF +chmod 600 "$SUPPORT_DIR/support.env" + +# Start the tunnel service (session expiry is managed server-side by MY) +systemctl daemon-reload +systemctl restart support-tunnel.service + +# Wait for the tunnel to actually connect (max 15 seconds) +CONNECTED=false +for i in $(seq 1 15); do + # Check if the service is still running + if ! systemctl is-active --quiet support-tunnel.service; then + echo "ERROR: support-tunnel.service failed to start" >&2 + journalctl -u support-tunnel --since "30 sec ago" --no-pager | tail -5 >&2 + exit 1 + fi + # Check for "WebSocket connected" in the journal + if journalctl -u support-tunnel --since "30 sec ago" --no-pager 2>/dev/null | grep -q "WebSocket connected"; then + CONNECTED=true + break + fi + sleep 1 +done + +if [[ "$CONNECTED" != "true" ]]; then + echo "WARNING: tunnel service started but WebSocket connection not confirmed within 15s" >&2 +fi + +# Count discovered services from the journal +SERVICES_COUNT=$(journalctl -u support-tunnel --since "30 sec ago" --no-pager 2>/dev/null \ + | grep -oP 'Manifest sent with \K[0-9]+' | tail -1) +SERVICES_COUNT="${SERVICES_COUNT:-0}" + +# Get the PID of the tunnel-client process +TUNNEL_PID=$(systemctl show -p MainPID --value support-tunnel.service 2>/dev/null) +TUNNEL_PID="${TUNNEL_PID:-0}" + +# Store state in Redis (use default user on local replica for write access) +unset REDISCLI_AUTH +redis-cli HSET "node/${NODE_ID}/tunnel" \ + active "true" \ + support_url "$SUPPORT_URL" \ + system_key "$SYSTEM_KEY" \ + connected "$CONNECTED" \ + services_count "$SERVICES_COUNT" \ + pid "$TUNNEL_PID" > /dev/null + +# Output on the original stdout (fd3) +jq -c -n \ + --argjson active true \ + --arg node_id "$NODE_ID" \ + --arg support_url "$SUPPORT_URL" \ + --arg system_key "$SYSTEM_KEY" \ + --argjson connected "$CONNECTED" \ + --argjson services "${SERVICES_COUNT}" \ + --argjson pid "$TUNNEL_PID" \ + '{active: $active, node_id: $node_id, support_url: $support_url, system_key: $system_key, connected: $connected, services: $services, pid: $pid}' >&3 diff --git a/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/validate-input.json b/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/validate-input.json new file mode 100644 index 0000000000..e47a0ca993 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/start-tunnel-client/validate-input.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "start-tunnel-client input", + "type": "object", + "properties": {}, + "additionalProperties": false +} diff --git a/core/imageroot/var/lib/nethserver/node/actions/stop-tunnel-client/50stop_tunnel_client b/core/imageroot/var/lib/nethserver/node/actions/stop-tunnel-client/50stop_tunnel_client new file mode 100755 index 0000000000..47f96ca32c --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/stop-tunnel-client/50stop_tunnel_client @@ -0,0 +1,20 @@ +#!/bin/bash + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +set -e +exec 1>&2 + +# Stop the tunnel service. The tunnel-client handles user cleanup on +# SIGTERM: it runs teardown on users.d plugins and deletes ephemeral +# users (cluster-admin, domain users) before exiting. +systemctl stop support-tunnel.service 2>/dev/null || true + +# Clean up Redis state (use default user on local replica) +source /var/lib/nethserver/node/state/environment +NODE_ID="${NODE_ID:-1}" +unset REDISCLI_AUTH +redis-cli DEL "node/${NODE_ID}/tunnel" > /dev/null diff --git a/core/imageroot/var/lib/nethserver/node/bin/install-support-tunnel b/core/imageroot/var/lib/nethserver/node/bin/install-support-tunnel new file mode 100755 index 0000000000..4ccff39eaa --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/bin/install-support-tunnel @@ -0,0 +1,36 @@ +#!/bin/bash + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +# +# Extract tunnel-client binary and plugin scripts from the core image. +# Called by start-tunnel-client node action before starting the service. +# + +set -e + +SUPPORT_DIR=/var/lib/nethserver/support-tunnel +CORE_IMAGE=$(source /etc/nethserver/core.env; echo "$CORE_IMAGE") + +mkdir -p "$SUPPORT_DIR/users.d" "$SUPPORT_DIR/diagnostics.d" + +# Extract binary and plugins from core image +cid=$(podman create "$CORE_IMAGE" /bin/true) +trap "podman rm $cid > /dev/null 2>&1" EXIT + +# Binary goes to /usr/local/bin/ (SELinux bin_t label by default) +podman cp "${cid}:/var/lib/nethserver/node/support-tunnel/tunnel-client" \ + /usr/local/bin/tunnel-client +chmod 755 /usr/local/bin/tunnel-client + +# Plugins go to the support data directory +podman cp "${cid}:/var/lib/nethserver/node/support-tunnel/users.d/." \ + "$SUPPORT_DIR/users.d/" 2>/dev/null || true +podman cp "${cid}:/var/lib/nethserver/node/support-tunnel/diagnostics.d/." \ + "$SUPPORT_DIR/diagnostics.d/" 2>/dev/null || true + +find "$SUPPORT_DIR/users.d" -type f -exec chmod 755 {} \; 2>/dev/null || true +find "$SUPPORT_DIR/diagnostics.d" -type f -exec chmod 755 {} \; 2>/dev/null || true diff --git a/core/support-tunnel/README.md b/core/support-tunnel/README.md new file mode 100644 index 0000000000..4890b83a80 --- /dev/null +++ b/core/support-tunnel/README.md @@ -0,0 +1,28 @@ +# Support Tunnel Client + +WebSocket-based remote support system that replaces the OpenVPN-based "don" support. + +## Binary + +The `tunnel-client-linux-amd64` binary is a build artifact from the +[MY project](https://github.com/NethServer/my) (`services/support/cmd/tunnel-client`). + +To build it: + +```bash +cd /path/to/my/services/support +make build-tunnel-client +cp build/tunnel-client /path/to/ns8-core/core/support-tunnel/tunnel-client-linux-amd64 +``` + +## Plugin directories + +- `users.d/` - Ephemeral user provisioning scripts (setup/teardown lifecycle) +- `diagnostics.d/` - System health check scripts + +## Coexistence with don + +This system coexists with the existing OpenVPN-based "don" support (`support.service`). +The two systems use separate paths and service names: +- don: `/var/lib/nethserver/support/`, `support.service` +- tunnel: `/var/lib/nethserver/support-tunnel/`, `support-tunnel.service` diff --git a/core/support-tunnel/diagnostics.d/health b/core/support-tunnel/diagnostics.d/health new file mode 100755 index 0000000000..21e608e3bc --- /dev/null +++ b/core/support-tunnel/diagnostics.d/health @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +# +# diagnostics.d plugin for NS8 node health +# +# Checks core systemd services, Redis connectivity, running modules, +# failed units, and certificate expiry. Outputs JSON PluginResult +# with structured details for UI consumption. +# +# Exit codes: 0=ok, 1=warning, 2=critical +# + +import json +import subprocess +import sys +import os +from datetime import datetime, timezone + +checks = [] +overall = "ok" # ok < warning < critical + +def rank(status): + return {"ok": 0, "warning": 1, "critical": 2, "error": 3}.get(status, 0) + +def worst(a, b): + return a if rank(a) >= rank(b) else b + + +# 1. Check core systemd services +core_services = [ + "redis.service", + "api-server.service", + "agent@cluster.service", + "agent@node.service", +] + +services_status = [] +for svc in core_services: + try: + result = subprocess.run( + ["systemctl", "is-active", svc], + capture_output=True, text=True, timeout=5 + ) + state = result.stdout.strip() + status = "ok" if state == "active" else "critical" + services_status.append({"name": svc, "state": state}) + overall = worst(overall, status) + except Exception: + services_status.append({"name": svc, "state": "unknown"}) + overall = worst(overall, "error") + +all_active = all(s["state"] == "active" for s in services_status) +checks.append({ + "name": "core_services", + "status": "ok" if all_active else "critical", + "value": f"{sum(1 for s in services_status if s['state'] == 'active')}/{len(services_status)} active", + "details": services_status, +}) + + +# 2. Check Redis connectivity +try: + result = subprocess.run( + ["redis-cli", "ping"], + capture_output=True, text=True, timeout=5 + ) + redis_ok = result.stdout.strip() == "PONG" + status = "ok" if redis_ok else "critical" + checks.append({ + "name": "redis", + "status": status, + "value": result.stdout.strip(), + }) + overall = worst(overall, status) +except Exception as e: + checks.append({ + "name": "redis", + "status": "critical", + "value": "unreachable", + "details": {"error": str(e)}, + }) + overall = worst(overall, "critical") + + +# 3. Check modules on this node +try: + result = subprocess.run( + ["redis-cli", "HGETALL", "cluster/module_node"], + capture_output=True, text=True, timeout=5 + ) + lines = result.stdout.strip().split("\n") if result.stdout.strip() else [] + + node_env = "/var/lib/nethserver/node/state/environment" + node_id = "1" + if os.path.isfile(node_env): + with open(node_env) as f: + for line in f: + if line.startswith("NODE_ID="): + node_id = line.strip().split("=", 1)[1] + break + + module_names = [] + for i in range(0, len(lines) - 1, 2): + if lines[i + 1] == node_id: + module_names.append(lines[i]) + + checks.append({ + "name": "modules", + "status": "ok", + "value": str(len(module_names)), + "details": {"node_id": node_id, "modules": module_names}, + }) +except Exception: + pass + + +# 4. Check failed systemd units +try: + result = subprocess.run( + ["systemctl", "list-units", "--state=failed", "--no-legend", "--no-pager", "--plain"], + capture_output=True, text=True, timeout=5 + ) + failed_lines = [l for l in result.stdout.strip().split("\n") if l.strip()] + failed_count = len(failed_lines) + + failed_units = [] + for line in failed_lines[:20]: + parts = line.split() + if parts: + unit_name = parts[0] + description = " ".join(parts[4:]) if len(parts) > 4 else "" + failed_units.append({"unit": unit_name, "description": description}) + + status = "warning" if failed_count > 0 else "ok" + checks.append({ + "name": "failed_units", + "status": status, + "value": str(failed_count), + "details": failed_units if failed_units else None, + }) + overall = worst(overall, status) +except Exception: + pass + + +# 5. Check TLS certificate freshness (Traefik ACME) +try: + cert_paths = [ + "/etc/nethserver/traefik/acme/acme.json", + "/home/traefik1/.config/state/acme.json", + ] + for cert_path in cert_paths: + if os.path.isfile(cert_path): + mtime = os.path.getmtime(cert_path) + age_days = int((datetime.now(timezone.utc).timestamp() - mtime) / 86400) + status = "warning" if age_days > 60 else "ok" + checks.append({ + "name": "tls_acme", + "status": status, + "value": f"{age_days}d", + "details": {"path": cert_path, "age_days": age_days}, + }) + overall = worst(overall, status) + break +except Exception: + pass + + +# Build summary +ok_count = sum(1 for c in checks if c["status"] == "ok") +warn_count = sum(1 for c in checks if c["status"] == "warning") +crit_count = sum(1 for c in checks if c["status"] == "critical") +summary = f"{ok_count} ok, {warn_count} warning, {crit_count} critical" + +result = { + "id": "health", + "name": "NS8 Node Health", + "status": overall, + "summary": summary, + "checks": checks, +} + +json.dump(result, sys.stdout) + +# Exit code reflects overall status +if overall == "critical": + sys.exit(2) +elif overall == "warning": + sys.exit(1) +else: + sys.exit(0) diff --git a/core/support-tunnel/tunnel-client-linux-amd64 b/core/support-tunnel/tunnel-client-linux-amd64 new file mode 100755 index 0000000000..56d480aeaa Binary files /dev/null and b/core/support-tunnel/tunnel-client-linux-amd64 differ diff --git a/core/support-tunnel/users.d/nethvoice b/core/support-tunnel/users.d/nethvoice new file mode 100644 index 0000000000..d20a158ba5 --- /dev/null +++ b/core/support-tunnel/users.d/nethvoice @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +""" +users.d plugin for NethVoice (NS8) + +Creates a FreePBX admin user (ampusers table) for each local NethVoice instance, +using the domain user credentials from the tunnel-client provisioning system. + +Setup: creates support user in ampusers for each local instance +Teardown: removes support user from ampusers for each local instance + +Usage: + nethvoice setup --users-file [--instances-file ] + nethvoice teardown --users-file [--instances-file ] + +Input files (JSON): + --users-file: SessionUsers with cluster_admin, domain_users credentials + --instances-file: ModuleContext with NethVoice instances, their domains and services +""" + +import json +import os +import subprocess +import sys + + +def parse_args(): + """Parse CLI arguments: action, --users-file, --instances-file.""" + if len(sys.argv) < 2: + sys.exit(1) + + action = sys.argv[1] + users_file = None + instances_file = None + + i = 2 + while i < len(sys.argv): + if sys.argv[i] == "--users-file" and i + 1 < len(sys.argv): + users_file = sys.argv[i + 1] + i += 2 + elif sys.argv[i] == "--instances-file" and i + 1 < len(sys.argv): + instances_file = sys.argv[i + 1] + i += 2 + else: + i += 1 + + return action, users_file, instances_file + + +def load_json(path): + """Load and parse a JSON file, return None on error.""" + if not path or not os.path.isfile(path): + return None + with open(path) as f: + return json.load(f) + + +def get_support_username(users_data): + """Extract the support username from users data (domain user or cluster admin).""" + for du in users_data.get("domain_users", []): + return du["username"] + ca = users_data.get("cluster_admin") + if ca: + return ca["username"] + return None + + +def get_domain_passwords(users_data): + """Build a domain -> password lookup from domain users.""" + passwords = {} + for du in users_data.get("domain_users", []): + passwords[du["domain"]] = du["password"] + return passwords + + +def get_mariadb_password(module_id): + """Read MARIADB_ROOT_PASSWORD from the module's passwords.env file.""" + passwords_file = f"/home/{module_id}/.config/state/passwords.env" + if not os.path.isfile(passwords_file): + return None + with open(passwords_file) as f: + for line in f: + if line.startswith("MARIADB_ROOT_PASSWORD="): + return line.strip().split("=", 1)[1] + return None + + +def run_mysql(module_id, db_password, sql): + """Execute a MySQL command inside the mariadb container of a NethVoice module.""" + result = subprocess.run( + [ + "su", "-", module_id, "-s", "/bin/bash", "-c", + f"podman exec -e MYSQL_PWD='{db_password}' mariadb " + f"mysql -uroot -N -B asterisk -e \"{sql}\"" + ], + capture_output=True, text=True, timeout=15, + ) + return result + + +def setup(users_data, instances_data): + """Create FreePBX admin user for each local NethVoice instance.""" + username = get_support_username(users_data) + if not username: + return [] + + domain_passwords = get_domain_passwords(users_data) + results = [] + + for inst in instances_data.get("instances", []): + module_id = inst["id"] + domain = inst.get("domain", "") + label = inst.get("label", "") + + # Only process local instances (have a home directory on this node) + db_password = get_mariadb_password(module_id) + if not db_password: + continue + + # Get the password for this instance's domain + password = domain_passwords.get(domain, "") + if not password: + continue + + # Create the support user in ampusers with SHA1 hashed password + sql = ( + f"INSERT INTO ampusers (username, password_sha1, sections) " + f"VALUES ('{username}', SHA1('{password}'), '*') " + f"ON DUPLICATE KEY UPDATE password_sha1=SHA1('{password}'), sections='*'" + ) + + try: + result = run_mysql(module_id, db_password, sql) + if result.returncode != 0: + print(f"Warning: MySQL error for {module_id}: {result.stderr.strip()}", file=sys.stderr) + continue + except Exception as e: + print(f"Warning: failed to create user in {module_id}: {e}", file=sys.stderr) + continue + + # Build display name + name = "NethVoice" + if label: + name += f" ({label})" + elif module_id: + name += f" ({module_id})" + + # Find the wizard service URL for this instance + wizard_service = "" + for svc_name in inst.get("services", {}): + if "wizard" in svc_name: + wizard_service = svc_name + break + + results.append({ + "id": module_id, + "name": name, + "notes": f"Domain: {domain}" + (f" | Service: {wizard_service}" if wizard_service else ""), + }) + + return results + + +def teardown(users_data, instances_data): + """Remove FreePBX admin user from each local NethVoice instance.""" + username = get_support_username(users_data) + if not username: + return + + for inst in instances_data.get("instances", []): + module_id = inst["id"] + + db_password = get_mariadb_password(module_id) + if not db_password: + continue + + sql = f"DELETE FROM ampusers WHERE username = '{username}'" + + try: + run_mysql(module_id, db_password, sql) + except Exception: + pass + + +def main(): + action, users_file, instances_file = parse_args() + + users_data = load_json(users_file) + if not users_data: + sys.exit(1) + + instances_data = load_json(instances_file) + if not instances_data: + # No instances to configure + if action == "setup": + print("[]") + sys.exit(0) + + if action == "setup": + results = setup(users_data, instances_data) + print(json.dumps(results)) + elif action == "teardown": + teardown(users_data, instances_data) + else: + print(f"Unknown action: {action}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()