1

My problem

I have a simple sh script that behaves exactly as I want, unless it's called from a node.js script.

What the sh script is supposed to do

  • Convert the (potentially binary) data passed via stdin to base64
  • Store that base64 string in a variable
  • Print the contents of that variable to stdout
  • If no data is passed to stdin, exit immediately without printing to stdout

My sh script

/tmp/aaa:

#!/bin/sh
! [ -t 0 ] && stdin_base64=$(base64 -w 0) || stdin_base64=""
echo -n "$stdin_base64"

When called from a terminal it works as expected

Without stdin:

$ /tmp/aaa

With stdin:

$ echo foo | /tmp/aaa
Zm9vCg==

With binary stdin:

$ echo -n -e '\x00\x00\x00' | /tmp/aaa
AAAA

With a ton of binary stdin that takes multiple seconds to be written:

$ dd if=/dev/urandom bs=1 count=10M | /tmp/aaa | rev | head -c 1

When called from node.js it breaks

When the exact same script gets called from node.js using execFile like this:

const { execFile } = require('child_process');

execFile('/tmp/aaa', [], {}, (error, stdout, stderr) => {
  console.log(error, stdout, stderr);
});

it just gets stuck indefinitely, without exiting, no errors and nothing gets printed to stdout or stderr. I assume it just waits for stdin forever because when I change the script to simply echo something, it exits immediately after printing:

#!/bin/sh
echo "test"

What I can/cannot do

  • I cannot change the node.js script
  • I cannot use Bash (I'm using an Alpine-based Docker image that only supports basic POSIX sh.)
  • I cannot install additional software.
  • The sh script needs to be changed in a way that it can handle stdin (or the lack thereof) properly, so that I always get the same behavior that I'm seeing when calling the script on the sh terminal directly.
  • It must support binary stdin data including null bytes.
  • I cannot use something like timeout 1 base64 -w because reading all data from stdin may take well more than 1 second.

Ideas

The closest thing I could come up with was this:

#!/bin/sh
stdin_file=$(mktemp)
cat - | base64 -w 0 > $stdin_file &
base64_pid=$!
timeout 1 sh -c "while [ ! -s $stdin_file ]; do sleep 0.1; done" && wait $base64_pid || kill $base64_pid 2&> /dev/null
stdin_base64=$(cat $stdin_file 2> /dev/null)
rm -f $stdin_file
echo -n "$stdin_base64"

But this would of course always result in a wasted second when called from node.js:

$ node -e "require('child_process').execFile('/tmp/aaa', [], {}, console.log)"

and on the other hand if it takes more than a second for data to arrive on stdin, it will fail, thinking there was no stdin:

$ (sleep 2; echo 123) | /tmp/aaa
Forivin
  • 14,780
  • 27
  • 106
  • 199
  • 5
    You aren't checking what you think you are checking. `! [ -t 0 ]` succeeds when standard input is not a terminal, not when it is "missing". – chepner May 12 '23 at 12:53
  • `as expected because no stdin was passed` - your understanding of your script is wrong. Stdin **is** passed to your script. It is your keyboard. You are passing your keyboard (or whatever your terminal is taking its input from) to your script as stdin. If you really want to pass no stdin to your script you'd call it like this `/tmp/aaa < /dev/null` not like this `/tmp/aaa` – slebetman May 12 '23 at 12:58
  • But since "passing no stdin" requires you to change the way your script is called then you need to modify both your script and the node process. On the other hand. If you want to use your current script you need to modify the node process to pass the terminal's input (keyboard) to your script using the `stdio: 'inherit'` option. In either case (accepting no stdin or accepting terminal as stdin) you need to modify your node script. – slebetman May 12 '23 at 13:01
  • The only option I can think of is to always assume you're not reading from stdin. So write two versions of your script: `/tmp/aaa` never reads from stdin and can be called from your node process but also if called normally will never read from stdin and another script `/tmp/aaa-stdin` that always reads from stdin. – slebetman May 12 '23 at 13:04
  • @slebetman Slight nit: the (pseudo)terminal *is* the standard input. Whether the terminal is connected to a keyboard is irrelevant. (Typically, the shell is receives a pseudoterminal as a device, and the pseudoterminal is baked by a terminal emulator, which interacts--via the OS--with the keyboard.) – chepner May 12 '23 at 13:20
  • Thank you guys, that's very valuable information! But how can I change the sh script to not get stuck indefinitely when the node script is calling it? I understand that setting `stdio: 'inherit` would do the job, but unfortunately I cannot make any changes to the node script. – Forivin May 15 '23 at 14:58
  • Even though this is off-topic because I can't actually change that node script in production, adding stdio: 'inherit didn't make a difference in the way it behaved. – Forivin May 15 '23 at 15:20
  • can you show the output of `docker inspect `? Is the field `Tty` set to `false`? – Elia May 17 '23 at 12:44
  • @Elia No, it's set to true. – Forivin May 17 '23 at 14:22
  • You did not specify in your question (1) how your node.js process is called (any redirects?) and (2) where you expect the actual data (input to base64) to come from (generated by node.js? stdin for node.js? something else?) – root May 20 '23 at 07:33
  • It should not matter how the node process is called. It is crucial that it works in any circumstance. – Forivin May 22 '23 at 19:39
  • Just fyi, the question simply demonstrates the core issue that I'm trying to solve. In reality I'm working on a complex project involving a remote-execution sh script that acts as a transparent proxy for arbitrary binaries. For example my script could be saved as `python` on a machine that doesn't have python and my script would proxy all input to a real python binary over the network via netcat and the play back the result (exit code, stdout, stderr). – Forivin May 22 '23 at 19:53
  • If I were to add all that information and all my code that is not actually relevant to solve this issue, it would render the question useless for many other people and would realistically make it way too long and complicated for anyone to answer in the first place. – Forivin May 22 '23 at 19:56
  • Hello, idk if it can help but if you look at the nodeJS official doc it says : " The child_process.execFile() function is similar to child_process.exec() except that it does not spawn a shell by default. Rather, the specified executable file is spawned directly as a new process making it slightly more efficient than child_process.exec(). Since a shell is not spawned, behaviors such as I/O redirection and file globbing are not supported." U can find solution to get stdin / out and error just after. https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback – Virgile Junique May 31 '23 at 09:56

3 Answers3

0

What you want to do is peek at stdin and see if any character is being provided. base64 will expect input before exiting so ideally you would want to provide it through the nodejs script (See How to pass STDIN to node.js child process)

As a workaround, you can add a timeout to your bash script to expect stdin to be filled. Here is an example with a timeout of 1 second. /tmp/aaa:

#!/bin/bash
read -t1 input
echo -n $input | base64 -w 0
tripleee
  • 175,061
  • 34
  • 275
  • 318
deribaucourt
  • 424
  • 8
  • But now it doesn't work anymore when I pass data through stdin like that: `echo foo | /tmp/aaa`. It should have printed the encoded data. – Forivin May 16 '23 at 13:41
  • My bad, I missed that the condition was reversed. – deribaucourt May 16 '23 at 13:52
  • I edited the solution with a timeout. Does that fulfil your request? – deribaucourt May 16 '23 at 14:11
  • The reason for using base64 in the first place was because it's not possible to store binary data in variables in sh scripts. Since `read` would first read the potentially binary data from stdin into a variable before encoding it, this wouldn't work unfortunately. Also, POSIX sh doesn't support -t on read afaik. – Forivin May 16 '23 at 14:22
  • `echo -n -e '\x00\x00\x00' | /tmp/aaa` would be a simple example demonstrating that it can't handle binary data. – Forivin May 17 '23 at 08:01
0

Not sure if this will fully solve your problen, if you cannot change the existing node.js code, but I suspect that is the problem area.

I seem to remember struggling with this in the past. I believe the call to execFile returns a child process, and you need to listen to events on it, as in this code of mine:

child.stdout.on('data', data =>...);
child.stderr.on('data', data =>...);
child.on('close', code =>...);
child.on('error', e =>...);

You ought to be able to at least debug things a little better using this approach. It seems that right now you are just hanging, and this may get you a little further.

Gary Archer
  • 22,534
  • 2
  • 12
  • 24
-1

it just gets stuck indefinitely, without exiting, no errors and nothing gets printed to stdout or stderr

This reads like a docker container started with -i but without -t.
Using the same script:

root# cat /tmp/test.sh
#!/bin/sh
! [ -t 0 ] && stdin_base64=$(base64 -w 0) || stdin_base64=""
echo -n "$stdin_base64"

Then running the following combinations work without issues:

root# docker run --rm -it -v /tmp:/host/tmp alpine:latest /host/tmp/test.sh
root# docker run --rm -t -v /tmp:/host/tmp alpine:latest /host/tmp/test.sh
root# docker run --rm -v /tmp:/host/tmp alpine:latest /host/tmp/test.sh

Running this however, results in what you described:

root# docker run --rm -i -v /tmp:/host/tmp alpine:latest /host/tmp/test.sh

Related: When would I use --interactive without --tty in a Docker container?

Elia
  • 762
  • 1
  • 12
  • 28
  • This is not an issue with docker. I can reproduce it outside of docker just fine. Here's a simple way to reproduce the issue with docker: `docker run -ti node:18.16.0-alpine3.17 node -e "require('child_process').execFile('/bin/base64', [], {}, console.log)"` – Forivin May 17 '23 at 14:20
  • I think the important bit you missed is that I never had any issues at all running my test script from the terminal or from the sh shell within my container. The issue I'm having only occurs, when my script gets called from node.js. – Forivin May 17 '23 at 14:42
  • in this case, doesn't removing the `!` as suggested in the comments make the script exit immediately as desired? – Elia May 17 '23 at 16:04
  • Removing the `i` doesn't make a difference unfortunately. – Forivin May 18 '23 at 11:01
  • It's the exclamation mark – Elia May 18 '23 at 12:57
  • That would break the script though. When there is data passed via stdin, it needs to be stored in that variable as base64 and then printed. If I removed the `!`, it wouldn't ever get to that. For example: `docker run -ti node:18.16.0-alpine3.17 sh -c 'echo -e "#!/bin/sh\\n[ -t 0 ] && stdin_base64=\$(base64 -w 0) || stdin_base64=\\necho -n \$stdin_base64" > /tmp/aaa && chmod +x /tmp/aaa && echo foo | /tmp/aaa'`. This doesn't print anything, but it should have printed the string "foo" base64 encoded. – Forivin May 18 '23 at 15:50
  • I understand the confusion now. `[ -t 0 ]` does not check if `stdin` is empty, it checks if `stdin` is connected to a terminal. Meaning, the condition `! [ -t 0 ]` checks if `stdin` is non-interactive, then wait for input. When you run `echo helloworld | script.sh`, the check `! [ -t 0]` is true because stdin is non-interactive, but there is input in `stdin` so `base64` command immediately reads it. In your case, `stdin` is also non-interactive (not connected to a terminal) but there is no data in `stdin` so `base64` blocks. – Elia May 19 '23 at 13:23
  • Try making your script: `! [ -t 0 ] && stdin_base64=$(timeout 1 base64 -w 0) || stdin_base64=""` – Elia May 19 '23 at 13:44
  • In that case it fails when I pass large amounts of data and it takes longer than a second. – Forivin May 20 '23 at 09:27