Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package crmsh for openSUSE:Factory checked in at 2026-11-03 17:59:30 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/crmsh (Old) and /work/SRC/openSUSE:Factory/.crmsh.new.1257 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "crmsh" Tue Nov 3 17:59:30 2026 rev:425 rq:1394654 version:5.1.0+20261103.fb6b3998 Changes: -------- --- /work/SRC/openSUSE:Factory/crmsh/crmsh.changes 2026-10-27 17:09:36.727369894 +0100 +++ /work/SRC/openSUSE:Factory/.crmsh.new.1257/crmsh.changes 2026-11-03 18:04:14.572194587 +0100 @@ -1,0 +2,21 @@ +Tue Nov 03 10:10:32 UTC 2026 - [email protected] + +- Update to version 5.1.0+20261103.fb6b3998: + * test(behave): rename behave_agent.py to avoid duplicated names (#1936) + +------------------------------------------------------------------- +Thu Oct 29 00:48:06 UTC 2026 - [email protected] + +- Update to version 5.1.0+20261028.cd5a01c0: + * fix(main): reset force option per iteration in input loop (#2212) + +------------------------------------------------------------------- +Wed Oct 28 08:28:19 UTC 2026 - [email protected] + +- Update to version 5.1.0+20261028.50e3ed36: + * refactor(main): consolidate in_repl setting into main_input_loop + * refactor(options): replace options.interactive with in_repl() + * refactor(main): remove redundant options.interactive setting in level navigation + * refactor(main): separate single-shot invocation and REPL loop + +------------------------------------------------------------------- Old: ---- crmsh-5.1.0+20261027.e559ffa7.tar.bz2 New: ---- crmsh-5.1.0+20261103.fb6b3998.tar.bz2 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ crmsh.spec ++++++ --- /var/tmp/diff_new_pack.K6QNXw/_old 2026-11-03 18:04:23.991590475 +0100 +++ /var/tmp/diff_new_pack.K6QNXw/_new 2026-11-03 18:04:23.993590559 +0100 @@ -40,7 +40,7 @@ Summary: High Availability cluster command-line interface License: GPL-2.0-or-later Group: %{pkg_group} -Version: 5.1.0+20261027.e559ffa7 +Version: 5.1.0+20261103.fb6b3998 Release: 0 URL: http://crmsh.github.io Source0: %{name}-%{version}.tar.bz2 ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.K6QNXw/_old 2026-11-03 18:04:24.052593038 +0100 +++ /var/tmp/diff_new_pack.K6QNXw/_new 2026-11-03 18:04:24.056593207 +0100 @@ -9,7 +9,7 @@ </service> <service name="tar_scm"> <param name="url">https://github.com/ClusterLabs/crmsh.git</param> - <param name="changesrevision">828483ec8937b01e50ce5586452cfa56f5fd055d</param> + <param name="changesrevision">3a170c26a2af07f2986faa72ec8a4981c66f5b06</param> </service> </servicedata> (No newline at EOF) ++++++ crmsh-5.1.0+20261027.e559ffa7.tar.bz2 -> crmsh-5.1.0+20261103.fb6b3998.tar.bz2 ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/cibconfig.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/cibconfig.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/cibconfig.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/cibconfig.py 2026-11-03 10:36:25.000000000 +0100 @@ -3354,7 +3354,7 @@ return self.modified_elems() or self.remove_queue or self.new_schema def ensure_cib_updated(self): - if options.interactive and not self.has_cib_changed(): + if options.in_repl() and not self.has_cib_changed(): self.refresh() def _verify_constraints(self, node): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/main.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/main.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/main.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/main.py 2026-11-03 10:36:25.000000000 +0100 @@ -67,7 +67,7 @@ ''' Write the history file. Remove tmp files. ''' - if options.interactive and not options.batch: + if options.in_repl(): try: from readline import write_history_file write_history_file(userdir.HISTORY_FILE) @@ -162,12 +162,6 @@ sys.exit(rc) -def set_interactive(): - '''Set the interactive option only if we're on a tty.''' - if utils.can_ask(): - options.interactive = True - - def add_quotes(args): ''' Add quotes if there's whitespace in one of the @@ -190,42 +184,77 @@ return l -def handle_noninteractive_use(context, user_args): +def _init_shadow_cib(context): + if options.shadow: + return bool(context.run("cib use " + options.shadow)) + return True + + +def handle_single_shot(context, user_args): """ - returns: either a status code of 0 or 1, or - None to indicate that nothing was done here. + Execute a single-shot command directly without entering the input loop. + Returns exit code 0 on success, 1 on failure. """ - if options.shadow: - if not context.run("cib use " + options.shadow): - return 1 + logger_utils.reset_lineno() + + l = add_quotes(user_args) + if context.run(' '.join(l)): + return 0 + return 1 + + +handle_noninteractive_use = handle_single_shot + + +def _is_level_navigation(context, user_args): + """ + Check if the user arguments are navigating into UI levels rather than executing a command. + """ + if not user_args: + return False - # this special case is silly, but we have to keep it to - # preserve the backward compatibility if len(user_args) == 1 and user_args[0].startswith("conf"): - if not context.run("configure"): - return 1 - elif len(user_args) > 0: - # we're not sure yet whether it's an interactive session or not - # (single-shot commands aren't) - logger_utils.reset_lineno() - options.interactive = False + return True - l = add_quotes(user_args) - if context.run(' '.join(l)): - # if the user entered a level, then just continue - if not context.previous_level(): - return 0 - set_interactive() - if options.interactive: - logger_utils.reset_lineno(-1) - else: + current_level = context.current_level() + for token in user_args: + child = current_level.get_child(token) + if not child or child.type != 'level': + return False + current_level = child.level() + return True + + +def _enter_level_from_args(context, user_args): + """ + Enter UI levels specified in user_args for an interactive session. + Returns True on success, False on failure. + """ + if len(user_args) == 1 and user_args[0].startswith("conf"): + cmd = "configure" + else: + cmd = ' '.join(add_quotes(user_args)) + + return bool(context.run(cmd)) + + +def dispatch(context, user_args): + """ + Dispatch CLI invocation to single-shot command execution or REPL input loop. + """ + if _is_level_navigation(context, user_args): + if not _enter_level_from_args(context, user_args): return 1 - return None + return main_input_loop(context) + elif user_args: + return handle_single_shot(context, user_args) + else: + return main_input_loop(context) def render_prompt(context): rendered_prompt = constants.prompt - if options.interactive and not options.batch: + if options.in_repl(): # TODO: fix how color interacts with readline, # seems the color prompt messes it up promptstr = "crm(%s/%s)%s# " % (cib_prompt(), utils.this_node(), context.prompt()) @@ -245,27 +274,28 @@ logger.error(msg) usage(2) - if options.interactive and not options.batch: + if options.in_repl(): context.setup_readline() -def main_input_loop(context, user_args): +def main_input_loop(context): """ Main input loop for crmsh. Parses input line by line. """ - rc = handle_noninteractive_use(context, user_args) - if rc is not None: - return rc + if not options.batch: + options.set_in_repl(True) setup_context(context) + initial_force = options.force rc = 0 while True: try: + options.force = initial_force inp = utils.multi_input(render_prompt(context)) if inp is None: - if options.interactive: + if options.in_repl(): rc = 0 context.quit(rc) try: @@ -275,7 +305,7 @@ rc = 1 logger.error(e, exc_info=e) except KeyboardInterrupt: - if options.interactive and not options.batch: + if options.in_repl(): print("Ctrl-C, leaving") context.quit(1) except Exception as e: @@ -304,7 +334,6 @@ if idx >= 0: line = line[idx+3:].lstrip() - options.interactive = False ui = ui_root.Root() context = ui_context.Context(ui) last_word = line.rsplit(' ', 1) @@ -327,7 +356,7 @@ options.force = opts.force if opts.filename: logger_utils.reset_lineno() - options.input_file, options.batch, options.interactive = opts.filename, True, False + options.input_file, options.batch = opts.filename, True options.history = opts.history or options.history config.core.wait = opts.wait or config.core.wait options.shadow = opts.cib or options.shadow @@ -345,9 +374,9 @@ def profile_run(context, user_args): import cProfile - cProfile.runctx('main_input_loop(context, user_args)', + cProfile.runctx('dispatch(context, user_args)', globals(), - {'context': context, 'user_args': user_args}, + {'context': context, 'user_args': user_args, 'dispatch': dispatch}, filename=options.profile) # print how to use the profile file, but don't disturb # the regression tests @@ -372,18 +401,19 @@ load_rc(context, userdir.RC_FILE) atexit.register(exit_handler) - options.interactive = utils.can_ask() - if not options.interactive: + if not utils.can_ask(): logger_utils.reset_lineno() options.batch = True user_args = parse_options() + if not _init_shadow_cib(context): + return 1 if config.core.debug: logger.debug(utils.debug_timestamp()) term.init() if options.profile: return profile_run(context, user_args) else: - return main_input_loop(context, user_args) + return dispatch(context, user_args) except network_utils.NoSSHError as msg: logger.error('%s', msg) sys.exit(1) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/options.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/options.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/options.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/options.py 2026-11-03 10:36:25.000000000 +0100 @@ -5,7 +5,6 @@ Session-only options (not saved). ''' -interactive = False batch = False ask_no = False regression_tests = False @@ -17,3 +16,16 @@ # set to true when completing non-interactively shell_completion = False force = False + +_in_repl = False + + +def in_repl(): + '''Return True if running in the interactive REPL shell.''' + return _in_repl + + +def set_in_repl(val=True): + global _in_repl + _in_repl = bool(val) + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_configure.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_configure.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_configure.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_configure.py 2026-11-03 10:36:25.000000000 +0100 @@ -247,7 +247,7 @@ if completing == 'params': return ['params'] if completing.endswith('='): - if len(completing) > 1 and options.interactive: + if len(completing) > 1 and options.in_repl(): topic = completing[:-1] CompletionHelp.help(topic, agent.meta_parameter(topic), args) return [] @@ -262,7 +262,7 @@ if completing == 'meta': return ['meta'] if completing.endswith('='): - if len(completing) > 1 and options.interactive: + if len(completing) > 1 and options.in_repl(): topic = completing[:-1] CompletionHelp.help(topic, agent.meta_parameter(topic), args) return [] @@ -501,7 +501,7 @@ # to show help messages if completing.endswith('='): - if len(completing) > 1 and options.interactive: + if len(completing) > 1 and options.in_repl(): topic = completing[:-1] CompletionHelp.help(topic, container_helptxt(args, constants.container_helptxt, topic), args) return [] @@ -861,7 +861,7 @@ context.fatal_error("Unknown method %s" % method) cib_factory.ensure_cib_updated() if method == "replace": - if options.interactive and cib_factory.has_cib_changed(): + if options.in_repl() and cib_factory.has_cib_changed(): if not utils.ask("This operation will erase all changes. Do you want to proceed?"): return False cib_factory.erase() @@ -939,7 +939,7 @@ @command.skill_level('administrator') def do_refresh(self, context): "usage: refresh" - if options.interactive and cib_factory.has_cib_changed(): + if options.in_repl() and cib_factory.has_cib_changed(): if not utils.ask("All changes will be dropped. Do you want to proceed?"): return cib_factory.refresh() @@ -1296,7 +1296,7 @@ def end_game(self, no_questions_asked=False): ok = True if cib_factory.has_cib_changed(): - if no_questions_asked or not options.interactive: + if no_questions_asked or not options.in_repl(): ok = self._commit() else: print("There are changes pending:") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_context.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_context.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_context.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_context.py 2026-11-03 10:36:25.000000000 +0100 @@ -246,11 +246,6 @@ Pushes an instance of the given UILevel subclass onto self.stack. ''' - # on entering new level we need to set the - # interactive option _before_ creating the level - if not options.interactive and not self.command_args: - self._set_interactive() - # not sure what this is all about self._in_transit = True @@ -262,11 +257,6 @@ self.stack.append(entry) self.clear_readline_cache() - def _set_interactive(self): - '''Set the interactive option only if we're on a tty.''' - if utils.can_ask(): - options.interactive = True - def execute_command(self): # build argument list arglist = [self.current_level(), self] + self.command_args @@ -288,7 +278,7 @@ return True by_level = self.current_level().should_wait() - transit_or_noninteractive = self.is_in_transit() or not options.interactive + transit_or_noninteractive = self.is_in_transit() or not options.in_repl() return by_level and transit_or_noninteractive def is_in_transit(self): @@ -351,7 +341,7 @@ ok = self.current_level().end_game() except utils.TerminateSubCommand: return - if options.interactive and not options.batch: + if options.in_repl(): if constants.need_reset: utils.ext_cmd("reset") else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_options.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_options.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_options.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_options.py 2026-11-03 10:36:25.000000000 +0100 @@ -174,7 +174,7 @@ config.reset() def end_game(self, no_questions_asked=False): - if no_questions_asked and not options.interactive: + if no_questions_asked and not options.in_repl(): self.do_save(None) # vim:ts=4:sw=4:et: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_template.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_template.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/ui_template.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/ui_template.py 2026-11-03 10:36:25.000000000 +0100 @@ -177,7 +177,7 @@ if not tmp: return False if method == "replace": - if options.interactive and cib_factory.has_cib_changed(): + if options.in_repl() and cib_factory.has_cib_changed(): if not utils.ask("This operation will erase all changes. Do you want to proceed?"): return False cib_factory.erase() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/crmsh/utils.py new/crmsh-5.1.0+20261103.fb6b3998/crmsh/utils.py --- old/crmsh-5.1.0+20261027.e559ffa7/crmsh/utils.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/crmsh/utils.py 2026-11-03 10:36:25.000000000 +0100 @@ -763,7 +763,7 @@ Even though the frequency may be set to always, it doesn't make sense to do that with non-interactive sessions. ''' - return options.interactive and config.core.check_frequency == "always" + return options.in_repl() and config.core.check_frequency == "always" def get_check_rc(): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/data-manifest new/crmsh-5.1.0+20261103.fb6b3998/data-manifest --- old/crmsh-5.1.0+20261027.e559ffa7/data-manifest 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/data-manifest 2026-11-03 10:36:25.000000000 +0100 @@ -92,7 +92,7 @@ test/features/resource_set.feature test/features/sbd_ui.feature test/features/ssh_agent.feature -test/features/steps/behave_agent.py +test/features/steps/behave_agent_client.py test/features/steps/bootstrap_steps.py test/features/steps/const.py test/features/steps/__init__.py @@ -202,9 +202,11 @@ test/unittests/test_handles.py test/unittests/test_lock.py test/unittests/test_log.py +test/unittests/test_main.py test/unittests/test_migration.py test/unittests/test_network_utils.py test/unittests/test_objset.py +test/unittests/test_options.py test/unittests/test_parallax.py test/unittests/test_parse.py test/unittests/test_prun.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/behave_agent.py new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/behave_agent.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/behave_agent.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/behave_agent.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -# behave_agent.py - a simple agent to execute command -# NO AUTHENTICATIONS. It should only be used in behave test. -import io -import os -import pwd -import socket -import struct -import subprocess -import typing - - -MSG_EOF = 0 -MSG_USER = 1 -MSG_CMD = 2 -MSG_OUT = 4 -MSG_ERR = 5 -MSG_RC = 6 - - -class Message: - @staticmethod - def write(output, type: int, data: bytes): - output.write(struct.pack('!ii', type, len(data))) - output.write(data) - - @staticmethod - def read(input): - buf = input.read(8) - type, length = struct.unpack('!ii', buf) - if length > 0: - buf = input.read(length) - else: - buf = b'' - return type, buf - - -class SocketIO(io.RawIOBase): - def __init__(self, s: socket.socket): - self._socket = s - - def readable(self) -> bool: - return True - - def writable(self) -> bool: - return True - - def read(self, __size: int = -1) -> bytes: - return self._socket.recv(__size) - - def readinto(self, __buffer) -> int: - return self._socket.recv_into(__buffer) - - def readall(self) -> bytes: - raise NotImplementedError - - def write(self, __b) -> int: - return self._socket.send(__b) - - -def call(host: str, port: int, cmdline: str, user: typing.Optional[str] = None): - family, type, proto, _, sockaddr = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0] - with socket.socket(family, type, proto) as s: - s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - if hasattr(socket, "TCP_KEEPIDLE"): - s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30) # start after 30s idle - s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # probe every 10s - s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # 3 probes - s.connect(sockaddr) - sout = io.BufferedWriter(SocketIO(s), 4096) - Message.write(sout, MSG_USER, user.encode('utf-8') if user else _getuser().encode('utf-8')) - Message.write(sout, MSG_CMD, cmdline.encode('utf-8')) - Message.write(sout, MSG_EOF, b'') - sout.flush() - s.shutdown(socket.SHUT_WR) - rc = None - stdout = [] - stderr = [] - sin = io.BufferedReader(SocketIO(s), 4096) - while True: - type, buf = Message.read(sin) - if type == MSG_OUT: - stdout.append(buf) - elif type == MSG_ERR: - stderr.append(buf) - elif type == MSG_RC: - rc, = struct.unpack('!i', buf) - elif type == MSG_EOF: - assert rc is not None - return rc, b''.join(stdout), b''.join(stderr) - else: - raise ValueError(f"Unknown message type: {type}") - - -def serve(stdin, stdout, stderr): - # This is an xinetd-style service. - assert os.geteuid() == 0 - user = None - cmd = None - sin = io.BufferedReader(stdin) - while True: - type, buf = Message.read(sin) - if type == MSG_USER: - user = buf.decode('utf-8') - elif type == MSG_CMD: - cmd = buf.decode('utf-8') - elif type == MSG_EOF: - assert user is not None - assert cmd is not None - break - else: - raise ValueError(f"Unknown message type: {type}") - if user == 'root': - args = ['/bin/sh'] - else: - args = ['/bin/su', '-', user, '-c', '/bin/sh'] - result = subprocess.run( - args, - input=cmd.encode('utf-8'), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - sout = io.BufferedWriter(stdout) - Message.write(sout, MSG_RC, struct.pack('!i', result.returncode)) - Message.write(sout, MSG_OUT, result.stdout) - Message.write(sout, MSG_ERR, result.stderr) - Message.write(sout, MSG_EOF, b'') - stdout.flush() - - -def _getuser(): - return pwd.getpwuid(os.geteuid()).pw_name - - -if __name__ == '__main__': - with open(0, 'rb') as stdin, \ - open(1, 'wb') as stdout, \ - open(2, 'wb') as stderr: - serve(stdin, stdout, stderr) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/behave_agent_client.py new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/behave_agent_client.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/behave_agent_client.py 1970-01-01 01:00:00.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/behave_agent_client.py 2026-11-03 10:36:25.000000000 +0100 @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# behave_agent.py - a simple agent to execute command +# NO AUTHENTICATIONS. It should only be used in behave test. +import io +import os +import pwd +import socket +import struct +import subprocess +import typing + + +MSG_EOF = 0 +MSG_USER = 1 +MSG_CMD = 2 +MSG_OUT = 4 +MSG_ERR = 5 +MSG_RC = 6 + + +class Message: + @staticmethod + def write(output, type: int, data: bytes): + output.write(struct.pack('!ii', type, len(data))) + output.write(data) + + @staticmethod + def read(input): + buf = input.read(8) + type, length = struct.unpack('!ii', buf) + if length > 0: + buf = input.read(length) + else: + buf = b'' + return type, buf + + +class SocketIO(io.RawIOBase): + def __init__(self, s: socket.socket): + self._socket = s + + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return True + + def read(self, __size: int = -1) -> bytes: + return self._socket.recv(__size) + + def readinto(self, __buffer) -> int: + return self._socket.recv_into(__buffer) + + def readall(self) -> bytes: + raise NotImplementedError + + def write(self, __b) -> int: + return self._socket.send(__b) + + +def call(host: str, port: int, cmdline: str, user: typing.Optional[str] = None): + family, type, proto, _, sockaddr = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0] + with socket.socket(family, type, proto) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + if hasattr(socket, "TCP_KEEPIDLE"): + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30) # start after 30s idle + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # probe every 10s + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # 3 probes + s.connect(sockaddr) + sout = io.BufferedWriter(SocketIO(s), 4096) + Message.write(sout, MSG_USER, user.encode('utf-8') if user else _getuser().encode('utf-8')) + Message.write(sout, MSG_CMD, cmdline.encode('utf-8')) + Message.write(sout, MSG_EOF, b'') + sout.flush() + s.shutdown(socket.SHUT_WR) + rc = None + stdout = [] + stderr = [] + sin = io.BufferedReader(SocketIO(s), 4096) + while True: + type, buf = Message.read(sin) + if type == MSG_OUT: + stdout.append(buf) + elif type == MSG_ERR: + stderr.append(buf) + elif type == MSG_RC: + rc, = struct.unpack('!i', buf) + elif type == MSG_EOF: + assert rc is not None + return rc, b''.join(stdout), b''.join(stderr) + else: + raise ValueError(f"Unknown message type: {type}") + + +def serve(stdin, stdout, stderr): + # This is an xinetd-style service. + assert os.geteuid() == 0 + user = None + cmd = None + sin = io.BufferedReader(stdin) + while True: + type, buf = Message.read(sin) + if type == MSG_USER: + user = buf.decode('utf-8') + elif type == MSG_CMD: + cmd = buf.decode('utf-8') + elif type == MSG_EOF: + assert user is not None + assert cmd is not None + break + else: + raise ValueError(f"Unknown message type: {type}") + if user == 'root': + args = ['/bin/sh'] + else: + args = ['/bin/su', '-', user, '-c', '/bin/sh'] + result = subprocess.run( + args, + input=cmd.encode('utf-8'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + sout = io.BufferedWriter(stdout) + Message.write(sout, MSG_RC, struct.pack('!i', result.returncode)) + Message.write(sout, MSG_OUT, result.stdout) + Message.write(sout, MSG_ERR, result.stderr) + Message.write(sout, MSG_EOF, b'') + stdout.flush() + + +def _getuser(): + return pwd.getpwuid(os.geteuid()).pw_name + + +if __name__ == '__main__': + with open(0, 'rb') as stdin, \ + open(1, 'wb') as stdout, \ + open(2, 'wb') as stderr: + serve(stdin, stdout, stderr) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/bootstrap_steps.py new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/bootstrap_steps.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/bootstrap_steps.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/bootstrap_steps.py 2026-11-03 10:36:25.000000000 +0100 @@ -1,7 +1,7 @@ import random from behave import given, when, then -import behave_agent +import behave_agent_client as behave_agent _rng = random.SystemRandom() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/step_implementation.py new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/step_implementation.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/step_implementation.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/step_implementation.py 2026-11-03 10:36:25.000000000 +0100 @@ -7,7 +7,7 @@ import behave from behave import given, when, then -import behave_agent +import behave_agent_client as behave_agent from crmsh import corosync, userdir, bootstrap from crmsh import utils as crmutils from crmsh import xmlutil diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/utils.py new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/utils.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/features/steps/utils.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/features/steps/utils.py 2026-11-03 10:36:25.000000000 +0100 @@ -6,7 +6,7 @@ import socket from crmsh import utils, userdir from crmsh.sh import ShellUtils -import behave_agent +import behave_agent_client as behave_agent COLOR_MODE = r'\x1b\[[0-9]+m' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/unittests/test_main.py new/crmsh-5.1.0+20261103.fb6b3998/test/unittests/test_main.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/unittests/test_main.py 1970-01-01 01:00:00.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/unittests/test_main.py 2026-11-03 10:36:25.000000000 +0100 @@ -0,0 +1,262 @@ +import sys +import unittest +from unittest import mock + +from crmsh import main, options, ui_root, ui_context + + +class TestInitShadowCib(unittest.TestCase): + def setUp(self): + self.orig_shadow = options.shadow + self.context = mock.Mock() + + def tearDown(self): + options.shadow = self.orig_shadow + + def test_no_shadow_option(self): + options.shadow = None + result = main._init_shadow_cib(self.context) + self.assertTrue(result) + self.context.run.assert_not_called() + + def test_shadow_option_success(self): + options.shadow = "test_shadow" + self.context.run.return_value = True + result = main._init_shadow_cib(self.context) + self.assertTrue(result) + self.context.run.assert_called_once_with("cib use test_shadow") + + def test_shadow_option_failure(self): + options.shadow = "test_shadow" + self.context.run.return_value = False + result = main._init_shadow_cib(self.context) + self.assertFalse(result) + self.context.run.assert_called_once_with("cib use test_shadow") + + +class TestHandleSingleShot(unittest.TestCase): + def setUp(self): + self.context = mock.Mock() + + @mock.patch('crmsh.main.logger_utils.reset_lineno') + def test_success(self, mock_reset_lineno): + self.context.run.return_value = True + + rc = main.handle_single_shot(self.context, ["status"]) + + self.assertEqual(rc, 0) + mock_reset_lineno.assert_called_once_with() + self.context.run.assert_called_once_with("status") + + @mock.patch('crmsh.main.logger_utils.reset_lineno') + def test_failure(self, mock_reset_lineno): + self.context.run.return_value = False + + rc = main.handle_single_shot(self.context, ["status"]) + + self.assertEqual(rc, 1) + mock_reset_lineno.assert_called_once_with() + self.context.run.assert_called_once_with("status") + + @mock.patch('crmsh.main.logger_utils.reset_lineno') + def test_with_arguments_and_quotes(self, mock_reset_lineno): + self.context.run.return_value = True + rc = main.handle_single_shot(self.context, ["resource", "param", "rsc1", "set", "key=val with space"]) + self.assertEqual(rc, 0) + self.context.run.assert_called_once_with('resource param rsc1 set "key=val with space"') + + +class TestIsLevelNavigation(unittest.TestCase): + def setUp(self): + root = ui_root.Root() + self.context = ui_context.Context(root) + + def test_empty_args(self): + self.assertFalse(main._is_level_navigation(self.context, [])) + self.assertFalse(main._is_level_navigation(self.context, None)) + + def test_conf_prefix(self): + self.assertTrue(main._is_level_navigation(self.context, ["conf"])) + self.assertTrue(main._is_level_navigation(self.context, ["configure"])) + + def test_top_level(self): + self.assertTrue(main._is_level_navigation(self.context, ["cluster"])) + self.assertTrue(main._is_level_navigation(self.context, ["node"])) + self.assertTrue(main._is_level_navigation(self.context, ["resource"])) + + def test_multi_token_level(self): + self.assertTrue(main._is_level_navigation(self.context, ["corosync", "link"])) + + def test_single_shot_subcommands(self): + self.assertFalse(main._is_level_navigation(self.context, ["status"])) + self.assertFalse(main._is_level_navigation(self.context, ["cluster", "start"])) + self.assertFalse(main._is_level_navigation(self.context, ["configure", "show"])) + + def test_unknown_subcommand(self): + self.assertFalse(main._is_level_navigation(self.context, ["nonexistent"])) + self.assertFalse(main._is_level_navigation(self.context, ["cluster", "nonexistent"])) + + +class TestEnterLevelFromArgs(unittest.TestCase): + def setUp(self): + self.context = mock.Mock() + + def test_conf_shorthand_success(self): + self.context.run.return_value = True + + result = main._enter_level_from_args(self.context, ["conf"]) + + self.assertTrue(result) + self.context.run.assert_called_once_with("configure") + + def test_level_success(self): + self.context.run.return_value = True + + result = main._enter_level_from_args(self.context, ["corosync", "link"]) + + self.assertTrue(result) + self.context.run.assert_called_once_with("corosync link") + + def test_enter_level_failure(self): + self.context.run.return_value = False + result = main._enter_level_from_args(self.context, ["cluster"]) + self.assertFalse(result) + self.context.run.assert_called_once_with("cluster") + + +class TestDispatch(unittest.TestCase): + def setUp(self): + self.context = mock.Mock() + + @mock.patch('crmsh.main.handle_single_shot') + @mock.patch('crmsh.main.main_input_loop') + @mock.patch('crmsh.main._enter_level_from_args') + @mock.patch('crmsh.main._is_level_navigation') + def test_dispatch_level_navigation_success(self, mock_is_level, mock_enter_level, mock_loop, mock_single_shot): + mock_is_level.return_value = True + mock_enter_level.return_value = True + mock_loop.return_value = 0 + + rc = main.dispatch(self.context, ["configure"]) + + self.assertEqual(rc, 0) + mock_is_level.assert_called_once_with(self.context, ["configure"]) + mock_enter_level.assert_called_once_with(self.context, ["configure"]) + mock_loop.assert_called_once_with(self.context) + mock_single_shot.assert_not_called() + + @mock.patch('crmsh.main.handle_single_shot') + @mock.patch('crmsh.main.main_input_loop') + @mock.patch('crmsh.main._enter_level_from_args') + @mock.patch('crmsh.main._is_level_navigation') + def test_dispatch_level_navigation_failure(self, mock_is_level, mock_enter_level, mock_loop, mock_single_shot): + mock_is_level.return_value = True + mock_enter_level.return_value = False + + rc = main.dispatch(self.context, ["configure"]) + + self.assertEqual(rc, 1) + mock_is_level.assert_called_once_with(self.context, ["configure"]) + mock_enter_level.assert_called_once_with(self.context, ["configure"]) + mock_loop.assert_not_called() + mock_single_shot.assert_not_called() + + @mock.patch('crmsh.main.handle_single_shot') + @mock.patch('crmsh.main.main_input_loop') + @mock.patch('crmsh.main._is_level_navigation') + def test_dispatch_single_shot(self, mock_is_level, mock_loop, mock_single_shot): + mock_is_level.return_value = False + mock_single_shot.return_value = 0 + + rc = main.dispatch(self.context, ["status"]) + + self.assertEqual(rc, 0) + mock_is_level.assert_called_once_with(self.context, ["status"]) + mock_single_shot.assert_called_once_with(self.context, ["status"]) + mock_loop.assert_not_called() + + @mock.patch('crmsh.main.handle_single_shot') + @mock.patch('crmsh.main.main_input_loop') + @mock.patch('crmsh.main._is_level_navigation') + def test_dispatch_no_args(self, mock_is_level, mock_loop, mock_single_shot): + mock_is_level.return_value = False + mock_loop.return_value = 0 + + rc = main.dispatch(self.context, []) + + self.assertEqual(rc, 0) + mock_is_level.assert_called_once_with(self.context, []) + mock_single_shot.assert_not_called() + mock_loop.assert_called_once_with(self.context) + + +class TestMainInputLoop(unittest.TestCase): + def setUp(self): + self.context = mock.Mock() + self.orig_in_repl = options.in_repl() + self.orig_batch = options.batch + self.orig_force = options.force + + def tearDown(self): + options.set_in_repl(self.orig_in_repl) + options.batch = self.orig_batch + options.force = self.orig_force + + @mock.patch('crmsh.main.setup_context') + @mock.patch('crmsh.utils.multi_input') + def test_input_loop_runs_and_exits(self, mock_multi_input, mock_setup_context): + options.batch = False + options.set_in_repl(False) + mock_multi_input.side_effect = ["node status", None] + self.context.run.return_value = True + self.context.quit.side_effect = SystemExit(0) + + with self.assertRaises(SystemExit) as cm: + main.main_input_loop(self.context) + + self.assertEqual(cm.exception.code, 0) + self.assertTrue(options.in_repl()) + mock_setup_context.assert_called_once_with(self.context) + self.context.run.assert_called_once_with("node status") + self.context.quit.assert_called_once_with(0) + + @mock.patch('crmsh.main.setup_context') + @mock.patch('crmsh.utils.multi_input') + def test_input_loop_force_option_resets_per_iteration(self, mock_multi_input, mock_setup_context): + options.batch = False + options.force = False + mock_multi_input.side_effect = ["sbd purge -F", "cluster init sbd -S -y", None] + + observed_force = [] + + def side_effect_run(cmd): + observed_force.append((cmd, options.force)) + if "-F" in cmd: + options.force = True + return True + + self.context.run.side_effect = side_effect_run + self.context.quit.side_effect = SystemExit(0) + + with self.assertRaises(SystemExit) as cm: + main.main_input_loop(self.context) + + self.assertEqual(cm.exception.code, 0) + self.assertEqual(observed_force, [ + ("sbd purge -F", False), + ("cluster init sbd -S -y", False), + ]) + + @mock.patch('crmsh.main.setup_context') + @mock.patch('crmsh.utils.multi_input') + def test_input_loop_keyboard_interrupt(self, mock_multi_input, mock_setup_context): + options.batch = True + options.set_in_repl(False) + mock_multi_input.side_effect = KeyboardInterrupt + self.context.quit.side_effect = SystemExit(1) + + with self.assertRaises(SystemExit) as cm: + main.main_input_loop(self.context) + + self.assertEqual(cm.exception.code, 1) + self.context.quit.assert_called_once_with(1) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test/unittests/test_options.py new/crmsh-5.1.0+20261103.fb6b3998/test/unittests/test_options.py --- old/crmsh-5.1.0+20261027.e559ffa7/test/unittests/test_options.py 1970-01-01 01:00:00.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test/unittests/test_options.py 2026-11-03 10:36:25.000000000 +0100 @@ -0,0 +1,24 @@ +import unittest +from crmsh import options + + +class TestOptions(unittest.TestCase): + def setUp(self): + self.orig_in_repl = options.in_repl() + + def tearDown(self): + options.set_in_repl(self.orig_in_repl) + + def test_in_repl_default(self): + options.set_in_repl(False) + self.assertFalse(options.in_repl()) + + def test_set_in_repl_true(self): + options.set_in_repl(True) + self.assertTrue(options.in_repl()) + + def test_set_in_repl_false(self): + options.set_in_repl(True) + self.assertTrue(options.in_repl()) + options.set_in_repl(False) + self.assertFalse(options.in_repl()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test_container/Dockerfile new/crmsh-5.1.0+20261103.fb6b3998/test_container/Dockerfile --- old/crmsh-5.1.0+20261027.e559ffa7/test_container/Dockerfile 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test_container/Dockerfile 2026-11-03 10:36:25.000000000 +0100 @@ -15,7 +15,7 @@ RUN mkdir -p /var/log/crmsh -COPY behave_agent.py /opt +COPY behave_agent_server.py /opt/behave_agent.py COPY behave-agent.socket /etc/systemd/system COPY [email protected] /etc/systemd/system RUN systemctl enable behave-agent.socket diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test_container/behave_agent.py new/crmsh-5.1.0+20261103.fb6b3998/test_container/behave_agent.py --- old/crmsh-5.1.0+20261027.e559ffa7/test_container/behave_agent.py 2026-10-27 09:52:08.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test_container/behave_agent.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -import io -import os -import pwd -import socket -import struct -import subprocess - - -MSG_EOF = 0 -MSG_USER = 1 -MSG_CMD = 2 -MSG_OUT = 4 -MSG_ERR = 5 -MSG_RC = 6 - - -class Message: - @staticmethod - def write(output, type: int, data: bytes): - output.write(struct.pack('!ii', type, len(data))) - output.write(data) - - @staticmethod - def read(input): - buf = input.read(8) - type, length = struct.unpack('!ii', buf) - if length > 0: - buf = input.read(length) - else: - buf = b'' - return type, buf - - -class SocketIO(io.RawIOBase): - def __init__(self, s: socket.socket): - self._socket = s - - def readable(self) -> bool: - return True - - def writable(self) -> bool: - return True - - def read(self, __size: int = -1) -> bytes: - return self._socket.recv(__size) - - def readinto(self, __buffer) -> int: - return self._socket.recv_into(__buffer) - - def readall(self) -> bytes: - raise NotImplementedError - - def write(self, __b) -> int: - return self._socket.send(__b) - - -def call(host: str, port: int, cmdline: str): - family, type, proto, _, sockaddr = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0] - with socket.socket(family, type, proto) as s: - s.connect(sockaddr) - sout = io.BufferedWriter(SocketIO(s), 4096) - Message.write(sout, MSG_USER, _getuser().encode('utf-8')) - Message.write(sout, MSG_CMD, cmdline.encode('utf-8')) - Message.write(sout, MSG_EOF, b'') - sout.flush() - s.shutdown(socket.SHUT_WR) - rc = None - stdout = [] - stderr = [] - sin = io.BufferedReader(SocketIO(s), 4096) - while True: - type, buf = Message.read(sin) - if type == MSG_OUT: - stdout.append(buf) - elif type == MSG_ERR: - stderr.append(buf) - elif type == MSG_RC: - rc, = struct.unpack('!i', buf) - elif type == MSG_EOF: - s.shutdown(socket.SHUT_RD) - assert rc is not None - return rc, b''.join(stdout), b''.join(stderr) - else: - raise ValueError(f"Unknown message type: {type}") - - -def serve(stdin, stdout, stderr): - assert os.geteuid() == 0 - user = None - cmd = None - sin = io.BufferedReader(stdin) - while True: - type, buf = Message.read(sin) - if type == MSG_USER: - user = buf.decode('utf-8') - elif type == MSG_CMD: - cmd = buf.decode('utf-8') - elif type == MSG_EOF: - assert user is not None - assert cmd is not None - break - else: - raise ValueError(f"Unknown message type: {type}") - if user == 'root': - args = ['/bin/sh'] - else: - args = ['/bin/su', '-', user, '-c', '/bin/sh'] - result = subprocess.run( - args, - input=cmd.encode('utf-8'), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - sout = io.BufferedWriter(stdout) - Message.write(sout, MSG_RC, struct.pack('!i', result.returncode)) - Message.write(sout, MSG_OUT, result.stdout) - Message.write(sout, MSG_ERR, result.stderr) - Message.write(sout, MSG_EOF, b'') - stdout.flush() - - -def _getuser(): - return pwd.getpwuid(os.geteuid()).pw_name - - -if __name__ == '__main__': - with open(0, 'rb') as stdin, \ - open(1, 'wb') as stdout, \ - open(2, 'wb') as stderr: - serve(stdin, stdout, stderr) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/crmsh-5.1.0+20261027.e559ffa7/test_container/behave_agent_server.py new/crmsh-5.1.0+20261103.fb6b3998/test_container/behave_agent_server.py --- old/crmsh-5.1.0+20261027.e559ffa7/test_container/behave_agent_server.py 1970-01-01 01:00:00.000000000 +0100 +++ new/crmsh-5.1.0+20261103.fb6b3998/test_container/behave_agent_server.py 2026-11-03 10:36:25.000000000 +0100 @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +import io +import os +import pwd +import socket +import struct +import subprocess + + +MSG_EOF = 0 +MSG_USER = 1 +MSG_CMD = 2 +MSG_OUT = 4 +MSG_ERR = 5 +MSG_RC = 6 + + +class Message: + @staticmethod + def write(output, type: int, data: bytes): + output.write(struct.pack('!ii', type, len(data))) + output.write(data) + + @staticmethod + def read(input): + buf = input.read(8) + type, length = struct.unpack('!ii', buf) + if length > 0: + buf = input.read(length) + else: + buf = b'' + return type, buf + + +class SocketIO(io.RawIOBase): + def __init__(self, s: socket.socket): + self._socket = s + + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return True + + def read(self, __size: int = -1) -> bytes: + return self._socket.recv(__size) + + def readinto(self, __buffer) -> int: + return self._socket.recv_into(__buffer) + + def readall(self) -> bytes: + raise NotImplementedError + + def write(self, __b) -> int: + return self._socket.send(__b) + + +def call(host: str, port: int, cmdline: str): + family, type, proto, _, sockaddr = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0] + with socket.socket(family, type, proto) as s: + s.connect(sockaddr) + sout = io.BufferedWriter(SocketIO(s), 4096) + Message.write(sout, MSG_USER, _getuser().encode('utf-8')) + Message.write(sout, MSG_CMD, cmdline.encode('utf-8')) + Message.write(sout, MSG_EOF, b'') + sout.flush() + s.shutdown(socket.SHUT_WR) + rc = None + stdout = [] + stderr = [] + sin = io.BufferedReader(SocketIO(s), 4096) + while True: + type, buf = Message.read(sin) + if type == MSG_OUT: + stdout.append(buf) + elif type == MSG_ERR: + stderr.append(buf) + elif type == MSG_RC: + rc, = struct.unpack('!i', buf) + elif type == MSG_EOF: + s.shutdown(socket.SHUT_RD) + assert rc is not None + return rc, b''.join(stdout), b''.join(stderr) + else: + raise ValueError(f"Unknown message type: {type}") + + +def serve(stdin, stdout, stderr): + assert os.geteuid() == 0 + user = None + cmd = None + sin = io.BufferedReader(stdin) + while True: + type, buf = Message.read(sin) + if type == MSG_USER: + user = buf.decode('utf-8') + elif type == MSG_CMD: + cmd = buf.decode('utf-8') + elif type == MSG_EOF: + assert user is not None + assert cmd is not None + break + else: + raise ValueError(f"Unknown message type: {type}") + if user == 'root': + args = ['/bin/sh'] + else: + args = ['/bin/su', '-', user, '-c', '/bin/sh'] + result = subprocess.run( + args, + input=cmd.encode('utf-8'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + sout = io.BufferedWriter(stdout) + Message.write(sout, MSG_RC, struct.pack('!i', result.returncode)) + Message.write(sout, MSG_OUT, result.stdout) + Message.write(sout, MSG_ERR, result.stderr) + Message.write(sout, MSG_EOF, b'') + stdout.flush() + + +def _getuser(): + return pwd.getpwuid(os.geteuid()).pw_name + + +if __name__ == '__main__': + with open(0, 'rb') as stdin, \ + open(1, 'wb') as stdout, \ + open(2, 'wb') as stderr: + serve(stdin, stdout, stderr)
