diff --git a/README.md b/README.md index 2899c8d..c472ac8 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,23 @@ One caveat with this feature: for job control signals (`SIGTSTP`, `SIGTTIN`, even if you rewrite it to something else. +### Signal "observing" + +dumb-init also allows executing an "observer" when a signal is received. An +observer is nothing more than a script or executable that gets called as part of +the signal-handling process, whether or not the signal is forwarded to the child +process. You can provide a full path to an observer or let dumb-init search the +`PATH`. The executable should not require or expect command line arguments (it +will get none), but two environment variables will be provided, +`DUMB_INIT_SIGNUM` and `DUMB_INIT_REPLACEMENT_SIGNUM`. They will contain the +signal _numbers_, not names. + +An observer is specified as an optional third parameter to `-r/--rewrite`: +`--rewrite 12:0:/path/to/observer`. To observe a signal while still passing it +to the child process, simply replace a signal with itself: `--rewrite +10:10:some_observer`. + + ## Installing inside Docker containers You have a few options for using `dumb-init`: @@ -217,7 +234,7 @@ entrypoint. An "entrypoint" is a partial command that gets prepended to your ENTRYPOINT ["/usr/bin/dumb-init", "--"] # or if you use --rewrite or other cli flags -# ENTRYPOINT ["dumb-init", "--rewrite", "2:3", "--"] +# ENTRYPOINT ["dumb-init", "--rewrite", "2:3", "--rewrite", "10:10:observer_script", "--"] CMD ["/my/script", "--with", "--args"] ``` diff --git a/dumb-init.c b/dumb-init.c index e1a2fab..9df6507 100644 --- a/dumb-init.c +++ b/dumb-init.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ // Indices are one-indexed (signal 1 is at index 1). Index zero is unused. // User-specified signal rewriting. int signal_rewrite[MAXSIG + 1] = {[0 ... MAXSIG] = -1}; +char *signal_observers[MAXSIG + 1] = {[0 ... MAXSIG] = NULL}; // One-time ignores due to TTY quirks. 0 = no skip, 1 = skip the next-received signal. char signal_temporary_ignores[MAXSIG + 1] = {[0 ... MAXSIG] = 0}; @@ -61,12 +63,41 @@ int translate_signal(int signum) { } void forward_signal(int signum) { - signum = translate_signal(signum); - if (signum != 0) { - kill(use_setsid ? -child_pid : child_pid, signum); - DEBUG("Forwarded signal %d to children.\n", signum); + int replacement = translate_signal(signum); + char *observer = signal_observers[signum]; + char s[10]; + + if (observer) { + pid_t observer_pid = fork(); + + if (observer_pid < 0) { + PRINTERR("%s: unable to fork observer\n", observer); + } else if (observer_pid == 0) { + /* child */ + sigset_t all_signals; + + sigfillset(&all_signals); + sigprocmask(SIG_UNBLOCK, &all_signals, NULL); + + snprintf(s, 10, "%d", signum); + setenv("DUMB_INIT_SIGNUM", s, 1); + snprintf(s, 10, "%d", replacement); + setenv("DUMB_INIT_REPLACEMENT_SIGNUM", s, 1); + + execl(observer, observer, NULL); + + PRINTERR("%s: %s\n", observer, strerror(errno)); + } else { + /* parent */ + DEBUG("%s: Observer spawned with PID %d.\n", observer, observer_pid); + } + } + + if (replacement != 0) { + kill(use_setsid ? -child_pid : child_pid, replacement); + DEBUG("Forwarded signal %d to children.\n", replacement); } else { - DEBUG("Not forwarding signal %d to children (ignored).\n", signum); + DEBUG("Not forwarding signal %d to children (ignored).\n", replacement); } } @@ -136,9 +167,15 @@ void print_help(char *argv[]) { " -c, --single-child Run in single-child mode.\n" " In this mode, signals are only proxied to the\n" " direct child and not any of its descendants.\n" - " -r, --rewrite s:r Rewrite received signal s to new signal r before proxying.\n" - " To ignore (not proxy) a signal, rewrite it to 0.\n" - " This option can be specified multiple times.\n" + " -r, --rewrite s:r[:observer]\n" + " Rewrite received signal s to new signal r before\n" + " proxying. To ignore (not proxy) a signal, rewrite it\n" + " to 0. The optional observer is a script or executable\n" + " to execute when signal s is received (regardless\n" + " of any rewriting). It must expect no arguments, but\n" + " the DUMB_INIT_SIGNUM and DUMB_INIT_REPLACEMENT_SIGNUM\n" + " environment variables will be set. This option can be\n" + " specified multiple times.\n" " -v, --verbose Print debugging information to stderr.\n" " -h, --help Print this help message and exit.\n" " -V, --version Print the current version and exit.\n" @@ -152,8 +189,10 @@ void print_help(char *argv[]) { void print_rewrite_signum_help() { fprintf( stderr, - "Usage: -r option takes :, where " - "is between 1 and %d.\n" + "Usage: -r option takes :[:], " + "where is between 1 and %d.\n" + " must be a path to an executable or an executable " + "that can be found in the PATH. It must expect no arguments.\n" "This option can be specified multiple times.\n" "Use --help for full usage.\n", MAXSIG @@ -161,10 +200,64 @@ void print_rewrite_signum_help() { exit(1); } +char *find_path(const char *partial) { + static char **path_entries = NULL; + static int path_count = 0; + + if (strchr(partial, '/')) { + return !access(partial, X_OK) ? strdup(partial) : NULL; + } else { + int i; + size_t plen; + char file[PATH_MAX]; + + if (!path_entries) { + char *path, *tokpath, *s; + + path = getenv("PATH"); + + if (!(tokpath = strdup(path && strlen(path) ? path : "/bin:/usr/bin:/sbin:/usr/sbin"))) { + PRINTERR("cannot get PATH\n"); + exit(1); + } + + for (path_count = 1, s = tokpath; (s = strchr(s, ':')); path_count++, s++) { + ; + } + + if (!(path_entries = (char**)malloc(path_count * sizeof(char*)))) { + PRINTERR("cannot not create PATH entries\n"); + exit(1); + } + + for(i = 0, s = strtok(tokpath, ":"); s; s = strtok(NULL, ":"), i++) { + path_entries[i] = strdup(s); + } + + free(tokpath); + } + + for (plen = strlen(partial), i = 0; i < path_count; i++) { + if (plen + strlen(path_entries[i]) < (PATH_MAX - 2)) { + sprintf(file, "%s/%s", path_entries[i], partial); + + if (!access(file, X_OK)) { + return strdup(file); + } + } + } + } + + return NULL; +} + void parse_rewrite_signum(char *arg) { - int signum, replacement; + int signum, replacement, position; + size_t length; + char *observer, *path; + if ( - sscanf(arg, "%d:%d", &signum, &replacement) == 2 && + sscanf(arg, "%d:%d%n", &signum, &replacement, &position) == 2 && (signum >= 1 && signum <= MAXSIG) && (replacement >= 0 && replacement <= MAXSIG) ) { @@ -172,6 +265,17 @@ void parse_rewrite_signum(char *arg) { } else { print_rewrite_signum_help(); } + + observer = arg + position; + + if ((*observer++ == ':') && (length = strlen(observer))) { + if (!(path = find_path(observer))) { + PRINTERR("%s: observer not found or not executable\n", observer); + exit(1); + } + + signal_observers[signum] = path; + } } void set_rewrite_to_sigstop_if_not_defined(int signum) { diff --git a/tests/cli_test.py b/tests/cli_test.py index 27d7bf2..127aeec 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -54,9 +54,15 @@ def test_help_message(flag, current_version): b' -c, --single-child Run in single-child mode.\n' b' In this mode, signals are only proxied to the\n' b' direct child and not any of its descendants.\n' - b' -r, --rewrite s:r Rewrite received signal s to new signal r before proxying.\n' - b' To ignore (not proxy) a signal, rewrite it to 0.\n' - b' This option can be specified multiple times.\n' + b' -r, --rewrite s:r[:observer]\n' + b' Rewrite received signal s to new signal r before\n' + b' proxying. To ignore (not proxy) a signal, rewrite it\n' + b' to 0. The optional observer is a script or executable\n' + b' to execute when signal s is received (regardless\n' + b' of any rewriting). It must expect no arguments, but\n' + b' the DUMB_INIT_SIGNUM and DUMB_INIT_REPLACEMENT_SIGNUM\n' + b' environment variables will be set. This option can be\n' + b' specified multiple times.\n' b' -v, --verbose Print debugging information to stderr.\n' b' -h, --help Print this help message and exit.\n' b' -V, --version Print the current version and exit.\n' @@ -143,8 +149,27 @@ def test_rewrite_errors(extra_args): stdout, stderr = proc.communicate() assert proc.returncode == 1 assert stderr == ( - b'Usage: -r option takes :, where ' - b'is between 1 and 31.\n' + b'Usage: -r option takes :[:], where is between 1 and 31.\n' + b' must be a path to an executable or an executable that can be found in the PATH. It must expect no arguments.\n' b'This option can be specified multiple times.\n' b'Use --help for full usage.\n' ) + +@pytest.mark.parametrize( + 'extra_args', [ + ('-r', '12:0:foo'), + ('-r', '12:0:/bin/foo'), + ], +) +@pytest.mark.usefixtures('both_debug_modes', 'both_setsid_modes') +def test_observer_errors(extra_args): + proc = Popen( + ('dumb-init',) + extra_args + ('echo', 'oh,', 'hi'), + stdout=PIPE, stderr=PIPE, + ) + stdout, stderr = proc.communicate() + assert proc.returncode == 1 + assert stderr in ( + b'[dumb-init] foo: observer not found or not executable\n', + b'[dumb-init] /bin/foo: observer not found or not executable\n', + ) diff --git a/tests/execs_observers_test.py b/tests/execs_observers_test.py new file mode 100644 index 0000000..91e190e --- /dev/null +++ b/tests/execs_observers_test.py @@ -0,0 +1,18 @@ +import os + +import pytest + +from testing import print_signals + + +@pytest.mark.usefixtures('both_debug_modes', 'both_setsid_modes') +def test_execs_observers(): + """Ensure dumb-init executes observers.""" + with print_signals(('-r', '10:0:/bin/pwd', '-r', '12:12:pwd',)) as (proc, _): + proc.send_signal(10) + assert proc.stdout.readline() == '{}\n'.format(os.getcwd()).encode('ascii') + proc.send_signal(12) + assert (proc.stdout.readline() + proc.stdout.readline()) in ( + '12\n{}\n'.format(os.getcwd()).encode('ascii'), + '{}\n12\n'.format(os.getcwd()).encode('ascii'), + )