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