devel 8.5.9 #76

Merged
jmbardallo merged 18 commits from devel into main 2026-07-31 10:39:29 +02:00
17 changed files with 211 additions and 68 deletions

View File

@ -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

View File

@ -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')
}

View File

@ -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)

View File

@ -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 <info@opengnsys.es> 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 <info@opengnsys.es> 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

View File

@ -2,7 +2,7 @@ Source: ogagent
Section: admin
Priority: optional
Maintainer: OpenGnsys developers <info@opengnsys.es>
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.

View File

@ -1 +1 @@
8.3.1
8.5.9

View File

@ -65,7 +65,7 @@
<item>
<widget class="QLabel" name="VersionLabel">
<property name="text">
<string>Version 0</string>
<string>Version 8.5.6</string>
</property>
</widget>
</item>

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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']])

View File

@ -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)

View File

@ -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))

View File

@ -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):

View File

@ -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 ''

View File

@ -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]

View File

@ -1,6 +1,6 @@
pywin32
pyqt6
requests
six
pycryptodome
pyinstaller