Search code examples
bashshellrootsudoio-redirection

Bash script with global exec redirection doesn't behave the same as root


I'm writing a script in which I want all output (stdout & stderr) to be displayed in my terminal as well as redirected into a log file. Here is the script:

#!/bin/bash

exec &> >(tee -a main.log)

case $1 in
    "start")
    echo "Starting some services..."
    ;;
    "status")
    echo "inactive"
    ;;
    *)
    echo "Usage: $0 start | status" 1>&2
    exit 1
    ;;
esac

Now here is an example session of me testing the script:

$ ./main.sh status
$ inactive

$ sudo ./main.sh status
$ cat main.log
inactive
inactive

We can notice:

  • When executed as a non-root user, the output is displayed after my prompt, which is already weird enough in my opinion.
  • When executed as root, no output is displayed in my terminal.
    • But we do have inactive twice in the log file.

Can someone explain to me what's happening here? I would want the script to behave the same whether it's run as root or non-root user.

What's even more surprising to me is when I use the script with bash -x:

$ bash -x ./main.sh status
+ exec
++ tee -a main.log
$ + case $1 in
+ echo inactive
inactive

$ sudo bash -x ./main.sh status
+ exec
++ tee -a main.log

When run as root, the script just stops after tee.

EDIT: I noticed that when run with bash -x, the rest of the -x output is redirected to the file. So the script doesn't just stops after tee.

EDIT: Here's my bash version

GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Solution

  • Approach 1: waiting for a process expansion

    Since you're using bash 5.x, you can collect the PID of a process expansion and wait for it.

    #!/bin/bash
    
    exec {orig_stdout}>&1 {orig_stderr}>&2
    exec > >(tee -a main.log) 2>&1
    log_pid=$!
    cleanup() {
      if [[ $log_pid ]]; then # so we only run cleanup once; this lets it be explicitly called
        # restore our original stdout and stderr; closes the input to tee
        exec >&$orig_stdout 2>&$orig_stderr
        # since tee's input has closed, it will exit; wait for that to happen
        wait "$log_pid"
        # clear the variable for that PID so if we run cleanup again it's a noop
        unset log_pid
      fi
    }
    trap cleanup EXIT
    
    # ...rest of your script goes here
    
    cleanup # in theory unnecessary because of the trap, but legal.
    

    Approach 2: Re-exec'ing yourself... but safely

    This is what the preexisting answer does, but in a manner more cautious to be correct and secure

    #!/bin/bash
    
    if [[ ! $forked ]]; then
      forked=1 exec -a "$0" bash "${BASH_SOURCE[0]}" "$@" | tee -a main.log
      exit
    fi
    
    # ...rest of your script goes here
    

    (${BASH_SOURCE[0]} is more reliable than $0 as described in BashFAQ #28).