Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions core/imageroot/usr/local/agent/pypkg/agent/facts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#
# Copyright (C) 2026 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

# Fact collection helpers

import warnings
import uuid
import hashlib
import ipaddress

PSEUDO_ENFORCING = True
PSEUDO_SEED = '0000'

def init_pseudonymization(enforce, rdb):
global PSEUDO_ENFORCING, PSEUDO_SEED
seed = rdb.get('cluster/anon_seed')
if seed:
PSEUDO_SEED = seed
elif enforce:
warnings.warn("Generating an unstable, temporary seed for pseudonymization")
PSEUDO_SEED = str(uuid.uuid4())
PSEUDO_ENFORCING = enforce

def has_subscription(rdb):
provider = rdb.hget('cluster/subscription', 'provider')
return provider in ["nscom", "nsent"]

def pseudo_string(val, maxlen=12):
"""Calculate a stable pseudonym of the given string"""
if val and PSEUDO_ENFORCING:
hashed_val = hashlib.sha256((PSEUDO_SEED + val).encode('utf-8')).hexdigest()
return hashed_val[0:maxlen]
else:
return val

def pseudo_domain(val):
"""Calculate a stable pseudonym of the given domain, keeping the TLD in clear text"""
if not val or not PSEUDO_ENFORCING:
return val

try:
domain, suffix = val.rsplit(".", 1)
return pseudo_string(domain, 8) + '.' + suffix
except ValueError:
return pseudo_string(val)

def pseudo_ip(val):
"""Calculate a stable pseudonym of the given IPv4 or IPv6 address,
preserving only private vs public scope
"""
if not val or not PSEUDO_ENFORCING:
return val

try:
ip = ipaddress.ip_address(val)
except ValueError:
return val

digest = hashlib.sha256((PSEUDO_SEED + ip.exploded).encode('utf-8')).digest()

if isinstance(ip, ipaddress.IPv4Address):
if ip.is_private:
# 10.0.0.0/8
host = int.from_bytes(digest[:3], byteorder='big')
pseudo_int = (10 << 24) | host
else:
# 1.0.0.0/8 (public)
host = int.from_bytes(digest[:3], byteorder='big')
pseudo_int = (1 << 24) | host

return str(ipaddress.IPv4Address(pseudo_int))

else:
if ip.is_private:
# fc00::/7 (ULA)
host = int.from_bytes(digest[:15], byteorder='big')
pseudo_int = (0xfc << 120) | host
else:
# 2000::/3 (global unicast)
host = int.from_bytes(digest[:15], byteorder='big')
pseudo_int = (0x2 << 124) | host

return str(ipaddress.IPv6Address(pseudo_int))
20 changes: 20 additions & 0 deletions core/imageroot/usr/local/agent/pypkg/cluster/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import agent
import urllib.parse
import sys
from agent.ldapproxy import Ldapproxy
from agent.ldapclient import Ldapclient

def fact_subscription(rdb):
# Get subscription status
Expand Down Expand Up @@ -65,3 +68,20 @@ def fact_backup(rdb):
# transform set to list, set is not JSON serializable
ret['destination_providers'] = list(ret['destination_providers'])
return ret

def fact_user_domain_counters(rdb, kdom):
try:
domparams = Ldapproxy().get_domain(kdom)
# Fetch domain and calculate active users
cldap = Ldapclient.factory(**domparams)
lusers = cldap.list_users()
lgroups = cldap.list_groups()
except Exception as ex:
print(agent.SD_ERR + f"Failed to count users and groups of LDAP domain {kdom}", str(ex), file=sys.stderr)
lusers = []
lgroups = []
return {
"active_users": sum(bool(user.get("locked", False)) == False for user in lusers),
"total_users": len(lusers),
"total_groups": len(lgroups),
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,49 @@ import json
import sys
from cluster import inventory
import agent
import agent.tasks
import agent.facts
import cluster.modules

rdb = agent.redis_connect(privileged=True, use_replica=True)
rdb = agent.redis_connect(privileged=True)

anon_enforcing = not agent.facts.has_subscription(rdb)
agent.facts.init_pseudonymization(anon_enforcing, rdb)
disabled_updates_reason = cluster.modules.get_disabled_updates_reason(rdb)
update_schedule_active = rdb.hget('cluster/apply_updates', 'is_active') == "1"

def get_user_domains_facts(rdb):
list_user_domains_result = agent.tasks.run(
agent_id='cluster',
action='list-user-domains',
extra={ 'isNotificationHidden': True },
endpoint="redis://cluster-leader",
)
if list_user_domains_result['exit_code'] != 0:
return []
domfacts = []
for ldom in list_user_domains_result['output']['domains']:
odom = {
"name": agent.facts.pseudo_domain(ldom["name"]),
"location": ldom["location"],
"protocol": ldom["protocol"],
"schema": ldom.get("schema"),
"providers_count": len(ldom["providers"]),
}
odom.update(inventory.fact_user_domain_counters(rdb, ldom["name"]))
domfacts.append(odom)
return domfacts

facts = {
'leader_node_id': rdb.hget("cluster/environment", "NODE_ID"),
'user_domains': get_user_domains_facts(rdb),
'subscription': inventory.fact_subscription(rdb),
'admins': inventory.fact_admins(rdb),
'repositories': inventory.fact_repositories(rdb),
'smarthost': inventory.fact_smarthost(rdb),
"backup": inventory.fact_backup(rdb),
'update_disabled': bool(disabled_updates_reason), # True if updates are disabled by some reason
'update_disabled_reason': disabled_updates_reason, # For example, update inhibited by ns7_migration
'update_schedule_active': update_schedule_active, # True if overnight automatic updates for subscription are enabled
}
json.dump(facts, fp=sys.stdout)
91 changes: 76 additions & 15 deletions core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ Helper script that retrieves info for the phonehome server.
import sys
import json
import agent
from cluster import modules
import agent.facts
import uuid
import cluster.modules

def decorate_with_ui_name(facts, rdb, base_key):
if 'ui_name' in facts:
return # value already present, nothing to do
ui_name = rdb.get(base_key + '/ui_name') or ""
facts['ui_name'] = agent.facts.pseudo_string(ui_name)

def cluster_facts():
try:
Expand All @@ -28,22 +36,47 @@ def cluster_facts():
print(agent.SD_WARNING + f"cluster/get-facts failed", file=sys.stderr)
return None

rdb = agent.redis_connect(use_replica=True)
if result['exit_code'] == 0:
return result['output']
facts = result['output']
decorate_with_ui_name(facts, rdb, 'cluster')
return facts
else:
print(agent.SD_WARNING + f"cluster/get-facts failed", file=sys.stderr)
return None

def modules_facts():
def get_installed_modules(rdb):
available = cluster.modules.list_available(rdb, skip_core_modules = False)
cluster.modules.decorate_with_installed(rdb, available)
cluster.modules.decorate_with_updates(rdb, available)
installed_modules = []
for srcapp in available:
for instapp in srcapp['installed']:
instapp['certification_level'] = srcapp['certification_level']
instapp['update_available'] = bool(srcapp['updates'])
installed_modules.append(instapp)
return installed_modules

def modules_facts(rdb):
ret = []
rdb = agent.redis_connect(use_replica=True)
installed_modules = modules.list_installed(rdb, skip_core_modules = False)
instance_list = list(mi for module_instances in installed_modules.values() for mi in module_instances)
module_domains_relation = rdb.hgetall('cluster/module_domains') or {}
instance_list = get_installed_modules(rdb)
fqdn_cache = {}

# Collect facts from modules:
for module in instance_list:
minfo = {"id": module.get('id'), "version": module.get("version"), "name": module.get("module"), "node": module.get("node") }

minfo = {
"id": module['id'],
"version": module["version"],
"name": module["module"],
"node": module["node"],
"certification_level": module["certification_level"],
"update_available": module["update_available"],
"user_domains": [],
"fqdns": [],
}
for v in set(module_domains_relation.get(module.get('id'), '').split()):
minfo["user_domains"].append(agent.facts.pseudo_domain(v))
try:
list_actions_result = agent.tasks.run(
agent_id='module/' + module.get('id'),
Expand Down Expand Up @@ -77,13 +110,35 @@ def modules_facts():
except Exception as ex:
print(agent.SD_WARNING + "module_facts() exception:", ex, file=sys.stderr)

decorate_with_ui_name(minfo, rdb, 'module/' + module.get('id'))
ret.append(minfo)

# Extract FQDNs used by applications from Traefik facts:
if minfo["name"] == "traefik" and "name_module_map" in minfo:
for fqdn, mkey in minfo["name_module_map"].items():
if mkey in fqdn_cache:
fqdn_cache[mkey].add(fqdn)
else:
fqdn_cache[mkey] = {fqdn}

# Decorate apps with list of FQDNs extracted from Traefik facts:
for minfo in ret:
if minfo["id"] in fqdn_cache:
minfo["fqdns"] = list(fqdn_cache[minfo["id"]])
return ret

def node_facts():
def get_core_update_available_map(rdb):
update_available = {}
try:
node_info_list = next(filter(lambda m: m["name"] == "core", cluster.modules.list_core_modules(rdb)))["instances"]
except (KeyError, StopIteration):
node_info_list = []
for node_info in node_info_list:
update_available[node_info["node_id"]] = bool(node_info["update"])
return update_available

def node_facts(rdb):
ret = {}
rdb = agent.redis_connect(privileged=True)
update_available = get_core_update_available_map(rdb)

tasks = [{
'agent_id': 'node/'+rdb.hget(key, 'NODE_ID'),
Expand All @@ -102,23 +157,29 @@ def node_facts():
for item in results:
for key in item['output']:
ret[key] = item['output'][key]
decorate_with_ui_name(ret[key], rdb, 'node/' + key)
ret[key]['update_available'] = update_available.get(key, False)
except:
print(agent.SD_WARNING + f"node/get-facts failed", file=sys.stderr)

return ret


def main():
# Init dictionary that returns the data
rdb = agent.redis_connect(privileged=True)
if not rdb.get("cluster/anon_seed"):
# Generate and store a random seed to send anonymous data. Make
# sure the Redis key exists before the client library uses it!
rdb.set("cluster/anon_seed", str(uuid.uuid4()))
anon_enforcing = not agent.facts.has_subscription(rdb)
agent.facts.init_pseudonymization(anon_enforcing, rdb)
facts = {
'$schema': 'https://schema.nethserver.org/facts/2022-12.json',
'uuid': rdb.get('cluster/uuid'),
'installation': 'nethserver',
'facts': {
'cluster': cluster_facts(),
'nodes': node_facts(),
'modules': modules_facts()
'nodes': node_facts(rdb),
'modules': modules_facts(rdb),
}
}
print(json.dumps(facts))
Expand Down
31 changes: 29 additions & 2 deletions core/imageroot/var/lib/nethserver/node/actions/get-facts/50get
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,29 @@ import os
import sys
import subprocess
import agent.volumes

import agent.facts
import ansible_runner

from datetime import datetime, timezone

def _run(cmd):
proc = subprocess.run(cmd, shell=True, check=True,
capture_output=True, text=True)
return proc.stdout.rstrip().lstrip()

def _node_creation_date():
# Read birth dates of files created during NS8 installation.
try:
output = subprocess.check_output(["stat", r"--printf='%Y\n%W\n'",
"/etc/nethserver/wg0.pub",
"/var/lib/nethserver/node/state/agent.env"
],
text=True,
)
timestamps = [int(x) for x in filter(str.isnumeric, output.split())]
creation_time = min(timestamps) # Consider the older value
except subprocess.CalledProcessError:
creation_time = 0
return datetime.fromtimestamp(creation_time, tz=timezone.utc).isoformat()

def _get_core_version():
with open('/etc/nethserver/core.env', encoding='utf-8') as file:
Expand Down Expand Up @@ -86,9 +100,22 @@ virtualization = 'physical'
if facts['ansible_virtualization_role'] == 'guest':
virtualization = facts['ansible_virtualization_type']

rdb = agent.redis_connect(use_replica=True)
anon_enforcing = not agent.facts.has_subscription(rdb)
agent.facts.init_pseudonymization(anon_enforcing, rdb)
leader_id = rdb.hget("cluster/environment", "NODE_ID")

json.dump(
{
os.environ['AGENT_ID'].removeprefix('node/'): {
'creation_date': _node_creation_date(),
'cluster_leader': leader_id == os.getenv("NODE_ID"),
'fqdn': agent.facts.pseudo_domain(facts['ansible_fqdn']),
'default_ipv4': agent.facts.pseudo_ip(facts.get('ansible_default_ipv4', {}).get('address', '')),
'default_ipv6': agent.facts.pseudo_ip(facts.get('ansible_default_ipv6', {}).get('address', '')),
'kernel_version': facts['ansible_kernel'],
'uptime_seconds': facts['ansible_uptime_seconds'],
'timezone': facts['ansible_date_time']['tz'],
'volumesconf_mountpoint_count': len(agent.volumes.get_base_paths()),
'volumesconf_application_types': agent.volumes.get_application_types(),
'version': _get_core_version(),
Expand Down
1 change: 1 addition & 0 deletions docs/core/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ subsections for more information.
|cluster/environment NODE_ID |INTEGER |The node ID of the leader node |
|cluster/ui_name |STRING |UI label for the cluster|
|cluster/uuid |STRING |Generated UUID that identifies the cluster|
|cluster/anon_seed |STRING |A random seed for stable facts pseudonymization |
|cluster/override/modules |HASH |Maps (image name) => (image URL), overriding the standard image name/URL resolution function |
|cluster/subscription |HASH |[Subscription]({{site.baseurl}}/core/subscription) attributes in key/value pairs|
|cluster/apply_updates |HASH |[Scheduled updates]({{site.baseurl}}/core/updates) attributes in key/value pairs|
Expand Down