diff --git a/README.md b/README.md index 78ca9140..710e0d11 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # node-pty -[![Build Status](https://dev.azure.com/vscode/node-pty/_apis/build/status/Microsoft.node-pty)](https://dev.azure.com/vscode/node-pty/_build/latest?definitionId=11) +[![Build Status](https://dev.azure.com/vscode/node-pty/_apis/build/status/Microsoft.node-pty?branchName=main)](https://dev.azure.com/vscode/node-pty/_build/latest?definitionId=11&branchName=main) `forkpty(3)` bindings for node.js. This allows you to fork processes with pseudoterminal file descriptors. It returns a terminal object which allows reads and writes. @@ -64,7 +64,7 @@ ptyProcess.write('ls\r'); - [Commas](https://github.com/CyanSalt/commas): A hackable terminal and command runner. - [ENiGMA½ BBS Software](https://github.com/NuSkooler/enigma-bbs): A modern BBS software with a nostalgic flair! - [Tinkerun](https://github.com/tinkerun/tinkerun): A new way of running Tinker. -- [Tess](https://github.com/SquitchYT/Tess/): Hackable, simple and rapid terminal for the new era of technology 👍 +- [Tess](https://tessapp.dev): Hackable, simple and rapid terminal for the new era of technology 👍 - [NxShell](https://nxshell.github.io/): An easy to use new terminal for Windows/Linux/MacOS platform. Do you use node-pty in your application as well? Please open a [Pull Request](https://github.com/Tyriar/node-pty/pulls) to include it here. We would love to have it in our list. diff --git a/binding.gyp b/binding.gyp index 46bb70d2..b2c262db 100644 --- a/binding.gyp +++ b/binding.gyp @@ -46,33 +46,51 @@ } ] }, { # OS!="win" - 'targets': [{ - 'target_name': 'pty', - 'include_dirs' : [ - ' void): IUnixProcess; + fork(file: string, args: string[], parsedEnv: string[], cwd: string, cols: number, rows: number, uid: number, gid: number, closeFDs: boolean, useUtf8: boolean, onExitCallback: (code: number, signal: number) => void, helperPath: string): IUnixProcess; open(cols: number, rows: number): IUnixOpenProcess; process(fd: number, pty: string): string; resize(fd: number, cols: number, rows: number): void; diff --git a/src/unix/comms.h b/src/unix/comms.h new file mode 100644 index 00000000..b9555437 --- /dev/null +++ b/src/unix/comms.h @@ -0,0 +1,5 @@ +#define COMM_PIPE_FD (STDERR_FILENO + 1) +#define COMM_ERR_EXEC 1 +#define COMM_ERR_CHDIR 2 +#define COMM_ERR_SETUID 3 +#define COMM_ERR_SETGID 4 diff --git a/src/unix/pty.cc b/src/unix/pty.cc index ce44a3e6..2c277f7b 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -29,6 +29,9 @@ #include #include #include +#include + +#include "comms.h" /* forkpty */ /* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */ @@ -54,15 +57,6 @@ #define VDISCARD VDISCRD #endif -/* environ for execvpe */ -/* node/src/node_child_process.cc */ -#if defined(__APPLE__) && !TARGET_OS_IPHONE -#include -#define environ (*_NSGetEnviron()) -#else -extern char **environ; -#endif - /* for pty_getproc */ #if defined(__linux__) #include @@ -77,6 +71,17 @@ extern char **environ; #define NSIG 32 #endif +#ifdef POSIX_SPAWN_CLOEXEC_DEFAULT + #define HAVE_POSIX_SPAWN_CLOEXEC_DEFAULT 1 +#else + #define HAVE_POSIX_SPAWN_CLOEXEC_DEFAULT 0 + #define POSIX_SPAWN_CLOEXEC_DEFAULT 0 +#endif + +#ifndef POSIX_SPAWN_USEVFORK + #define POSIX_SPAWN_USEVFORK 0 +#endif + /** * Structs */ @@ -103,9 +108,6 @@ NAN_METHOD(PtyGetProc); * Functions */ -static int -pty_execvpe(const char *, char **, char **); - static int pty_nonblock(int); @@ -117,11 +119,6 @@ pty_openpty(int *, int *, char *, const struct termios *, const struct winsize *); -static pid_t -pty_forkpty(int *, char *, - const struct termios *, - const struct winsize *); - static void pty_waitpid(void *); @@ -131,10 +128,16 @@ pty_after_waitpid(uv_async_t *); static void pty_after_close(uv_handle_t *); +static void throw_for_errno(const char* message, int _errno) { + Nan::ThrowError(( + message + std::string(strerror(_errno)) + ).c_str()); +} + NAN_METHOD(PtyFork) { Nan::HandleScope scope; - if (info.Length() != 10 || + if (info.Length() != 12 || !info[0]->IsString() || !info[1]->IsArray() || !info[2]->IsArray() || @@ -144,41 +147,54 @@ NAN_METHOD(PtyFork) { !info[6]->IsNumber() || !info[7]->IsNumber() || !info[8]->IsBoolean() || - !info[9]->IsFunction()) { + !info[9]->IsBoolean() || + !info[10]->IsFunction() || + !info[11]->IsString()) { return Nan::ThrowError( - "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, onexit)"); + "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, closeFDs, utf8, onexit, helperPath)"); } // file Nan::Utf8String file(info[0]); - // args - int i = 0; - v8::Local argv_ = v8::Local::Cast(info[1]); - int argc = argv_->Length(); - int argl = argc + 1 + 1; - char **argv = new char*[argl]; - argv[0] = strdup(*file); - argv[argl-1] = NULL; - for (; i < argc; i++) { - Nan::Utf8String arg(Nan::Get(argv_, i).ToLocalChecked()); - argv[i+1] = strdup(*arg); - } - // env - i = 0; v8::Local env_ = v8::Local::Cast(info[2]); int envc = env_->Length(); char **env = new char*[envc+1]; env[envc] = NULL; - for (; i < envc; i++) { + for (int i = 0; i < envc; i++) { Nan::Utf8String pair(Nan::Get(env_, i).ToLocalChecked()); env[i] = strdup(*pair); } // cwd Nan::Utf8String cwd_(info[3]); - char *cwd = strdup(*cwd_); + + // uid / gid + int uid = info[6]->IntegerValue(Nan::GetCurrentContext()).FromJust(); + int gid = info[7]->IntegerValue(Nan::GetCurrentContext()).FromJust(); + + // closeFDs + bool closeFDs = Nan::To(info[8]).FromJust(); + bool explicitlyCloseFDs = closeFDs && !HAVE_POSIX_SPAWN_CLOEXEC_DEFAULT; + + // args + v8::Local argv_ = v8::Local::Cast(info[1]); + + const int EXTRA_ARGS = 5; + int argc = argv_->Length(); + int argl = argc + EXTRA_ARGS + 1; + char **argv = new char*[argl]; + argv[0] = strdup(*cwd_); + argv[1] = strdup(std::to_string(uid).c_str()); + argv[2] = strdup(std::to_string(gid).c_str()); + argv[3] = strdup(explicitlyCloseFDs ? "1": "0"); + argv[4] = strdup(*file); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + Nan::Utf8String arg(Nan::Get(argv_, i).ToLocalChecked()); + argv[i + EXTRA_ARGS] = strdup(*arg); + } // size struct winsize winp; @@ -191,7 +207,7 @@ NAN_METHOD(PtyFork) { struct termios t = termios(); struct termios *term = &t; term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT; - if (Nan::To(info[8]).FromJust()) { + if (Nan::To(info[9]).FromJust()) { #if defined(IUTF8) term->c_iflag |= IUTF8; #endif @@ -225,15 +241,12 @@ NAN_METHOD(PtyFork) { cfsetispeed(term, B38400); cfsetospeed(term, B38400); - // uid / gid - int uid = info[6]->IntegerValue(Nan::GetCurrentContext()).FromJust(); - int gid = info[7]->IntegerValue(Nan::GetCurrentContext()).FromJust(); - - // fork the pty - int master = -1; + // helperPath + Nan::Utf8String helper_path_(info[11]); + char *helper_path = strdup(*helper_path_); sigset_t newmask, oldmask; - struct sigaction sig_action; + int flags = POSIX_SPAWN_USEVFORK; // temporarily block all signals // this is needed due to a race condition in openpty @@ -242,85 +255,109 @@ NAN_METHOD(PtyFork) { sigfillset(&newmask); pthread_sigmask(SIG_SETMASK, &newmask, &oldmask); - pid_t pid = pty_forkpty(&master, nullptr, term, &winp); + int master, slave; + int ret = pty_openpty(&master, &slave, nullptr, term, &winp); + if (ret == -1) { + perror("openpty failed"); + Nan::ThrowError("openpty failed."); + goto done; + } - if (!pid) { - // remove all signal handler from child - sig_action.sa_handler = SIG_DFL; - sig_action.sa_flags = 0; - sigemptyset(&sig_action.sa_mask); - for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1 - sigaction(i, &sig_action, NULL); - } + int comms_pipe[2]; + if (pipe(comms_pipe)) { + perror("pipe() failed"); + Nan::ThrowError("pipe() failed."); + goto done; } - // reenable signals - pthread_sigmask(SIG_SETMASK, &oldmask, NULL); - if (pid) { - for (i = 0; i < argl; i++) free(argv[i]); - delete[] argv; - for (i = 0; i < envc; i++) free(env[i]); - delete[] env; - free(cwd); + posix_spawn_file_actions_t acts; + posix_spawn_file_actions_init(&acts); + posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); + posix_spawn_file_actions_adddup2(&acts, comms_pipe[1], COMM_PIPE_FD); + posix_spawn_file_actions_addclose(&acts, comms_pipe[1]); + + posix_spawnattr_t attrs; + posix_spawnattr_init(&attrs); + if (closeFDs) { + flags |= POSIX_SPAWN_CLOEXEC_DEFAULT; } + posix_spawnattr_setflags(&attrs, flags); - switch (pid) { - case -1: - return Nan::ThrowError("forkpty(3) failed."); - case 0: - if (strlen(cwd)) { - if (chdir(cwd) == -1) { - perror("chdir(2) failed."); - _exit(1); - } - } + { // suppresses "jump bypasses variable initialization" errors + pid_t pid; + auto error = posix_spawn(&pid, helper_path, &acts, &attrs, argv, env); - if (uid != -1 && gid != -1) { - if (setgid(gid) == -1) { - perror("setgid(2) failed."); - _exit(1); - } - if (setuid(uid) == -1) { - perror("setuid(2) failed."); - _exit(1); - } - } + close(comms_pipe[1]); - pty_execvpe(argv[0], argv, env); + // reenable signals + pthread_sigmask(SIG_SETMASK, &oldmask, NULL); - perror("execvp(3) failed."); - _exit(1); - default: - if (pty_nonblock(master) == -1) { - return Nan::ThrowError("Could not set master fd to nonblocking."); + if (error) { + throw_for_errno("posix_spawn failed: ", error); + goto done; + } + + int helper_error[2]; + auto bytes_read = read(comms_pipe[0], &helper_error, sizeof(helper_error)); + close(comms_pipe[0]); + + if (bytes_read == sizeof(helper_error)) { + if (helper_error[0] == COMM_ERR_EXEC) { + throw_for_errno("exec() failed: ", helper_error[1]); + } else if (helper_error[0] == COMM_ERR_CHDIR) { + throw_for_errno("chdir() failed: ", helper_error[1]); + } else if (helper_error[0] == COMM_ERR_SETUID) { + throw_for_errno("setuid() failed: ", helper_error[1]); + } else if (helper_error[0] == COMM_ERR_SETGID) { + throw_for_errno("setgid() failed: ", helper_error[1]); } + goto done; + } - v8::Local obj = Nan::New(); - Nan::Set(obj, - Nan::New("fd").ToLocalChecked(), - Nan::New(master)); - Nan::Set(obj, - Nan::New("pid").ToLocalChecked(), - Nan::New(pid)); - Nan::Set(obj, - Nan::New("pty").ToLocalChecked(), - Nan::New(ptsname(master)).ToLocalChecked()); - - pty_baton *baton = new pty_baton(); - baton->exit_code = 0; - baton->signal_code = 0; - baton->cb.Reset(v8::Local::Cast(info[9])); - baton->pid = pid; - baton->async.data = baton; - - uv_async_init(uv_default_loop(), &baton->async, pty_after_waitpid); - - uv_thread_create(&baton->tid, pty_waitpid, static_cast(baton)); - - return info.GetReturnValue().Set(obj); + if (pty_nonblock(master) == -1) { + Nan::ThrowError("Could not set master fd to nonblocking."); + goto done; + } + + v8::Local obj = Nan::New(); + Nan::Set(obj, + Nan::New("fd").ToLocalChecked(), + Nan::New(master)); + Nan::Set(obj, + Nan::New("pid").ToLocalChecked(), + Nan::New(pid)); + Nan::Set(obj, + Nan::New("pty").ToLocalChecked(), + Nan::New(ptsname(master)).ToLocalChecked()); + + pty_baton *baton = new pty_baton(); + baton->exit_code = 0; + baton->signal_code = 0; + baton->cb.Reset(v8::Local::Cast(info[10])); + baton->pid = pid; + baton->async.data = baton; + + uv_async_init(uv_default_loop(), &baton->async, pty_after_waitpid); + + uv_thread_create(&baton->tid, pty_waitpid, static_cast(baton)); + + info.GetReturnValue().Set(obj); } +done: + posix_spawn_file_actions_destroy(&acts); + posix_spawnattr_destroy(&attrs); - return info.GetReturnValue().SetUndefined(); + if (argv) { + for (int i = 0; i < argl; i++) free(argv[i]); + delete[] argv; + } + if (env) { + for (int i = 0; i < envc; i++) free(env[i]); + delete[] env; + } + free(helper_path); } NAN_METHOD(PtyOpen) { @@ -428,21 +465,6 @@ NAN_METHOD(PtyGetProc) { return info.GetReturnValue().Set(name_); } -/** - * execvpe - */ - -// execvpe(3) is not portable. -// http://www.gnu.org/software/gnulib/manual/html_node/execvpe.html -static int -pty_execvpe(const char *file, char **argv, char **envp) { - char **old = environ; - environ = envp; - int ret = execvp(file, argv); - environ = old; - return ret; -} - /** * Nonblocking FD */ @@ -670,55 +692,6 @@ pty_openpty(int *amaster, #endif } -static pid_t -pty_forkpty(int *amaster, - char *name, - const struct termios *termp, - const struct winsize *winp) { -#if defined(__sun) - int master, slave; - - int ret = pty_openpty(&master, &slave, name, termp, winp); - if (ret == -1) return -1; - if (amaster) *amaster = master; - - pid_t pid = fork(); - - switch (pid) { - case -1: // error in fork, we are still in parent - close(master); - close(slave); - return -1; - case 0: // we are in the child process - close(master); - setsid(); - -#if defined(TIOCSCTTY) - // glibc does this - if (ioctl(slave, TIOCSCTTY, NULL) == -1) { - _exit(1); - } -#endif - - dup2(slave, 0); - dup2(slave, 1); - dup2(slave, 2); - - if (slave > 2) close(slave); - - return 0; - default: // we are in the parent process - close(slave); - return pid; - } - - return -1; -#else - return forkpty(amaster, name, (termios *)termp, (winsize *)winp); -#endif -} - - /** * Init */ diff --git a/src/unix/spawn-helper.cc b/src/unix/spawn-helper.cc new file mode 100644 index 00000000..35358bfc --- /dev/null +++ b/src/unix/spawn-helper.cc @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "comms.h" + +void bail (int type, int code) { + int buf[2] = { type, code }; + (void)! write(COMM_PIPE_FD, &buf, sizeof(buf)); + _exit(1); +} + +int main (int argc, char** argv) { + sigset_t empty_set; + sigemptyset(&empty_set); + pthread_sigmask(SIG_SETMASK, &empty_set, nullptr); + + setsid(); + +#if defined(TIOCSCTTY) + // glibc does this + if (ioctl(STDIN_FILENO, TIOCSCTTY, NULL) == -1) { + _exit(1); + } +#else + char *slave_path = ttyname(STDIN_FILENO); + // open implicit attaches a process to a terminal device if: + // - process has no controlling terminal yet + // - O_NOCTTY is not set + close(open(slave_path, O_RDWR)); +#endif + + char *cwd = argv[0]; + int uid = std::stoi(argv[1]); + int gid = std::stoi(argv[2]); + bool closeFDs = std::stoi(argv[3]); + char *file = argv[4]; + argv = &argv[4]; + + fcntl(COMM_PIPE_FD, F_SETFD, FD_CLOEXEC); + + if (strlen(cwd) && chdir(cwd) == -1) { + bail(COMM_ERR_CHDIR, errno); + } + if (uid != -1 && setuid(uid) == -1) { + bail(COMM_ERR_SETUID, errno); + } + if (gid != -1 && setgid(gid) == -1) { + bail(COMM_ERR_SETGID, errno); + } + if (closeFDs) { + struct rlimit rlim_ofile; + getrlimit(RLIMIT_NOFILE, &rlim_ofile); + for (rlim_t fd = STDERR_FILENO + 1; fd < rlim_ofile.rlim_cur; fd++) { + if (fd != COMM_PIPE_FD) { + close(fd); + } + } + } + + execvp(file, argv); + bail(COMM_ERR_EXEC, errno); + return 1; +} diff --git a/src/unixTerminal.test.ts b/src/unixTerminal.test.ts index ee2cd696..74881f44 100644 --- a/src/unixTerminal.test.ts +++ b/src/unixTerminal.test.ts @@ -7,6 +7,8 @@ import { UnixTerminal } from './unixTerminal'; import * as assert from 'assert'; import * as cp from 'child_process'; import * as path from 'path'; +import * as tty from 'tty'; +import { constants } from 'os'; import { pollUntil } from './testUtils.test'; const FIXTURES_PATH = path.normalize(path.join(__dirname, '..', 'fixtures', 'utf8-character.txt')); @@ -26,8 +28,9 @@ if (process.platform !== 'win32') { regExp = /^\/dev\/tty[p-sP-S][a-z0-9]+$/; } if (regExp) { - assert.ok(regExp.test((term)._pty), '"' + (term)._pty + '" should match ' + regExp.toString()); + assert.ok(regExp.test(term.ptsName), '"' + term.ptsName + '" should match ' + regExp.toString()); } + assert.ok(tty.isatty(term.fd)); }); }); @@ -104,6 +107,17 @@ if (process.platform !== 'win32') { term.master.write('master\n'); }); }); + describe('close', () => { + const term = new UnixTerminal('node'); + it('should exit when terminal is destroyed programmatically', (done) => { + term.on('exit', (code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, constants.signals.SIGHUP); + done(); + }); + term.destroy(); + }); + }); describe('signals in parent and child', () => { it('SIGINT - custom in parent and child', done => { // this test is cumbersome - we have to run it in a sub process to @@ -239,5 +253,33 @@ if (process.platform !== 'win32') { }); }); }); + describe('spawn', () => { + it('should handle exec() errors', (done) => { + try { + new UnixTerminal('/bin/bogus.exe', []); + done(new Error('should have failed')); + } catch { + done(); + } + }); + it('should handle chdir() errors', (done) => { + try { + new UnixTerminal('/bin/echo', [], { cwd: '/nowhere' }); + done(new Error('should have failed')); + } catch (e) { + assert.equal(e.toString(), 'Error: chdir() failed: No such file or directory'); + done(); + } + }); + it('should handle setuid() errors', (done) => { + try { + new UnixTerminal('/bin/echo', [], { uid: 999999 }); + done(new Error('should have failed')); + } catch (e) { + assert.equal(e.toString(), 'Error: setuid() failed: Operation not permitted'); + done(); + } + }); + }); }); } diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts index 690dbb57..118b5bcc 100644 --- a/src/unixTerminal.ts +++ b/src/unixTerminal.ts @@ -4,17 +4,21 @@ * Copyright (c) 2018, Microsoft Corporation (MIT License). */ import * as net from 'net'; +import * as path from 'path'; import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal'; import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces'; import { ArgvOrCommandLine } from './types'; import { assign } from './utils'; let pty: IUnixNative; +let helperPath: string; try { pty = require('../build/Release/pty.node'); + helperPath = '../build/Release/spawn-helper'; } catch (outerError) { try { pty = require('../build/Debug/pty.node'); + helperPath = '../build/Debug/spawn-helper'; } catch (innerError) { console.error('innerError', innerError); // Re-throw the exception from the Release require if the Debug require fails as well @@ -22,6 +26,10 @@ try { } } +helperPath = path.resolve(__dirname, helperPath); +helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); + const DEFAULT_FILE = 'sh'; const DEFAULT_NAME = 'xterm'; const DESTROY_SOCKET_TIMEOUT_MS = 200; @@ -61,6 +69,7 @@ export class UnixTerminal extends Terminal { this._rows = opt.rows || DEFAULT_ROWS; const uid = opt.uid || -1; const gid = opt.gid || -1; + const closeFDs = opt.closeFDs || false; const env: IProcessEnv = assign({}, opt.env); if (opt.env === process.env) { @@ -103,7 +112,7 @@ export class UnixTerminal extends Terminal { }; // fork - const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), onexit); + const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), closeFDs, onexit, helperPath); this._socket = new PipeSocket(term.fd); if (encoding !== null) { @@ -169,6 +178,10 @@ export class UnixTerminal extends Terminal { this._socket.write(data); } + /* Accessors */ + get fd(): number { return this._fd; } + get ptsName(): string { return this._pty; } + /** * openpty */ diff --git a/typings/node-pty.d.ts b/typings/node-pty.d.ts index ccab4a0c..9dd3286c 100644 --- a/typings/node-pty.d.ts +++ b/typings/node-pty.d.ts @@ -78,11 +78,17 @@ declare module 'node-pty' { export interface IPtyForkOptions extends IBasePtyForkOptions { /** - * Security warning: use this option with great caution, as opened file descriptors - * with higher privileges might leak to the child program. + * Security warning: use this option with great caution unless `closeFDs` is also set, + * as opened file descriptors with higher privileges might leak to the child program. */ uid?: number; gid?: number; + + /** + * Close all open file after spawning the child process (UNIX only). + * Carries a performance penalty on Linux. + */ + closeFDs?: boolean; } export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {