diff --git a/man/rules/meson.build b/man/rules/meson.build
index df4af1e543..d281842396 100644
--- a/man/rules/meson.build
+++ b/man/rules/meson.build
@@ -1052,6 +1052,7 @@ manpages = [
'systemd-shutdown'],
''],
['systemd-pstore.service', '8', ['systemd-pstore'], 'ENABLE_PSTORE'],
+ ['systemd-pty-forward', '1', [], ''],
['systemd-quotacheck.service',
'8',
['systemd-quotacheck'],
diff --git a/man/systemd-pty-forward.xml b/man/systemd-pty-forward.xml
new file mode 100644
index 0000000000..84e73fc161
--- /dev/null
+++ b/man/systemd-pty-forward.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ systemd-pty-forward
+ systemd
+
+
+
+ systemd-pty-forward
+ 1
+
+
+
+ systemd-pty-forward
+ Run a command with a custom terminal background color or title
+
+
+
+
+ systemd-pty-forward
+ OPTIONS
+ COMMAND
+
+
+
+
+ Description
+
+ systemd-pty-forward can be used to run a command with a custom terminal
+ background color or title.
+
+
+
+ Options
+ The following options are understood:
+
+
+
+
+
+ Change the terminal background color to the specified ANSI color as long as the
+ command runs. The color specified should be an ANSI X3.64 SGR background color, i.e. strings such as
+ 40, 41, …, 47, 48;2;…,
+ 48;5;…. See ANSI
+ Escape Code (Wikipedia) for details.
+
+ Example: --background=44 for a blue background.
+
+
+
+
+
+
+
+
+ Change the terminal title to the specified string as long as the command runs.
+
+
+
+
+
+
+
+
+
+ Suppresses additional informational output while running.
+
+
+
+
+
+
+
+
+
diff --git a/meson.build b/meson.build
index 538e776ab4..933133e6b1 100644
--- a/meson.build
+++ b/meson.build
@@ -2402,6 +2402,7 @@ subdir('src/pcrextend')
subdir('src/pcrlock')
subdir('src/portable')
subdir('src/pstore')
+subdir('src/ptyfwd')
subdir('src/quotacheck')
subdir('src/random-seed')
subdir('src/rc-local-generator')
diff --git a/mkosi.conf.d/05-tools/mkosi.conf.d/arch.conf b/mkosi.conf.d/05-tools/mkosi.conf.d/arch.conf
index 5787aa8f44..c6ec83a6b0 100644
--- a/mkosi.conf.d/05-tools/mkosi.conf.d/arch.conf
+++ b/mkosi.conf.d/05-tools/mkosi.conf.d/arch.conf
@@ -9,9 +9,11 @@ ToolsTreePackages=
github-cli
libcap
libmicrohttpd
+ libxslt
mypy
perl-json-xs
python-jinja
+ python-lxml
python-pytest
ruff
shellcheck
diff --git a/mkosi.conf.d/05-tools/mkosi.conf.d/centos-fedora.conf b/mkosi.conf.d/05-tools/mkosi.conf.d/centos-fedora.conf
index 23731f7376..21d3741504 100644
--- a/mkosi.conf.d/05-tools/mkosi.conf.d/centos-fedora.conf
+++ b/mkosi.conf.d/05-tools/mkosi.conf.d/centos-fedora.conf
@@ -15,6 +15,9 @@ ToolsTreePackages=
pkgconfig(mount)
tpm2-tss-devel
python3-jinja2
+ python3-lxml
python3-mypy
python3-pytest
shellcheck
+ xsltproc
+ docbook-style-xsl
diff --git a/mkosi.conf.d/05-tools/mkosi.conf.d/debian-ubuntu.conf b/mkosi.conf.d/05-tools/mkosi.conf.d/debian-ubuntu.conf
index 46204fe8f6..106072f541 100644
--- a/mkosi.conf.d/05-tools/mkosi.conf.d/debian-ubuntu.conf
+++ b/mkosi.conf.d/05-tools/mkosi.conf.d/debian-ubuntu.conf
@@ -17,5 +17,7 @@ ToolsTreePackages=
libtss2-dev
mypy
python3-jinja2
+ python3-lxml
python3-pytest
shellcheck
+ xsltproc
diff --git a/mkosi.conf.d/05-tools/mkosi.conf.d/opensuse.conf b/mkosi.conf.d/05-tools/mkosi.conf.d/opensuse.conf
index d486875fab..8a659360fa 100644
--- a/mkosi.conf.d/05-tools/mkosi.conf.d/opensuse.conf
+++ b/mkosi.conf.d/05-tools/mkosi.conf.d/opensuse.conf
@@ -19,5 +19,7 @@ ToolsTreePackages=
python3-ruff
tpm2-0-tss-devel
python3-jinja2
+ python3-lxml
python3-pytest
+ xsltproc
ShellCheck
diff --git a/src/basic/terminal-util.h b/src/basic/terminal-util.h
index 82df771cec..833c6617de 100644
--- a/src/basic/terminal-util.h
+++ b/src/basic/terminal-util.h
@@ -142,8 +142,8 @@ int getttyname_harder(int fd, char **ret);
int ptsname_malloc(int fd, char **ret);
-int openpt_allocate(int flags, char **ret_slave);
-int openpt_allocate_in_namespace(pid_t pid, int flags, char **ret_slave);
+int openpt_allocate(int flags, char **ret_peer);
+int openpt_allocate_in_namespace(pid_t pid, int flags, char **ret_peer);
int open_terminal_in_namespace(pid_t pid, const char *name, int mode);
int vt_restore(int fd);
diff --git a/src/machine/machine.c b/src/machine/machine.c
index 9edea3dfa4..d94805c418 100644
--- a/src/machine/machine.c
+++ b/src/machine/machine.c
@@ -668,19 +668,19 @@ int machine_kill(Machine *m, KillWhom whom, int signo) {
return manager_kill_unit(m->manager, m->unit, signo, NULL);
}
-int machine_openpt(Machine *m, int flags, char **ret_slave) {
+int machine_openpt(Machine *m, int flags, char **ret_peer) {
assert(m);
switch (m->class) {
case MACHINE_HOST:
- return openpt_allocate(flags, ret_slave);
+ return openpt_allocate(flags, ret_peer);
case MACHINE_CONTAINER:
if (!pidref_is_set(&m->leader))
return -EINVAL;
- return openpt_allocate_in_namespace(m->leader.pid, flags, ret_slave);
+ return openpt_allocate_in_namespace(m->leader.pid, flags, ret_peer);
default:
return -EOPNOTSUPP;
diff --git a/src/machine/machine.h b/src/machine/machine.h
index bf265143f0..30e4a6a466 100644
--- a/src/machine/machine.h
+++ b/src/machine/machine.h
@@ -100,7 +100,7 @@ MachineState machine_state_from_string(const char *s) _pure_;
const char* kill_whom_to_string(KillWhom k) _const_;
KillWhom kill_whom_from_string(const char *s) _pure_;
-int machine_openpt(Machine *m, int flags, char **ret_slave);
+int machine_openpt(Machine *m, int flags, char **ret_peer);
int machine_open_terminal(Machine *m, const char *path, int mode);
int machine_start_getty(Machine *m, const char *ptmx_name, sd_bus_error *error);
int machine_start_shell(Machine *m, int ptmx_fd, const char *ptmx_name, const char *user, const char *path, char **args, char **env, sd_bus_error *error);
diff --git a/src/ptyfwd/meson.build b/src/ptyfwd/meson.build
new file mode 100644
index 0000000000..f615285933
--- /dev/null
+++ b/src/ptyfwd/meson.build
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+executables += [
+ executable_template + {
+ 'name' : 'systemd-pty-forward',
+ 'public' : true,
+ 'sources' : files('ptyfwd-tool.c'),
+ },
+]
diff --git a/src/ptyfwd/ptyfwd-tool.c b/src/ptyfwd/ptyfwd-tool.c
new file mode 100644
index 0000000000..faa4071fac
--- /dev/null
+++ b/src/ptyfwd/ptyfwd-tool.c
@@ -0,0 +1,214 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+#include
+
+#include "alloc-util.h"
+#include "build.h"
+#include "event-util.h"
+#include "fd-util.h"
+#include "main-func.h"
+#include "pretty-print.h"
+#include "ptyfwd.h"
+#include "strv.h"
+
+static bool arg_quiet = false;
+static char *arg_background = NULL;
+static char *arg_title = NULL;
+
+STATIC_DESTRUCTOR_REGISTER(arg_background, freep);
+STATIC_DESTRUCTOR_REGISTER(arg_title, freep);
+
+static int help(void) {
+ _cleanup_free_ char *link = NULL;
+ int r;
+
+ r = terminal_urlify_man("systemd-pty-forward", "1", &link);
+ if (r < 0)
+ return log_oom();
+
+ printf("%1$s [OPTIONS...] COMMAND ...\n"
+ "\n%5$sRun command with a custom terminal background color or title.%6$s\n"
+ "\n%3$sOptions:%4$s\n"
+ " -h --help Show this help\n"
+ " --version Print version\n"
+ " -q --quiet Suppress information messages during runtime\n"
+ " --background=COLOR Set ANSI color for background\n"
+ " --title=TITLE Set terminal title\n"
+ "\nSee the %2$s for details.\n",
+ program_invocation_short_name,
+ link,
+ ansi_underline(),
+ ansi_normal(),
+ ansi_highlight(),
+ ansi_normal());
+
+ return 0;
+}
+
+static int parse_argv(int argc, char *argv[]) {
+ enum {
+ ARG_VERSION = 0x100,
+ ARG_BACKGROUND,
+ ARG_TITLE,
+ };
+
+ static const struct option options[] = {
+ { "help", no_argument, NULL, 'h' },
+ { "version", no_argument, NULL, ARG_VERSION },
+ { "quiet", no_argument, NULL, 'q' },
+ { "background", required_argument, NULL, ARG_BACKGROUND },
+ { "title", required_argument, NULL, ARG_TITLE },
+ {}
+ };
+
+ int c, r;
+
+ assert(argc >= 0);
+ assert(argv);
+
+ optind = 0;
+ while ((c = getopt_long(argc, argv, "+hq", options, NULL)) >= 0)
+ switch (c) {
+
+ case 'h':
+ return help();
+
+ case ARG_VERSION:
+ return version();
+
+ case 'q':
+ arg_quiet = true;
+ break;
+
+ case ARG_BACKGROUND:
+ r = free_and_strdup_warn(&arg_background, optarg);
+ if (r < 0)
+ return r;
+ break;
+
+ case ARG_TITLE:
+ r = free_and_strdup_warn(&arg_title, optarg);
+ if (r < 0)
+ return r;
+ break;
+
+ case '?':
+ return -EINVAL;
+
+ default:
+ assert_not_reached();
+ }
+
+ return 1;
+}
+
+static int pty_forward_handler(PTYForward *f, int rcode, void *userdata) {
+ sd_event *e = ASSERT_PTR(userdata);
+
+ assert(f);
+
+ if (rcode == -ECANCELED) {
+ log_debug_errno(rcode, "PTY forwarder disconnected.");
+ return sd_event_exit(e, EXIT_SUCCESS);
+ } else if (rcode < 0) {
+ (void) sd_event_exit(e, EXIT_FAILURE);
+ return log_error_errno(rcode, "Error on PTY forwarding logic: %m");
+ }
+
+ return 0;
+}
+
+static int helper_on_exit(sd_event_source *s, const siginfo_t *si, void *userdata) {
+ /* Add 128 to signal exit statuses to mimick shells. */
+ return sd_event_exit(sd_event_source_get_event(s), si->si_status + (si->si_code == CLD_EXITED ? 0 : 128));
+}
+
+static int run(int argc, char *argv[]) {
+ _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+ _cleanup_close_ int pty_fd = -EBADF, peer_fd = -EBADF;
+ _cleanup_(pty_forward_freep) PTYForward *forward = NULL;
+ _cleanup_(pidref_done) PidRef pidref = PIDREF_NULL;
+ _cleanup_(sd_event_source_unrefp) sd_event_source *exit_source = NULL;
+ int r;
+
+ log_setup();
+
+ assert_se(sigprocmask_many(SIG_BLOCK, /*ret_old_mask=*/ NULL, SIGCHLD) >= 0);
+
+ r = parse_argv(argc, argv);
+ if (r <= 0)
+ return r;
+
+ _cleanup_strv_free_ char **l = strv_copy(argv + optind);
+ if (!l)
+ return log_oom();
+
+ r = sd_event_default(&event);
+ if (r < 0)
+ return log_error_errno(r, "Failed to get event loop: %m");
+
+ (void) sd_event_set_signal_exit(event, true);
+
+ pty_fd = openpt_allocate(O_RDWR|O_NOCTTY|O_NONBLOCK|O_CLOEXEC, /*ret_peer=*/ NULL);
+ if (pty_fd < 0)
+ return log_error_errno(pty_fd, "Failed to acquire pseudo tty: %m");
+
+ peer_fd = pty_open_peer(pty_fd, O_RDWR|O_NOCTTY|O_CLOEXEC);
+ if (peer_fd < 0)
+ return log_error_errno(peer_fd, "Failed to open pty peer: %m");
+
+ if (!arg_quiet)
+ log_info("Press ^] three times within 1s to disconnect TTY.");
+
+ r = pty_forward_new(event, pty_fd, /*flags=*/ 0, &forward);
+ if (r < 0)
+ return log_error_errno(r, "Failed to create PTY forwarder: %m");
+
+ if (!isempty(arg_background)) {
+ r = pty_forward_set_background_color(forward, arg_background);
+ if (r < 0)
+ return log_error_errno(r, "Failed to set background color: %m");
+ }
+
+ if (shall_set_terminal_title() && !isempty(arg_title)) {
+ r = pty_forward_set_title(forward, arg_title);
+ if (r < 0)
+ return log_error_errno(r, "Failed to set title: %m");
+ }
+
+ pty_forward_set_handler(forward, pty_forward_handler, &event);
+
+ r = pidref_safe_fork_full(
+ "(sd-ptyfwd)",
+ (int[]) { peer_fd, peer_fd, peer_fd },
+ /* except_fds= */ NULL,
+ /* n_except_fds= */ 0,
+ /* flags= */ FORK_RESET_SIGNALS|FORK_DEATHSIG_SIGTERM|FORK_LOG|FORK_REARRANGE_STDIO,
+ &pidref);
+ if (r < 0)
+ return log_error_errno(r, "Failed to fork child: %m");
+ if (r == 0) {
+ r = terminal_new_session();
+ if (r < 0)
+ return log_error_errno(r, "Failed to create new session: %m");
+
+ (void) execvp(l[0], l);
+ log_error_errno(errno, "Failed to execute %s: %m", l[0]);
+ _exit(EXIT_FAILURE);
+ }
+
+ peer_fd = safe_close(peer_fd);
+
+ r = event_add_child_pidref(event, &exit_source, &pidref, WEXITED, helper_on_exit, NULL);
+ if (r < 0)
+ return log_error_errno(r, "Failed to add child event source: %m");
+
+ r = sd_event_source_set_child_process_own(exit_source, true);
+ if (r < 0)
+ return log_error_errno(r, "Failed to take ownership of child process: %m");
+
+ return sd_event_loop(event);
+}
+
+DEFINE_MAIN_FUNCTION_WITH_POSITIVE_FAILURE(run);
diff --git a/test/units/TEST-74-AUX-UTILS.pty-forward.sh b/test/units/TEST-74-AUX-UTILS.pty-forward.sh
new file mode 100755
index 0000000000..9b9b14a18a
--- /dev/null
+++ b/test/units/TEST-74-AUX-UTILS.pty-forward.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+set -eux
+set -o pipefail
+
+systemd-pty-forward --background 41 --title test echo foobar