diff --git a/CHANGELOG.md b/CHANGELOG.md index e4465ff..ee0b0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [8.5.9] - 2026-07-23 + +### Fixed +- Preserve original exit code in Windows PowerShell script wrapper when redirecting streams, ensuring ogcore receives failure status (e.g., CommandNotFoundException). +- Prevent raw PowerShell CLIXML output from leaking into stderr on Windows when a command fails non-interactively. +- Removed the `START_SCRIPT` webhook entirely from the script launching process for both admin (`client=false`) and user-session (`client=true`) paths. This eliminates the race condition that caused duplicate traces in `ogcore`, as `ogcore` now marks traces as `IN_PROGRESS` synchronously. + +## [8.5.6] - 2026-07-21 +### Added +- Notify `ogcore` via webhook when natively executed scripts finish, bringing `ogagent` in line with `ogLive` functionality (resolves stuck traces in "Enviada" state). + ## [8.5.5] - 2026-05-13 ### Changed @@ -13,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Promote job completion log in `oglive_worker.mon()` from `DEBUG` to `INFO`, now including the job result. - When `interfaceAdmin()` fails, log the script stdout at `WARNING` in addition to the existing `ERROR` for stderr, providing full diagnostic context without requiring a debug-level rerun. +## [8.5.5] - 2026-05-20 + +### Changed + +- On machines with multiple network interfaces, the agent now selects the interface whose IP the OS routing table would use to reach ogcore, instead of always picking the first interface returned by the OS. This ensures the agent registers with the correct IP on multi-homed machines. If the routing query fails (network not ready yet) the detection loop keeps retrying rather than accepting a wrong interface. Falls back to the first available interface only when the routing query succeeds but no interface matches (e.g. NAT or VPN). + ## [8.5.4] - 2026-05-11 ### Fixed diff --git a/Jenkins/Jenkinsfile-deb-pkg b/Jenkins/Jenkinsfile-deb-pkg index 7133403..ebdc580 100644 --- a/Jenkins/Jenkinsfile-deb-pkg +++ b/Jenkins/Jenkinsfile-deb-pkg @@ -67,6 +67,20 @@ pipeline { } } } + stage('Generate Changelog (Devel)') { + when { + branch 'devel' + } + steps { + script { + def devName = params.DEV_NAME ? params.DEV_NAME : env.DEFAULT_DEV_NAME + def devEmail = params.DEV_EMAIL ? params.DEV_EMAIL : env.DEFAULT_DEV_EMAIL + generateDebianChangelog(env.BUILD_DIR, devName, devEmail, "devel") + sh "mkdir -p ${env.BUILD_DIR}/debian/" + sh "cp ${env.BUILD_DIR}/debian/changelog ${env.BUILD_DIR}/linux/debian/" + } + } + } stage('Build') { steps { dir ("${env.BUILD_DIR}") { @@ -112,8 +126,32 @@ pipeline { } } } + + stage ('Publish to Debian Repository (Devel)') { + when { + branch 'devel' + } + agent { label 'debian-repo' } + steps { + script { + def versionPattern = "-${env.BUILD_NUMBER}~devel" + publicarEnAptly('/var/tmp/opengnsys/debian-repo/ogagent', 'devel', versionPattern) + } + } + } } post { + success { + script { + if (env.BRANCH_NAME == 'devel') { + build job: 'Aptly publish develop repository', + wait: false, + parameters: [ + string(name: 'TRIGGERED_BY', value: "${env.JOB_NAME}-${env.BUILD_NUMBER}") + ] + } + } + } always { notifyBuildStatus('narenas@qindel.com') } diff --git a/linux/Makefile b/linux/Makefile index 95d5c64..612f110 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -38,6 +38,7 @@ install-ogagent: cp $(SOURCEDIR)/OGAgentUser.py $(LIBDIR) # QT Dialogs & resources + cd $(SOURCEDIR) && python3 update.py cp $(SOURCEDIR)/*_ui.py $(LIBDIR) # Version file cp $(SOURCEDIR)/VERSION $(LIBDIR) diff --git a/linux/debian/changelog b/linux/debian/changelog index 7a14615..540f6cf 100644 --- a/linux/debian/changelog +++ b/linux/debian/changelog @@ -1,3 +1,17 @@ +ogagent (8.5.9-1) stable; urgency=medium + + * Removed START_SCRIPT webhook to fix race conditions causing duplicate traces + * Added script completion webhook to resolve stuck traces in Enviada state + + -- OpenGnsys developers Wed, 29 Jul 2026 13:58:00 +0200 + +ogagent (8.5.5-1) stable; urgency=medium + + * Remove obsolete dependencies (six, prctl, libxss1) to support modern Ubuntu releases (like 26.04) + * Update policykit-1 to polkitd + + -- OpenGnsys developers Thu, 09 Jul 2026 12:00:00 +0200 + ogagent (8.5.4-1) stable; urgency=medium * Fix network detection race condition on slow-booting machines: activation diff --git a/linux/debian/control b/linux/debian/control index c8d9e6e..5e8ff55 100644 --- a/linux/debian/control +++ b/linux/debian/control @@ -2,7 +2,7 @@ Source: ogagent Section: admin Priority: optional Maintainer: OpenGnsys developers -Build-Depends: debhelper (>= 7), po-debconf +Build-Depends: debhelper (>= 7), po-debconf, pyqt6-dev-tools Standards-Version: 3.9.2 Homepage: https://opengnsys.es/ @@ -11,7 +11,7 @@ Section: admin Priority: optional Architecture: all Depends: - policykit-1 (>= 0.100), python3 (>=3.4) | python (>= 3.4), python3-pyqt6, python3-requests, - python3-six, python3-prctl, python3-distro, libxss1, zenity, ${misc:Depends} + polkitd | policykit-1 (>= 0.100), python3 (>=3.4), python3-pyqt6, python3-requests, + python3-distro, zenity, ${misc:Depends} Description: OpenGnsys Agent for Operating Systems This package provides the required components to allow this machine to work on an environment managed by OpenGnsys. diff --git a/src/VERSION b/src/VERSION index 56b6be4..507ae5a 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -8.3.1 +8.5.9 diff --git a/src/about-dialog.ui b/src/about-dialog.ui index 72d248c..f94b1a9 100644 --- a/src/about-dialog.ui +++ b/src/about-dialog.ui @@ -65,7 +65,7 @@ - Version 0 + Version 8.5.6 diff --git a/src/opengnsys/__init__.py b/src/opengnsys/__init__.py index cf7a335..8b72c2b 100644 --- a/src/opengnsys/__init__.py +++ b/src/opengnsys/__init__.py @@ -31,13 +31,10 @@ """ -# On centos, old six release does not includes byte2int, nor six.PY2 -import six - from . import modules from .RESTApi import REST, RESTError -VERSION='0' +VERSION='8.5.6' __title__ = 'OpenGnsys Agent' __version__ = VERSION @@ -46,11 +43,4 @@ __author__ = 'Adolfo Gómez' __license__ = "BSD 3-clause" __copyright__ = "Copyright VirtualCable S.L.U." -if not hasattr(six, 'byte2int'): - if six.PY3: - import operator - six.byte2int = operator.itemgetter(0) - else: - def _byte2int(bs): - return ord(bs[0]) - six.byte2int = _byte2int + diff --git a/src/opengnsys/httpserver.py b/src/opengnsys/httpserver.py index 5879618..6dc4fe9 100644 --- a/src/opengnsys/httpserver.py +++ b/src/opengnsys/httpserver.py @@ -34,10 +34,9 @@ import os import json import ssl import threading -from six.moves.socketserver import ThreadingMixIn # @UnresolvedImport -from six.moves.BaseHTTPServer import BaseHTTPRequestHandler # @UnresolvedImport -from six.moves.BaseHTTPServer import HTTPServer # @UnresolvedImport -from six.moves.urllib.parse import unquote # @UnresolvedImport +from socketserver import ThreadingMixIn +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import unquote from .utils import exceptionToMessage from .certs import createSelfSignedCert diff --git a/src/opengnsys/jobmgr.py b/src/opengnsys/jobmgr.py index fe5c093..e7f77e3 100644 --- a/src/opengnsys/jobmgr.py +++ b/src/opengnsys/jobmgr.py @@ -25,7 +25,7 @@ class JobMgr(): ts = now.strftime ('%Y-%m-%d %H:%M:%S.%f%z') ## '%s' doesn't work on windows job_id = hashlib.sha256 (now.isoformat().encode('UTF-8') + script.encode ('UTF-8')).hexdigest()[0:12] p = subprocess.Popen (args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - self.jobs[job_id] = { 'p': p, 'pid': p.pid, 'starttime': ts, 'script': script, 'client': is_client, 'status': 'running', 'stdout': '', 'stderr': '' } + self.jobs[job_id] = { 'p': p, 'pid': p.pid, 'starttime': ts, 'script': script, 'client': is_client, 'status': 'running', 'stdout': '', 'stderr': '', 'notified': False } self.jobs[job_id]['t1'] = threading.Thread (target=job_readstdout, args=(self.jobs[job_id],)) self.jobs[job_id]['t2'] = threading.Thread (target=job_readstderr, args=(self.jobs[job_id],)) self.jobs[job_id]['t1'].start() diff --git a/src/opengnsys/linux/operations.py b/src/opengnsys/linux/operations.py index 9d2d928..5af98ee 100644 --- a/src/opengnsys/linux/operations.py +++ b/src/opengnsys/linux/operations.py @@ -41,7 +41,6 @@ import ctypes.util import subprocess import struct import array -import six import distro from opengnsys import utils @@ -53,12 +52,12 @@ def _getMacAddr(ifname): ''' if isinstance(ifname, list): return dict([(name, _getMacAddr(name)) for name in ifname]) - if isinstance(ifname, six.text_type): + if isinstance(ifname, str): ifname = ifname.encode('utf-8') # If unicode, convert to bytes (or str in python 2.7) try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) info = bytearray(fcntl.ioctl(s.fileno(), 0x8927, struct.pack(str('256s'), ifname[:15]))) - return six.text_type(''.join(['%02x:' % char for char in info[18:24]])[:-1]) + return str(''.join(['%02x:' % char for char in info[18:24]])[:-1]) except Exception: return None @@ -70,11 +69,11 @@ def _getIpAddr(ifname): ''' if isinstance(ifname, list): return dict([(name, _getIpAddr(name)) for name in ifname]) - if isinstance(ifname, six.text_type): + if isinstance(ifname, str): ifname = ifname.encode('utf-8') # If unicode, convert to bytes (or str in python 2.7) try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - return six.text_type(socket.inet_ntoa(fcntl.ioctl( + return str(socket.inet_ntoa(fcntl.ioctl( s.fileno(), 0x8915, # SIOCGIFADDR struct.pack(str('256s'), ifname[:15]) @@ -138,11 +137,6 @@ def reboot(flags=0): ''' Simple reboot using os command ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - # Check for OpenGnsys Client or GNU/Linux distribution. if os.path.exists('/scripts/oginit'): subprocess.call('source /opt/opengnsys/etc/preinit/loadenviron.sh; /opt/opengnsys/scripts/reboot', shell=True) @@ -154,11 +148,6 @@ def poweroff(flags=0): ''' Simple poweroff using os command ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - # Check for OpenGnsys Client or GNU/Linux distribution. if os.path.exists('/scripts/oginit'): subprocess.call('source /opt/opengnsys/etc/preinit/loadenviron.sh; /opt/opengnsys/scripts/poweroff', shell=True) @@ -171,11 +160,6 @@ def logoff(): Kills all curent user processes, which must send a logogof caveat: If the user has other sessions, will also disconnect from them ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - subprocess.call(['/usr/bin/pkill', '-u', os.environ['USER']]) diff --git a/src/opengnsys/macos/operations.py b/src/opengnsys/macos/operations.py index 5f827a1..24022d9 100644 --- a/src/opengnsys/macos/operations.py +++ b/src/opengnsys/macos/operations.py @@ -41,7 +41,6 @@ import ctypes.util import subprocess import struct import array -import six import json from opengnsys import utils @@ -134,11 +133,6 @@ def reboot(flags=0): ''' Simple reboot command ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - # Exec reboot command subprocess.call('/sbin/shutdown -r now', shell=True) @@ -147,11 +141,6 @@ def poweroff(flags=0): ''' Simple poweroff command ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - # Exec shutdown command subprocess.call('/sbin/shutdown -h now', shell=True) @@ -160,11 +149,6 @@ def logoff(): ''' Simple logout using AppleScript ''' - # Workaround for dummy thread - if six.PY3 is False: - import threading - threading._DummyThread._Thread__stop = lambda x: 42 - # Exec logout using AppleScript subprocess.call('/usr/bin/osascript -e \'tell app "System Events" to «event aevtrlgo»\'', shell=True) diff --git a/src/opengnsys/modules/client/OpenGnSys/__init__.py b/src/opengnsys/modules/client/OpenGnSys/__init__.py index d78aeb3..c0ffdf9 100644 --- a/src/opengnsys/modules/client/OpenGnSys/__init__.py +++ b/src/opengnsys/modules/client/OpenGnSys/__init__.py @@ -53,6 +53,35 @@ class OpenGnSysWorker(ClientWorker): job_id = self.jobmgr.launch_job (script, True) self.sendServerMessage('script_launched', {'op': 'launched', 'job_id': job_id}) + # Monitor the job in the background and notify the server when done + def monitor_job(): + import time + import threading + + job = self.jobmgr.jobs.get(job_id) + if not job: + return + + p = job.get('p') + if not p: + return + + p.wait() + # Wait for stdout/stderr threads to finish + if job.get('t1'): job['t1'].join() + if job.get('t2'): job['t2'].join() + + logger.debug(f'Client job {job_id} finished, notifying server.') + self.sendServerMessage('script_finished', { + 'job_id': job_id, + 'rc': p.returncode, + 'stderr': job.get('stderr', ''), + 'stdout': job.get('stdout', '') + }) + + import threading + threading.Thread(target=monitor_job, daemon=True).start() + def process_terminatescript(self, json_params): job_id = json_params['job_id'] logger.debug('Processing terminatescript request, job_id "{}"'.format (job_id)) diff --git a/src/opengnsys/modules/server/OpenGnSys/__init__.py b/src/opengnsys/modules/server/OpenGnSys/__init__.py index 765bb5a..d66b03f 100644 --- a/src/opengnsys/modules/server/OpenGnSys/__init__.py +++ b/src/opengnsys/modules/server/OpenGnSys/__init__.py @@ -34,6 +34,7 @@ import base64 import os import random import shutil +import socket import string import sys import threading @@ -102,6 +103,7 @@ class OpenGnSysWorker(ServerWorker): exec_level = None # Execution level (permitted operations) jobmgr = JobMgr() _registered = False # Whether ogcore has acknowledged our ogagent/started + client_jobs = set() # Track active client job IDs for busy status def _send_started(self): """Send ogagent/started to ogcore and mark as registered on success.""" @@ -125,13 +127,40 @@ class OpenGnSysWorker(ServerWorker): while True: time.sleep(1) n += 1 + + # Check for finished jobs to send webhook + for k, elem in list(self.jobmgr.jobs.items()): + if elem.get('p') and elem['p'].poll() is not None: + if not elem.get('notified', False): + if not elem.get('t1').is_alive() and not elem.get('t2').is_alive(): + elem['notified'] = True + elem['status'] = 'finished' + elem['rc'] = elem['p'].returncode + + stdout = elem.get('stdout', '') + stderr = elem.get('stderr', '') + der = stdout + ('\n' if stdout and stderr else '') + stderr + webhook_body = { + "nfn": "RESPUESTA_EjecutarScript", + "job_id": k, + "res": 1 if elem['rc'] == 0 else 0, + "der": der + } + logger.info(f"Job {k} finished with return code {elem['rc']}. Sending webhook to ogcore.") + try: + self.REST.sendMessage('clients/status/webhook', webhook_body) + except Exception as e: + logger.error(f"Failed to send webhook for job {k}: {e}") + if not n % 10: _st_map = {'linux': 'LNX', 'macos': 'OSX', 'windows': 'WIN'} + is_busy = any(j['p'].poll() is None for j in self.jobmgr.jobs.values()) or len(self.client_jobs) > 0 body = { "iph": self.interface.ip, "timestamp": int(time.time()), - "busy": any(j['p'].poll() is None for j in self.jobmgr.jobs.values()), + "busy": is_busy, "st": _st_map.get(operations.os_type.lower(), 'UNK'), + "loggedin": len(self.user) > 0, } result = self.REST.sendMessage('clients/status/webhook', body) if result is None: @@ -142,6 +171,36 @@ class OpenGnSysWorker(ServerWorker): logger.info('Connection to ogcore restored, re-registering') self._send_started() + def _best_interface(self, nets): + """ + Return the interface whose IP the OS routing table would use to reach + ogcore, by opening a no-op UDP socket (no packets sent) and reading + the local address assigned by the kernel. + + Returns None if the routing query itself fails (network not ready yet), + so the caller keeps retrying. Falls back to nets[0] only when the query + succeeds but no interface matches the routing IP (e.g. NAT or VPN). + """ + try: + host = urllib.parse.urlparse(self.REST.endpoint).hostname + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect((host, 80)) + local_ip = s.getsockname()[0] + except Exception as e: + logger.debug('Routing query to ogcore failed (network not ready?): {}'.format(e)) + return None + + for iface in nets: + if iface.ip == local_ip: + logger.debug('Selected interface {} ({}) for ogcore at {}'.format( + iface.name, local_ip, host)) + return iface + + # Routing succeeded but no interface matched (NAT/VPN): best effort + logger.debug('No interface matched routing IP {}, falling back to {}'.format( + local_ip, nets[0].ip)) + return nets[0] + def _setup_network(self, ca_file, crt_file, key_file): """ Background thread: waits indefinitely for a network interface, then @@ -159,8 +218,8 @@ class OpenGnSysWorker(ServerWorker): try: nets = list(operations.getNetworkInfo()) if nets: - self.interface = nets[0] - if t > 0: + self.interface = self._best_interface(nets) + if self.interface and t > 0: logger.debug("Network interface found after {} seconds".format(t)) else: logger.error('No network interfaces found') @@ -441,11 +500,38 @@ class OpenGnSysWorker(ServerWorker): def process_client_script_launched(self, data): import tempfile + job_id = data['job_id'] job_id_path = os.path.join(tempfile.gettempdir(), 'EjecutarScript-jobid') with open (job_id_path, 'w') as fd: - fd.write (data['job_id']) + fd.write (job_id) + self.client_jobs.add(job_id) + return True + def process_client_script_finished(self, data): + job_id = data['job_id'] + rc = data['rc'] + stdout = data.get('stdout', '') + stderr = data.get('stderr', '') + der = stdout + ('\n' if stdout and stderr else '') + stderr + + if job_id in self.client_jobs: + self.client_jobs.remove(job_id) + + logger.info(f"Client job {job_id} finished with return code {rc}. Sending webhook to ogcore.") + webhook_body = { + "nfn": "RESPUESTA_EjecutarScript", + "job_id": job_id, + "res": 1 if rc == 0 else 0, + "der": der + } + try: + self.REST.sendMessage('clients/status/webhook', webhook_body) + except Exception as e: + logger.error(f"Failed to send webhook for client job {job_id}: {e}") + return True + + @execution_level('full') @check_secret def process_terminatescript(self, path, get_params, post_params, server): diff --git a/src/opengnsys/utils.py b/src/opengnsys/utils.py index c54b4b7..9df7a89 100644 --- a/src/opengnsys/utils.py +++ b/src/opengnsys/utils.py @@ -32,7 +32,6 @@ import sys -import six if sys.platform == 'win32': _fromEncoding = 'windows-1250' @@ -42,15 +41,15 @@ else: def toUnicode(msg): try: - if not isinstance(msg, six.text_type): - if isinstance(msg, six.binary_type): + if not isinstance(msg, str): + if isinstance(msg, bytes): return msg.decode(_fromEncoding, 'ignore') - return six.text_type(msg) + return str(msg) else: return msg except Exception: try: - return six.text_type(msg) + return str(msg) except Exception: return '' diff --git a/src/opengnsys/windows/operations.py b/src/opengnsys/windows/operations.py index 789bbe3..ff1e79d 100644 --- a/src/opengnsys/windows/operations.py +++ b/src/opengnsys/windows/operations.py @@ -151,6 +151,8 @@ def get_etc_path(): def build_popen_args(script): ## turn the script into utf16le, then to b64 again, and feed the blob to powershell + ## wrap the script to redirect stderr and output as a simple string to avoid CLIXML wrappers in non-interactive errors + script = f"$ProgressPreference = 'SilentlyContinue'; try {{ $errBefore = $Error.Count; $output = & {{ {script} }} 2>&1; $errAfter = $Error.Count; $exitcode = $LASTEXITCODE; $output | Out-String; if ($exitcode) {{ exit $exitcode }} elseif ($errAfter -gt $errBefore) {{ exit 1 }} }} catch {{ $_ | Out-String; exit 1 }}" u16 = script.encode ('utf-16le') ## utf16 b64 = base64.b64encode (u16).decode ('utf-8') ## b64 (which returns bytes, so we need an additional decode(utf8)) return ['powershell', '-WindowStyle', 'Hidden', '-EncodedCommand', b64] diff --git a/src/requirements.txt b/src/requirements.txt index b85c90d..29bf5af 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,6 +1,6 @@ pywin32 pyqt6 requests -six + pycryptodome pyinstaller