diff --git a/bubblewrap.c b/bubblewrap.c
index 2ab88d2f..8611411f 100644
--- a/bubblewrap.c
+++ b/bubblewrap.c
@@ -77,6 +77,7 @@ static bool opt_unshare_cgroup_try = false;
static bool opt_needs_devpts = false;
static bool opt_new_session = false;
static bool opt_die_with_parent = false;
+static bool opt_forward_signals = false;
static uid_t opt_sandbox_uid = -1;
static gid_t opt_sandbox_gid = -1;
static int opt_sync_fd = -1;
@@ -352,6 +353,7 @@ usage (int ecode, FILE *out)
" --json-status-fd FD Write container status to FD as multiple JSON documents\n"
" --new-session Create a new terminal session\n"
" --die-with-parent Kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.\n"
+ " --forward-signals Forward SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2 and SIGWINCH to COMMAND\n"
" --as-pid-1 Do not install a reaper process with PID=1\n"
" --cap-add CAP Add cap CAP when running as privileged user\n"
" --cap-drop CAP Drop cap CAP when running as privileged user\n"
@@ -374,14 +376,39 @@ handle_die_with_parent (void)
die_with_error ("prctl");
}
+/* Signals that --forward-signals passes on to the command */
+static const int forwarded_signals[] = { SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2, SIGWINCH };
+
+static void
+add_forwarded_signals (sigset_t *mask)
+{
+ size_t i;
+
+ if (!opt_forward_signals)
+ return;
+
+ for (i = 0; i < N_ELEMENTS (forwarded_signals); i++)
+ sigaddset (mask, forwarded_signals[i]);
+}
+
+/* SIGCHLD, plus the signals --forward-signals passes on */
+static void
+monitored_signals (sigset_t *mask)
+{
+ sigemptyset (mask);
+ sigaddset (mask, SIGCHLD);
+ add_forwarded_signals (mask);
+}
+
+/* The sandbox inherits the mask, so a signal arriving during setup stays
+ * pending until the command is ready for it. */
static void
-block_sigchild (void)
+block_signals (void)
{
sigset_t mask;
int status;
- sigemptyset (&mask);
- sigaddset (&mask, SIGCHLD);
+ monitored_signals (&mask);
if (sigprocmask (SIG_BLOCK, &mask, NULL) == -1)
die_with_error ("sigprocmask");
@@ -392,17 +419,72 @@ block_sigchild (void)
}
static void
-unblock_sigchild (void)
+unblock_signals (void)
{
sigset_t mask;
- sigemptyset (&mask);
- sigaddset (&mask, SIGCHLD);
+ monitored_signals (&mask);
if (sigprocmask (SIG_UNBLOCK, &mask, NULL) == -1)
die_with_error ("sigprocmask");
}
+/* A signal the kernel sent to our process group has already reached pid if it
+ * is in that group, so forwarding it would deliver it twice. Also called from
+ * a signal handler: getpgid() is a bare system call on Linux. */
+static bool
+should_forward_signal (pid_t pid, int code)
+{
+ pid_t pgid;
+
+ if (pid <= 0)
+ return false;
+
+ if (code != SI_KERNEL)
+ return true;
+
+ pgid = getpgid (pid);
+ return pgid != -1 && pgid != getpgrp ();
+}
+
+/* The command, while init forwards to it; 0 once init has reaped it, as its
+ * pid can be reused after that */
+static volatile sig_atomic_t forward_signals_pid = 0;
+
+static void
+forward_signal_handler (int sig, siginfo_t *info, void *context UNUSED)
+{
+ int saved_errno = errno;
+ pid_t pid = forward_signals_pid;
+
+ if (should_forward_signal (pid, info->si_code))
+ kill (pid, sig);
+
+ errno = saved_errno;
+}
+
+/* Used in init: as pid 1 of a pid namespace it only receives signals it has a
+ * handler for, so it cannot use a signalfd like the monitor does. */
+static void
+install_forward_signal_handlers (pid_t pid)
+{
+ struct sigaction act = { 0 };
+ sigset_t mask;
+ size_t i;
+
+ forward_signals_pid = pid;
+ act.sa_sigaction = forward_signal_handler;
+ act.sa_flags = SA_SIGINFO | SA_RESTART;
+ for (i = 0; i < N_ELEMENTS (forwarded_signals); i++)
+ if (sigaction (forwarded_signals[i], &act, NULL) != 0)
+ die_with_error ("sigaction");
+
+ sigemptyset (&mask);
+ add_forwarded_signals (&mask);
+ if (sigprocmask (SIG_UNBLOCK, &mask, NULL) == -1)
+ die_with_error ("sigprocmask");
+}
+
/* Closes all fd:s except 0,1,2 and the passed in array of extra fds */
static int
close_extra_fds (void *data, int fd)
@@ -507,8 +589,7 @@ monitor_child (int event_fd, pid_t child_pid, int setup_finished_fd)
assert (j < sizeof(dont_close)/sizeof(*dont_close));
fdwalk (close_extra_fds, dont_close);
- sigemptyset (&mask);
- sigaddset (&mask, SIGCHLD);
+ monitored_signals (&mask);
signal_fd = signalfd (-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
if (signal_fd == -1)
@@ -548,12 +629,17 @@ monitor_child (int event_fd, pid_t child_pid, int setup_finished_fd)
}
/* We need to read the signal_fd, or it will keep polling as read,
- * however we ignore the details as we get them from waitpid
+ * however we ignore the details of SIGCHLD as we get them from waitpid
* below anyway */
s = read (signal_fd, &fdsi, sizeof (struct signalfd_siginfo));
if (s == -1 && errno != EINTR && errno != EAGAIN)
die_with_error ("read signalfd");
+ /* With --forward-signals, pass the signal on */
+ if (s == (ssize_t) sizeof (struct signalfd_siginfo) && fdsi.ssi_signo != SIGCHLD &&
+ should_forward_signal (child_pid, fdsi.ssi_code))
+ kill (child_pid, fdsi.ssi_signo);
+
/* We may actually get several sigchld compressed into one
SIGCHLD, so we have to handle all of them. */
while ((died_pid = waitpid (-1, &died_status, WNOHANG)) > 0)
@@ -610,6 +696,9 @@ do_init (int event_fd, pid_t initial_pid)
/* Optionally bind our lifecycle to that of the caller */
handle_die_with_parent ();
+ if (opt_forward_signals)
+ install_forward_signal_handlers (initial_pid);
+
seccomp_programs_apply ();
while (true)
@@ -620,6 +709,7 @@ do_init (int event_fd, pid_t initial_pid)
child = TEMP_FAILURE_RETRY (wait (&status));
if (child == initial_pid)
{
+ forward_signals_pid = 0;
initial_exit_status = propagate_exit_status (status);
if(event_fd != -1)
@@ -2575,6 +2665,10 @@ parse_args_recurse (int *argcp,
{
opt_die_with_parent = true;
}
+ else if (strcmp (arg, "--forward-signals") == 0)
+ {
+ opt_forward_signals = true;
+ }
else if (strcmp (arg, "--as-pid-1") == 0)
{
opt_as_pid_1 = true;
@@ -3052,8 +3146,8 @@ main (int argc,
die_with_error ("eventfd()");
}
- /* We block sigchild here so that we can use signalfd in the monitor. */
- block_sigchild ();
+ /* We block sigchild (and forwarded signals) here so that we can use signalfd in the monitor. */
+ block_signals ();
clone_flags = SIGCHLD | CLONE_NEWNS;
if (opt_unshare_user)
@@ -3526,8 +3620,8 @@ main (int argc,
close (opt_sync_fd);
}
- /* We want sigchild in the child */
- unblock_sigchild ();
+ /* We want sigchild and forwarded signals in the child */
+ unblock_signals ();
/* Optionally bind our lifecycle */
handle_die_with_parent ();
diff --git a/bwrap.xml b/bwrap.xml
index b48cf5b4..9e4487e2 100644
--- a/bwrap.xml
+++ b/bwrap.xml
@@ -592,6 +592,19 @@
See prctl, PR_SET_PDEATHSIG.
+
+
+
+ Forwards SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2 and
+ SIGWINCH sent to bwrap on to the child process (COMMAND), so that it
+ can handle them. Signals generated by the terminal are not forwarded
+ unless COMMAND has left bwrap's process group, for example with
+ , because it receives those itself.
+
+ With , COMMAND must install a handler to
+ receive them, as for any PID=1.
+
+
diff --git a/completions/bash/bwrap b/completions/bash/bwrap
index e7a523c2..e9e8d102 100644
--- a/completions/bash/bwrap
+++ b/completions/bash/bwrap
@@ -47,6 +47,7 @@ _bwrap() {
--exec-label
--file
--file-label
+ --forward-signals
--gid
--hostname
--info-fd
diff --git a/completions/zsh/_bwrap b/completions/zsh/_bwrap
index fbddda43..bea4075a 100644
--- a/completions/zsh/_bwrap
+++ b/completions/zsh/_bwrap
@@ -46,6 +46,7 @@ _bwrap_args=(
'--disable-userns[Disable further use of user namespaces inside sandbox]'
'--exec-label[Exec label for the sandbox]:SELinux label:_selinux_contexts'
'--file-label[File label for temporary sandbox content]:SELinux label:_selinux_contexts'
+ '--forward-signals[Forward SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2 and SIGWINCH to COMMAND]'
'--gid[Custom gid in the sandbox (requires --unshare-user or --userns)]: :_guard "[0-9]#" "numeric group ID"'
'--help[Print help and exit]'
'--hostname[Custom hostname in the sandbox (requires --unshare-uts)]:hostname:'
diff --git a/tests/meson.build b/tests/meson.build
index fe45af0a..1c820b58 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -16,6 +16,8 @@ executable(
)
test_scripts = [
+ 'test-forward-signals-tty.py',
+ 'test-forward-signals.sh',
'test-run.sh',
'test-sandbox.py',
'test-seccomp.py',
diff --git a/tests/test-forward-signals-tty.py b/tests/test-forward-signals-tty.py
new file mode 100644
index 00000000..775a456b
--- /dev/null
+++ b/tests/test-forward-signals-tty.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# The parts of --forward-signals that need a terminal, as the kernel sends
+# SIGINT and SIGWINCH to the foreground process group of one.
+
+import fcntl
+import importlib.util
+import os
+import pty
+import signal
+import struct
+import sys
+import tempfile
+import termios
+import time
+import unittest
+
+_spec = importlib.util.spec_from_file_location(
+ 'test_helper',
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), 'test-helper.py'),
+)
+_helper = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_helper)
+
+BWRAP = _helper.BWRAP
+can_run_bwrap = _helper.can_run_bwrap
+run_tap_tests = _helper.run_tap_tests
+
+# Reports the signals it receives and keeps running
+COMMAND = r'''
+trap 'echo got-int >> "$1"' INT
+trap 'echo got-winch >> "$1"' WINCH
+echo ready >> "$1"
+while :; do sleep 0.2; done
+'''
+
+
+@unittest.skipUnless(can_run_bwrap(), 'bwrap not available or not functional')
+class TestForwardSignalsTty(unittest.TestCase):
+ def sandbox(self, *extra_args):
+ """bwrap in a pseudo-terminal, as its own session and foreground group"""
+ d = tempfile.TemporaryDirectory(prefix='bwrap-tty-test.')
+ self.addCleanup(d.cleanup)
+ self.log = os.path.join(d.name, 'log')
+ script = os.path.join(d.name, 'command.sh')
+ with open(script, 'w') as f:
+ f.write(COMMAND)
+ self.pid, self.terminal = pty.fork()
+ if self.pid == 0:
+ try:
+ os.execv(BWRAP, [
+ BWRAP, '--forward-signals', '--unshare-user',
+ '--uid', '0', '--gid', '0', '--bind', '/', '/',
+ '--proc', '/proc', '--dev', '/dev', *extra_args,
+ '--', '/bin/sh', script, self.log,
+ ])
+ finally:
+ os._exit(127)
+ self.addCleanup(self.kill)
+ self.assertTrue(self.wait_for('ready'), 'command did not start')
+
+ def kill(self):
+ try:
+ os.kill(self.pid, signal.SIGKILL)
+ os.waitpid(self.pid, 0)
+ except OSError:
+ pass
+ os.close(self.terminal)
+
+ def wait_for(self, text, timeout=10):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ with open(self.log) as f:
+ if text in f.read():
+ return True
+ except FileNotFoundError:
+ pass
+ time.sleep(0.02)
+ return False
+
+ def check_ctrl_c(self, *extra_args):
+ self.sandbox(*extra_args)
+ os.write(self.terminal, b'\x03')
+ self.assertTrue(self.wait_for('got-int', 3), 'SIGINT did not arrive')
+ # the command handles it, so bwrap must not have died in its place
+ self.assertEqual(os.waitpid(self.pid, os.WNOHANG), (0, 0))
+
+ def test_ctrl_c(self):
+ self.check_ctrl_c('--unshare-pid')
+
+ def test_ctrl_c_as_pid_1(self):
+ self.check_ctrl_c('--unshare-pid', '--as-pid-1')
+
+ def resize(self):
+ fcntl.ioctl(self.terminal, termios.TIOCSWINSZ,
+ struct.pack('HHHH', 40, 100, 0, 0))
+
+ def test_resize(self):
+ """With --new-session the command is in a session of its own, so a
+ resize only reaches it if bwrap forwards SIGWINCH."""
+ self.sandbox('--unshare-pid', '--new-session')
+ self.resize()
+ self.assertTrue(self.wait_for('got-winch', 3), 'SIGWINCH did not arrive')
+
+
+if __name__ == '__main__':
+ run_tap_tests(sys.modules[__name__])
diff --git a/tests/test-forward-signals.sh b/tests/test-forward-signals.sh
new file mode 100755
index 00000000..28385bf9
--- /dev/null
+++ b/tests/test-forward-signals.sh
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+
+set -xeuo pipefail
+
+srcd=$(cd $(dirname "$0") && pwd)
+. "${srcd}/libtest.sh"
+
+if test -n "${bwrap_is_suid:-}"; then
+ skip "no setuid support for --unshare-user"
+fi
+
+echo "1..8"
+test_count=0
+
+ok () {
+ test_count=$((test_count + 1))
+ echo ok $test_count "$@"
+}
+
+SANDBOX="$BWRAP --unshare-user --uid 0 --gid 0 --unshare-pid --bind / / --proc /proc --dev /dev"
+
+# wait_for SECONDS CMD...: poll until CMD succeeds, fail after SECONDS
+wait_for () {
+ local deadline=$((SECONDS + $1))
+ shift
+ until "$@"; do
+ test "$SECONDS" -lt "$deadline" || assert_not_reached "timed out waiting for: $*"
+ sleep 0.05
+ done
+}
+
+# exited PID: PID is gone or a zombie
+exited () {
+ ! kill -0 "$1" 2>/dev/null || test "$(sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -c1)" = Z
+}
+
+# child_pid FILE: the pid bwrap reported to --info-fd
+child_pid () {
+ wait_for 5 grep -qs child-pid "$1"
+ sed -n 's/.*"child-pid": *\([0-9]*\).*/\1/p' "$1"
+}
+
+# A command that reports the signals it receives and keeps running
+cat > handler.sh <<'EOF'
+trap 'echo got-term >> "$1"' TERM
+trap 'echo got-usr1 >> "$1"' USR1
+echo ready >> "$1"
+while :; do sleep 0.2; done
+EOF
+
+# start OPTIONS...: run $SANDBOX in its own process group, as a job would be,
+# and set BWRAP_PID and SANDBOX_PID once the command is ready
+start () {
+ rm -f log info.json
+ set -m
+ $SANDBOX --info-fd 3 "$@" -- bash handler.sh "$(pwd)/log" 3> info.json > /dev/null 2>&1 &
+ BWRAP_PID=$!
+ set +m
+ SANDBOX_PID=$(child_pid info.json)
+ wait_for 5 grep -qs ready log
+}
+
+start
+kill -TERM "$BWRAP_PID"
+wait_for 2 exited "$BWRAP_PID"
+sleep 0.5
+assert_not_file_has_content log got-term
+kill -KILL "$SANDBOX_PID" 2>/dev/null || true
+ok "without --forward-signals, a signal is not forwarded"
+
+# a command without a handler, so the forwarded SIGTERM kills it
+rm -f log
+set -m
+$SANDBOX --forward-signals -- sh -c "echo ready >> $(pwd)/log; exec sleep 60" > /dev/null 2>&1 &
+bwrap_pid=$!
+set +m
+wait_for 5 grep -qs ready log
+kill -TERM "$bwrap_pid"
+status=0
+wait "$bwrap_pid" || status=$?
+assert_streq "$status" 143
+ok "SIGTERM reaches the command, which bwrap reports as 128+15"
+
+start --forward-signals
+kill -USR1 "$BWRAP_PID"
+wait_for 2 grep -qs got-usr1 log
+kill -0 "$BWRAP_PID"
+kill -KILL "$SANDBOX_PID"
+ok "a forwarded SIGUSR1 is handled and the sandbox keeps running"
+
+start --forward-signals
+kill -TERM "$SANDBOX_PID"
+wait_for 2 grep -qs got-term log
+kill -KILL "$SANDBOX_PID"
+ok "a signal sent to the init process reaches the command"
+
+# The command exits at once, a background process keeps the sandbox alive, and
+# a new process takes the pid the command had. No --proc, as /proc/sys is
+# read-only in the sandbox bwrap sets up.
+rm -f log info.json
+$SANDBOX --forward-signals --info-fd 3 -- sh -c 'sleep 60 > /dev/null 2>&1 &' 3> info.json
+sandbox_pid=$(child_pid info.json)
+set -m
+$BWRAP --userns 11 --pidns 12 --cap-add ALL --bind / / -- \
+ sh -c "echo 1 > /proc/sys/kernel/ns_last_pid && bash $(pwd)/handler.sh $(pwd)/log; true" \
+ 11< "/proc/$sandbox_pid/ns/user" 12< "/proc/$sandbox_pid/ns/pid" > /dev/null 2>&1 &
+reuse_pid=$!
+set +m
+wait_for 5 grep -qs ready log
+kill -TERM "$sandbox_pid"
+sleep 0.5
+assert_not_file_has_content log got-term
+kill -KILL "$reuse_pid" "$sandbox_pid" 2>/dev/null || true
+ok "once the command is reaped, its pid is not signalled again"
+
+# Without --unshare-pid there is no init process, so bwrap's child is the command
+rm -f log
+set -m
+$BWRAP --forward-signals --unshare-user --uid 0 --gid 0 --bind / / --proc /proc --dev /dev \
+ -- bash handler.sh "$(pwd)/log" > /dev/null 2>&1 &
+bwrap_pid=$!
+set +m
+wait_for 5 grep -qs ready log
+kill -TERM "$bwrap_pid"
+wait_for 2 grep -qs got-term log
+kill -KILL "$bwrap_pid"
+ok "a signal is forwarded when there is no init process"
+
+# bwrap also runs an init process for --lock-file, where it is not pid 1
+rm -f log
+touch lock
+set -m
+$BWRAP --forward-signals --lock-file "$(pwd)/lock" --bind / / --proc /proc --dev /dev \
+ -- bash handler.sh "$(pwd)/log" > /dev/null 2>&1 &
+bwrap_pid=$!
+set +m
+wait_for 5 grep -qs ready log
+kill -TERM "$bwrap_pid"
+wait_for 2 grep -qs got-term log
+kill -KILL "$bwrap_pid"
+ok "a signal is forwarded through the init process of --lock-file"
+
+# --block-fd holds the command back, so the signal arrives during setup
+rm -f log info.json
+mkfifo block
+set -m
+$SANDBOX --forward-signals --info-fd 3 --block-fd 4 -- sh -c "echo started >> $(pwd)/log" \
+ 3> info.json 4<> block > /dev/null 2>&1 &
+bwrap_pid=$!
+set +m
+sandbox_pid=$(child_pid info.json)
+kill -TERM "$bwrap_pid"
+echo go > block
+status=0
+wait "$bwrap_pid" || status=$?
+assert_streq "$status" 143
+test ! -e log
+ok "a signal arriving during setup is not lost and the command does not start"