From 88684d1068d0daddab0315732e251dfcbe79ffa7 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 12 Jul 2026 10:21:44 +1000 Subject: [PATCH 01/14] Add client-side ControlMaster (connection multiplexing) support Implements ssh ControlMaster/ControlPath/ControlSlave multiplexing on Windows, which has historically been out of scope for this port. The POSIX mux design depends on two primitives Windows lacks: Unix domain socket servers and SCM_RIGHTS file descriptor passing. This change provides Windows-native equivalents in the compat layer and compiles mux.c into ssh.exe: - AF_UNIX server emulation over named pipes (fileio.c, w32fd.c): bind()/listen()/accept() on AF_UNIX stream sockets are implemented with CreateNamedPipe + overlapped ConnectNamedPipe, integrated with the w32_select event loop the same way TCP listeners are. ControlPath values are mapped deterministically onto pipe names (\\.\pipe\openssh-uds-); explicit \\.\pipe\ paths are used verbatim. The listener pipe is created with a DACL restricted to SYSTEM and the current user, with PIPE_REJECT_REMOTE_CLIENTS. - File descriptor passing (w32fd.c, monitor_fdpass.c): mm_send_fd() transmits the sender's pid and raw handle value in-band; mm_receive_fd() verifies the claimed pid against GetNamedPipeClientProcessId, verifies the peer process token belongs to the same Windows user, and then duplicates the handle with OpenProcess(PROCESS_DUP_HANDLE) + DuplicateHandle. Received handles default to the synchronous io path, matching how inherited stdio handles are classified. - getpeereid() (misc.c): now succeeds iff the pipe peer process runs as the same Windows user, so the mux listener's peer check in channels.c behaves as intended (defense in depth on top of the pipe DACL). - mux.c is compiled into ssh.exe; the temp-path/link/unlink/umask socket setup and tcgetattr are #ifdef'd for Windows. sun_path is raised to 260 since ControlPath commonly lives under deep profile paths. Limitations (documented, fail safe): - tty sessions are not multiplexed; ssh transparently falls back to a separate connection (the master would have to drive the client's console - raw mode, VT input, resize - which is future work). - ControlPersist is disabled on Windows (requires fork()); the master must remain in the foreground, e.g. ssh -M -N. - Passing socket-type fds (OPENSSH_STDIO_MODE=sock) is not supported. - Client and master must run un-elevated or both elevated; DuplicateHandle across an elevation boundary fails (by design). Tested on Windows 11 x64 against sshd from this branch: master (-M -N -S), -O check, multiple concurrent exec sessions with stdout/ stderr/exit-status propagation, stdin round-trip, -O forward with a live tunnel, -O exit, and single shared TCP connection verified throughout. Stale/foreign ControlPath and same-user enforcement exercised manually. Co-Authored-By: Claude Fable 5 --- contrib/win32/openssh/ssh.vcxproj | 1 + contrib/win32/win32compat/fileio.c | 297 +++++++++++++++++++++- contrib/win32/win32compat/inc/sys/un.h | 6 +- contrib/win32/win32compat/misc.c | 34 ++- contrib/win32/win32compat/misc_internal.h | 1 + contrib/win32/win32compat/no-ops.c | 28 +- contrib/win32/win32compat/w32fd.c | 270 +++++++++++++++++++- contrib/win32/win32compat/w32fd.h | 4 + monitor_fdpass.c | 18 ++ mux.c | 43 ++++ ssh.c | 7 + 11 files changed, 662 insertions(+), 47 deletions(-) diff --git a/contrib/win32/openssh/ssh.vcxproj b/contrib/win32/openssh/ssh.vcxproj index 911460869af3..272c5badc9c0 100644 --- a/contrib/win32/openssh/ssh.vcxproj +++ b/contrib/win32/openssh/ssh.vcxproj @@ -515,6 +515,7 @@ + diff --git a/contrib/win32/win32compat/fileio.c b/contrib/win32/win32compat/fileio.c index 2f62fa855478..8dae80f74502 100644 --- a/contrib/win32/win32compat/fileio.c +++ b/contrib/win32/win32compat/fileio.c @@ -81,6 +81,7 @@ struct createFile_flags { int syncio_initiate_read(struct w32_io* pio); int syncio_initiate_write(struct w32_io* pio, DWORD num_bytes); int syncio_close(struct w32_io* pio); +static wchar_t *afunix_pipe_name(const char *sun_path); /* maps Win32 error to errno */ int @@ -133,11 +134,11 @@ fileio_connect(struct w32_io* pio, char* name) goto cleanup; } - if ((name_w = utf8_to_utf16(name)) == NULL) { + if ((name_w = afunix_pipe_name(name)) == NULL) { errno = ENOMEM; return -1; } - + do { h = CreateFileW(name_w, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, NULL); @@ -178,6 +179,279 @@ fileio_connect(struct w32_io* pio, char* name) return ret; } +/* + * AF_UNIX stream sockets are emulated over named pipes. + * A path that is not already a pipe name (\\.\pipe\...) is mapped onto + * one deterministically so that server (bind) and client (connect) + * arrive at the same pipe name from the same sun_path. + */ +#define AFUNIX_PIPE_PREFIX L"\\\\.\\pipe\\" +#define AFUNIX_PIPE_PREFIX_LEN 9 +/* pipe name component (after \\.\pipe\) is limited to 256 chars */ +#define AFUNIX_PIPE_NAME_MAX 256 + +/* per-listener state, hangs off pio->internal.context */ +struct afunix_listener_state { + wchar_t *pipe_name; + PSECURITY_DESCRIPTOR sd; /* owner + SYSTEM only */ + BOOL connect_pending; /* overlapped ConnectNamedPipe outstanding */ + BOOL client_connected; /* connection completed, awaiting accept() */ +}; + +static wchar_t * +afunix_pipe_name(const char *sun_path) +{ + wchar_t *path_w = NULL, *ret = NULL, *p; + size_t len; + + if ((path_w = utf8_to_utf16(sun_path)) == NULL) { + errno = ENOMEM; + return NULL; + } + + for (p = path_w; *p; p++) + if (*p == L'/') + *p = L'\\'; + + if (_wcsnicmp(path_w, AFUNIX_PIPE_PREFIX, AFUNIX_PIPE_PREFIX_LEN) == 0) + return path_w; + + /* map filesystem-style path to \\.\pipe\openssh-uds- */ + for (p = path_w; *p; p++) + if (*p == L'\\' || *p == L':') + *p = L'-'; + + if (wcslen(path_w) > AFUNIX_PIPE_NAME_MAX - wcslen(L"openssh-uds-")) { + free(path_w); + errno = ENAMETOOLONG; + return NULL; + } + + len = AFUNIX_PIPE_PREFIX_LEN + wcslen(L"openssh-uds-") + wcslen(path_w) + 1; + if ((ret = malloc(len * sizeof(wchar_t))) == NULL) { + free(path_w); + errno = ENOMEM; + return NULL; + } + swprintf_s(ret, len, L"%s%s%s", AFUNIX_PIPE_PREFIX, L"openssh-uds-", path_w); + free(path_w); + return ret; +} + +/* issue an overlapped ConnectNamedPipe on the current listener instance */ +static int +afunix_listener_arm(struct w32_io* pio) +{ + struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; + + ResetEvent(pio->read_overlapped.hEvent); + if (ConnectNamedPipe(WINHANDLE(pio), &pio->read_overlapped)) { + state->client_connected = TRUE; + SetEvent(pio->read_overlapped.hEvent); + return 0; + } + switch (GetLastError()) { + case ERROR_IO_PENDING: + state->connect_pending = TRUE; + return 0; + case ERROR_PIPE_CONNECTED: + state->client_connected = TRUE; + SetEvent(pio->read_overlapped.hEvent); + return 0; + default: + errno = errno_from_Win32LastError(); + debug3("afunix listener - ConnectNamedPipe() ERROR:%d, io:%p", GetLastError(), pio); + return -1; + } +} + +static HANDLE +afunix_create_instance(struct afunix_listener_state* state, BOOL first) +{ + SECURITY_ATTRIBUTES sa; + HANDLE h; + DWORD open_mode = PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED; + + if (first) + open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; + + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = state->sd; + sa.bInheritHandle = FALSE; + + h = CreateNamedPipeW(state->pipe_name, open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_REJECT_REMOTE_CLIENTS | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, 4096, 4096, 0, &sa); + + if (h == INVALID_HANDLE_VALUE) { + DWORD win32_error = GetLastError(); + debug3("afunix - CreateNamedPipe(%ls) ERROR:%d", state->pipe_name, win32_error); + /* pipe already owned by another process */ + if (win32_error == ERROR_ACCESS_DENIED || win32_error == ERROR_PIPE_BUSY) + errno = EADDRINUSE; + else + errno = errno_from_Win32Error(win32_error); + } + return h; +} + +/* bind() on an AF_UNIX socket - creates the first pipe instance */ +int +fileio_afunix_bind(struct w32_io* pio, const char* sun_path) +{ + struct afunix_listener_state* state = NULL; + wchar_t *sid_utf16 = NULL, sddl[SDDL_LENGTH]; + PSID user_sid = NULL; + HANDLE h = INVALID_HANDLE_VALUE; + int ret = -1; + + if (WINHANDLE(pio) != 0 && WINHANDLE(pio) != INVALID_HANDLE_VALUE) { + errno = EINVAL; + return -1; + } + + if ((state = calloc(1, sizeof(*state))) == NULL) { + errno = ENOMEM; + return -1; + } + + if ((state->pipe_name = afunix_pipe_name(sun_path)) == NULL) + goto cleanup; + + /* restrict the control pipe to SYSTEM and the current user */ + if ((user_sid = get_sid(NULL)) == NULL || + ConvertSidToStringSidW(user_sid, &sid_utf16) == FALSE) { + debug3("afunix bind - cannot retrieve current user's SID"); + errno = EOTHER; + goto cleanup; + } + swprintf_s(sddl, SDDL_LENGTH, L"D:P(A;;GA;;;SY)(A;;GA;;;%s)", sid_utf16); + if (ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, + SDDL_REVISION_1, &state->sd, NULL) == FALSE) { + debug3("afunix bind - cannot convert sddl ERROR:%d", GetLastError()); + errno = EOTHER; + goto cleanup; + } + + if ((h = afunix_create_instance(state, TRUE)) == INVALID_HANDLE_VALUE) + goto cleanup; + + pio->handle = h; + pio->internal.context = state; + ret = 0; + +cleanup: + if (user_sid) + free(user_sid); + if (sid_utf16) + LocalFree(sid_utf16); + if (ret != 0 && state) { + if (state->pipe_name) + free(state->pipe_name); + if (state->sd) + LocalFree(state->sd); + free(state); + } + return ret; +} + +/* listen() on a bound AF_UNIX socket - starts accepting connections */ +int +fileio_afunix_listen(struct w32_io* pio, int backlog) +{ + struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; + + if (state == NULL || WINHANDLE(pio) == 0 || WINHANDLE(pio) == INVALID_HANDLE_VALUE) { + errno = EINVAL; + return -1; + } + + if ((pio->read_overlapped.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL)) == NULL) { + errno = ENOMEM; + return -1; + } + + if (afunix_listener_arm(pio) != 0) { + CloseHandle(pio->read_overlapped.hEvent); + pio->read_overlapped.hEvent = NULL; + return -1; + } + + pio->internal.state = SOCK_LISTENING; + return 0; +} + +/* select() readiness check for a listening AF_UNIX socket */ +BOOL +fileio_afunix_listener_ready(struct w32_io* pio) +{ + struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; + DWORD bytes; + + if (state == NULL) + return FALSE; + if (state->client_connected) + return TRUE; + if (!state->connect_pending) + return FALSE; + + if (GetOverlappedResult(WINHANDLE(pio), &pio->read_overlapped, &bytes, FALSE)) { + state->connect_pending = FALSE; + state->client_connected = TRUE; + return TRUE; + } + if (GetLastError() == ERROR_IO_INCOMPLETE) + return FALSE; + + /* + * connection attempt failed (client vanished). Report ready anyway; + * accept() hands out the dead pipe and reads on it return EOF, matching + * socket semantics for a connection that was closed right after accept. + */ + state->connect_pending = FALSE; + state->client_connected = TRUE; + return TRUE; +} + +/* accept() on a listening AF_UNIX socket. Caller ensures a client is connected */ +struct w32_io* +fileio_afunix_accept(struct w32_io* pio) +{ + struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; + struct w32_io* accepted = NULL; + HANDLE connected, next; + + if (state == NULL || !fileio_afunix_listener_ready(pio)) { + errno = EAGAIN; + return NULL; + } + + if ((accepted = malloc(sizeof(struct w32_io))) == NULL) { + errno = ENOMEM; + return NULL; + } + memset(accepted, 0, sizeof(struct w32_io)); + + connected = WINHANDLE(pio); + state->client_connected = FALSE; + + /* stand up the next instance before handing out the connected one */ + if ((next = afunix_create_instance(state, FALSE)) == INVALID_HANDLE_VALUE) { + /* listener is degraded; subsequent accepts will fail */ + error("afunix accept - failed to create next pipe instance, errno:%d", errno); + pio->handle = 0; + } else { + pio->handle = next; + if (afunix_listener_arm(pio) != 0) + error("afunix accept - failed to arm next pipe instance"); + } + + accepted->handle = connected; + accepted->type = NONSOCK_FD; + return accepted; +} + /* used to name named pipes used to implement pipe() */ static int pipe_counter = 0; @@ -1083,6 +1357,25 @@ fileio_close(struct w32_io* pio) { debug4("fileclose - pio:%p", pio); + /* bound/listening AF_UNIX socket emulated over named pipes */ + if (pio->internal.context) { + struct afunix_listener_state* state = + (struct afunix_listener_state*)pio->internal.context; + if (WINHANDLE(pio) != 0 && WINHANDLE(pio) != INVALID_HANDLE_VALUE) { + CancelIo(WINHANDLE(pio)); + CloseHandle(WINHANDLE(pio)); + } + if (pio->read_overlapped.hEvent) + CloseHandle(pio->read_overlapped.hEvent); + if (state->pipe_name) + free(state->pipe_name); + if (state->sd) + LocalFree(state->sd); + free(state); + free(pio); + return 0; + } + if (pio->type == NONSOCK_SYNC_FD || FILETYPE(pio) == FILE_TYPE_CHAR) return syncio_close(pio); diff --git a/contrib/win32/win32compat/inc/sys/un.h b/contrib/win32/win32compat/inc/sys/un.h index 42f09b8cebd7..2ff0b531e83f 100644 --- a/contrib/win32/win32compat/inc/sys/un.h +++ b/contrib/win32/win32compat/inc/sys/un.h @@ -2,6 +2,10 @@ struct sockaddr_un { short sun_family; /* AF_UNIX */ - char sun_path[108]; /* path name (gag) */ + /* + * larger than the traditional 108 - these paths are mapped onto + * named pipe names on Windows and deep profile paths are common + */ + char sun_path[260]; /* path name (gag) */ }; diff --git a/contrib/win32/win32compat/misc.c b/contrib/win32/win32compat/misc.c index 861ee2d585e4..7e8f01b286ae 100644 --- a/contrib/win32/win32compat/misc.c +++ b/contrib/win32/win32compat/misc.c @@ -55,6 +55,7 @@ #include "inc\sys\types.h" #include "inc\sys\ioctl.h" #include "inc\fcntl.h" +#include "inc\pwd.h" #include "inc\utf.h" #include "debug.h" #include "w32fd.h" @@ -2046,12 +2047,39 @@ bash_to_win_path(const char *in, char *out, const size_t out_len) return retVal; } +/* + * getpeereid() emulation for AF_UNIX sockets emulated over named pipes. + * There are no numeric uids on Windows; the contract provided is: succeed + * with euid == geteuid() iff the pipe peer process runs as the same Windows + * user, so that callers comparing against getuid()/geteuid() get the right + * answer. Note that the pipe's DACL (owner + SYSTEM) enforces this too. + */ int getpeereid(int s, uid_t *euid, gid_t *egid) { - verbose("%s is not supported", __func__); - errno = ENOTSUP; - return -1; + HANDLE h; + DWORD peer_pid = 0; + + if ((h = w32_fd_to_handle(s)) == NULL || h == INVALID_HANDLE_VALUE) { + errno = EBADF; + return -1; + } + + if (!GetNamedPipeClientProcessId(h, &peer_pid) && + !GetNamedPipeServerProcessId(h, &peer_pid)) { + debug3("%s - cannot determine pipe peer, error: %d", __func__, GetLastError()); + errno = ENOTSUP; + return -1; + } + + if (peer_pid != GetCurrentProcessId() && !w32_is_pid_same_user(peer_pid)) { + errno = EPERM; + return -1; + } + + *euid = geteuid(); + *egid = getegid(); + return 0; } int diff --git a/contrib/win32/win32compat/misc_internal.h b/contrib/win32/win32compat/misc_internal.h index 5a43a992e820..ad6d0fd2389f 100644 --- a/contrib/win32/win32compat/misc_internal.h +++ b/contrib/win32/win32compat/misc_internal.h @@ -73,6 +73,7 @@ int file_in_chroot_jail(HANDLE); int file_in_chroot_jail_helper(wchar_t*); PSID lookup_sid(const wchar_t* name_utf16, PSID psid, DWORD * psid_len); PSID get_sid(const char*); +BOOL w32_is_pid_same_user(DWORD pid); int am_system(); int is_conpty_supported(); int exec_command_with_pty(int * pid, char* cmd, int in, int out, int err, unsigned int col, unsigned int row, int ttyfd); diff --git a/contrib/win32/win32compat/no-ops.c b/contrib/win32/win32compat/no-ops.c index a179d0ddfaae..b604fa9da1ce 100644 --- a/contrib/win32/win32compat/no-ops.c +++ b/contrib/win32/win32compat/no-ops.c @@ -56,33 +56,7 @@ permanently_set_uid(struct passwd *pw) } -/* mux.c defs */ -int muxserver_sock = -1; -typedef struct Channel Channel; -unsigned int muxclient_command = 0; -void -muxserver_listen(void) -{ - return; -} - -void -mux_exit_message(Channel *c, int exitval) -{ - return; -} - -void -mux_tty_alloc_failed(Channel *c) -{ - return; -} - -void -muxclient(const char *path) -{ - return; -} +/* mux.c is now compiled on Windows (ControlMaster support) */ int innetgr(const char *netgroup, const char *host, const char *user, const char *domain) diff --git a/contrib/win32/win32compat/w32fd.c b/contrib/win32/win32compat/w32fd.c index ed468be54f7a..e392e6cc7d27 100644 --- a/contrib/win32/win32compat/w32fd.c +++ b/contrib/win32/win32compat/w32fd.c @@ -276,6 +276,9 @@ w32_io_is_io_available(struct w32_io* pio, BOOL rd) { if (pio->type == SOCK_FD) return socketio_is_io_available(pio, rd); + else if (pio->internal.state == SOCK_LISTENING) + /* listening AF_UNIX socket emulated over named pipes */ + return rd ? fileio_afunix_listener_ready(pio) : FALSE; else return fileio_is_io_available(pio, rd); } @@ -285,6 +288,9 @@ w32_io_on_select(struct w32_io* pio, BOOL rd) { if ((pio->type == SOCK_FD)) socketio_on_select(pio, rd); + else if (pio->internal.state == SOCK_LISTENING) + /* ConnectNamedPipe is already pending; nothing to initiate */ + return; else fileio_on_select(pio, rd); } @@ -338,7 +344,6 @@ int w32_accept(int fd, struct sockaddr* addr, int* addrlen) { CHECK_FD(fd); - CHECK_SOCK_IO(fd_table.w32_ios[fd]); int min_index = fd_table_get_min_index(); struct w32_io* pio = NULL; @@ -346,11 +351,35 @@ w32_accept(int fd, struct sockaddr* addr, int* addrlen) return -1; if (fd_table.w32_ios[fd]->type == NONSOCK_FD) { - errno = ENOTSUP; - verbose("Unix domain server sockets are not supported"); - return -1; + struct w32_io* listener = fd_table.w32_ios[fd]; + + if (listener->internal.state != SOCK_LISTENING || + WINHANDLE(listener) == 0) { + errno = EINVAL; + return -1; + } + + while (!fileio_afunix_listener_ready(listener)) { + if (!w32_io_is_blocking(listener)) { + errno = EAGAIN; + return -1; + } + if (wait_for_any_event(&listener->read_overlapped.hEvent, + 1, INFINITE) == -1) + return -1; + } + + if ((pio = fileio_afunix_accept(listener)) == NULL) + return -1; + + fd_table_set(pio, min_index); + if (addr && addrlen) + memset(addr, 0, *addrlen); + debug4("afunix accept - handle:%p, io:%p, fd:%d", pio->handle, pio, min_index); + return min_index; } + CHECK_SOCK_IO(fd_table.w32_ios[fd]); pio = socketio_accept(fd_table.w32_ios[fd], addr, addrlen); if (!pio) return -1; @@ -397,11 +426,8 @@ int w32_listen(int fd, int backlog) { CHECK_FD(fd); - if (fd_table.w32_ios[fd]->type == NONSOCK_FD) { - errno = ENOTSUP; - verbose("Unix domain server sockets are not supported"); - return -1; - } + if (fd_table.w32_ios[fd]->type == NONSOCK_FD) + return fileio_afunix_listen(fd_table.w32_ios[fd], backlog); CHECK_SOCK_IO(fd_table.w32_ios[fd]); return socketio_listen(fd_table.w32_ios[fd], backlog); @@ -412,9 +438,8 @@ w32_bind(int fd, const struct sockaddr *name, int namelen) { CHECK_FD(fd); if (fd_table.w32_ios[fd]->type == NONSOCK_FD) { - errno = ENOTSUP; - verbose("Unix domain server sockets are not supported"); - return -1; + struct sockaddr_un* addr = (struct sockaddr_un*)name; + return fileio_afunix_bind(fd_table.w32_ios[fd], addr->sun_path); } CHECK_SOCK_IO(fd_table.w32_ios[fd]); @@ -796,8 +821,9 @@ w32_select(int fds, w32_fd_set* readfds, w32_fd_set* writefds, w32_fd_set* excep for (int i = 0; i < fds; i++) { if (readfds && FD_ISSET(i, readfds)) { w32_io_on_select(fd_table.w32_ios[i], TRUE); - if ((fd_table.w32_ios[i]->type == SOCK_FD) && - (fd_table.w32_ios[i]->internal.state == SOCK_LISTENING)) { + /* listening sockets (TCP or AF_UNIX pipe) signal via event */ + if (fd_table.w32_ios[i]->internal.state == SOCK_LISTENING && + fd_table.w32_ios[i]->read_overlapped.hEvent != NULL) { if (num_events == SELECT_EVENT_LIMIT) { debug3("select - ERROR: max #events breach"); errno = ENOMEM; @@ -1005,6 +1031,222 @@ w32_fd_to_handle(int fd) return fd_table.w32_ios[fd]->handle; } +/* wraps a raw win32 handle in a new fd table entry */ +static int +w32_allocate_fd_for_handle(HANDLE h, int type) +{ + int min_index = fd_table_get_min_index(); + struct w32_io* pio; + + if (min_index == -1) + return -1; + + if ((pio = malloc(sizeof(struct w32_io))) == NULL) { + errno = ENOMEM; + return -1; + } + memset(pio, 0, sizeof(struct w32_io)); + pio->type = type; + pio->handle = h; + fd_table_set(pio, min_index); + return min_index; +} + +/* + * File descriptor passing over AF_UNIX (named pipe) sockets. + * Windows has no SCM_RIGHTS: the sender transmits its pid and raw handle + * value in-band; the receiver verifies the peer is the same Windows user + * and pulls the handle across with DuplicateHandle. Used by ssh mux + * (ControlMaster) to pass the mux client's stdio to the mux master. + */ +#define W32_FDPASS_MAGIC 0x77465044 /* "wFPD" */ +#define W32_FDPASS_TIMEOUT_MS 15000 + +#pragma pack(push, 1) +struct w32_fdpass_msg { + unsigned __int32 magic; + unsigned __int32 pid; + unsigned __int64 handle; + unsigned __int32 type; /* enum w32_io_type of sender's fd */ +}; +#pragma pack(pop) + +/* TRUE if process pid runs as the same Windows user as us */ +BOOL +w32_is_pid_same_user(DWORD pid) +{ + BOOL ret = FALSE; + HANDLE proc = NULL, token = NULL; + TOKEN_USER *peer_info = NULL; + PSID my_sid = NULL; + DWORD info_len = 0; + + if ((my_sid = get_sid(NULL)) == NULL) { + error("fdpass - cannot retrieve own SID"); + goto done; + } + if ((proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)) == NULL) { + error("fdpass - OpenProcess(%d) failed, error: %d", pid, GetLastError()); + goto done; + } + if (!OpenProcessToken(proc, TOKEN_QUERY, &token)) { + error("fdpass - OpenProcessToken failed, error: %d", GetLastError()); + goto done; + } + if (GetTokenInformation(token, TokenUser, NULL, 0, &info_len) == TRUE || + (peer_info = (TOKEN_USER*)malloc(info_len)) == NULL) + goto done; + if (GetTokenInformation(token, TokenUser, peer_info, info_len, &info_len) == FALSE) + goto done; + ret = EqualSid(my_sid, peer_info->User.Sid); + if (!ret) + error("fdpass - peer process %d is a different user", pid); + +done: + if (peer_info) + free(peer_info); + if (my_sid) + free(my_sid); + if (token) + CloseHandle(token); + if (proc) + CloseHandle(proc); + return ret; +} + +/* read/write full buffer on possibly nonblocking fd, pumping APCs */ +static int +fdpass_io(int sock, char* buf, int len, BOOL do_write) +{ + int done = 0, r; + ULONGLONG deadline = GetTickCount64() + W32_FDPASS_TIMEOUT_MS; + + while (done < len) { + r = do_write ? w32_write(sock, buf + done, len - done) : + w32_read(sock, buf + done, len - done); + if (r > 0) { + done += r; + continue; + } + if (r == 0 && !do_write) { + errno = EPIPE; + return -1; + } + if (r < 0 && errno != EAGAIN && errno != EINTR) + return -1; + if (GetTickCount64() > deadline) { + errno = ETIMEDOUT; + return -1; + } + /* pump APCs so pending async io on sock can complete */ + if (wait_for_any_event(NULL, 0, 100) == -1 && errno != EINTR) + return -1; + errno = 0; + } + return 0; +} + +int +w32_fdpass_send(int sock, int fd) +{ + struct w32_fdpass_msg msg; + struct w32_io* pio; + + CHECK_FD(sock); + CHECK_FD(fd); + + pio = fd_table.w32_ios[fd]; + if (pio->type == SOCK_FD) { + /* would need WSADuplicateSocket with receiver pid */ + errno = ENOTSUP; + error("fdpass - passing socket fds is not supported"); + return -1; + } + + msg.magic = W32_FDPASS_MAGIC; + msg.pid = GetCurrentProcessId(); + msg.handle = (unsigned __int64)(uintptr_t)pio->handle; + msg.type = pio->type; + + if (fdpass_io(sock, (char*)&msg, sizeof(msg), TRUE) != 0) { + error("fdpass - failed to send fd %d over fd %d, errno: %d", fd, sock, errno); + return -1; + } + debug3("fdpass - sent fd:%d handle:%p over fd:%d", fd, pio->handle, sock); + return 0; +} + +int +w32_fdpass_recv(int sock) +{ + struct w32_fdpass_msg msg; + HANDLE src_proc = NULL, dup = NULL; + DWORD pipe_client_pid = 0; + int fd = -1, type; + + CHECK_FD(sock); + + if (fdpass_io(sock, (char*)&msg, sizeof(msg), FALSE) != 0) { + error("fdpass - failed to read fd message from fd %d, errno: %d", sock, errno); + return -1; + } + + if (msg.magic != W32_FDPASS_MAGIC) { + error("fdpass - bad magic 0x%08x from fd %d", msg.magic, sock); + errno = EINVAL; + return -1; + } + + /* when the transport is a named pipe, the claimed pid must match the peer */ + if (fd_table.w32_ios[sock]->type == NONSOCK_FD && + GetNamedPipeClientProcessId(fd_table.w32_ios[sock]->handle, &pipe_client_pid) && + pipe_client_pid != 0 && pipe_client_pid != GetCurrentProcessId() && + pipe_client_pid != msg.pid) { + error("fdpass - claimed pid %d does not match pipe peer %d", + msg.pid, pipe_client_pid); + errno = EPERM; + return -1; + } + + if (!w32_is_pid_same_user(msg.pid)) { + errno = EPERM; + return -1; + } + + if ((src_proc = OpenProcess(PROCESS_DUP_HANDLE, FALSE, msg.pid)) == NULL) { + error("fdpass - OpenProcess(%d) for dup failed, error: %d", msg.pid, GetLastError()); + errno = EPERM; + return -1; + } + + if (!DuplicateHandle(src_proc, (HANDLE)(uintptr_t)msg.handle, + GetCurrentProcess(), &dup, 0, FALSE, DUPLICATE_SAME_ACCESS)) { + error("fdpass - DuplicateHandle failed, error: %d", GetLastError()); + CloseHandle(src_proc); + errno = EPERM; + return -1; + } + CloseHandle(src_proc); + + /* + * sender's fd classification decides sync vs async io; inherited stdio + * handles (console, redirected pipes/files) are typically opened + * non-overlapped, so default to synchronous io unless the sender was + * using async io on its end. + */ + type = (msg.type == NONSOCK_FD) ? NONSOCK_FD : NONSOCK_SYNC_FD; + if (GetFileType(dup) == FILE_TYPE_CHAR) + type = NONSOCK_SYNC_FD; + + if ((fd = w32_allocate_fd_for_handle(dup, type)) == -1) { + CloseHandle(dup); + return -1; + } + debug3("fdpass - received handle:%p from pid:%d as fd:%d type:%d", + dup, msg.pid, fd, type); + return fd; +} + int w32_ftruncate(int fd, off_t length) { diff --git a/contrib/win32/win32compat/w32fd.h b/contrib/win32/win32compat/w32fd.h index 0f1ee3a08f62..d3c94d96d807 100644 --- a/contrib/win32/win32compat/w32fd.h +++ b/contrib/win32/win32compat/w32fd.h @@ -155,6 +155,10 @@ void fileio_on_select(struct w32_io* pio, BOOL rd); int fileio_close(struct w32_io* pio); int fileio_pipe(struct w32_io* pio[2], int); struct w32_io* fileio_afunix_socket(); +int fileio_afunix_bind(struct w32_io* pio, const char* sun_path); +int fileio_afunix_listen(struct w32_io* pio, int backlog); +BOOL fileio_afunix_listener_ready(struct w32_io* pio); +struct w32_io* fileio_afunix_accept(struct w32_io* pio); int fileio_connect(struct w32_io*, char*); struct w32_io* fileio_open(const char *pathname, int flags, mode_t mode); int fileio_read(struct w32_io* pio, void *dst, size_t max); diff --git a/monitor_fdpass.c b/monitor_fdpass.c index a2472abdb01b..0a1bce63c2ed 100644 --- a/monitor_fdpass.c +++ b/monitor_fdpass.c @@ -39,6 +39,23 @@ #include "log.h" #include "monitor_fdpass.h" +#ifdef WINDOWS +/* implemented in contrib/win32/win32compat/w32fd.c over DuplicateHandle */ +int w32_fdpass_send(int sock, int fd); +int w32_fdpass_recv(int sock); + +int +mm_send_fd(int sock, int fd) +{ + return w32_fdpass_send(sock, fd); +} + +int +mm_receive_fd(int sock) +{ + return w32_fdpass_recv(sock); +} +#else /* !WINDOWS */ int mm_send_fd(int sock, int fd) { @@ -174,3 +191,4 @@ mm_receive_fd(int sock) return -1; #endif } +#endif /* !WINDOWS */ diff --git a/mux.c b/mux.c index 0cd169732cd3..ff1b2f1bf81f 100644 --- a/mux.c +++ b/mux.c @@ -443,8 +443,13 @@ mux_master_process_new_session(struct ssh *ssh, u_int rid, } /* Try to pick up ttymodes from client before it goes raw */ +#ifdef WINDOWS + /* no tcgetattr; tty sessions get default modes */ + memset(&cctx->tio, 0, sizeof(cctx->tio)); +#else if (cctx->want_tty && tcgetattr(new_fd[0], &cctx->tio) == -1) error_f("tcgetattr: %s", strerror(errno)); +#endif window = CHAN_SES_WINDOW_DEFAULT; packetmax = CHAN_SES_PACKET_DEFAULT; @@ -1319,10 +1324,12 @@ mux_tty_alloc_failed(struct ssh *ssh, Channel *c) void muxserver_listen(struct ssh *ssh) { +#ifndef WINDOWS mode_t old_umask; char *orig_control_path = options.control_path; char rbuf[16+1]; u_int i, r; +#endif int oerrno; if (options.control_path == NULL || @@ -1331,6 +1338,28 @@ muxserver_listen(struct ssh *ssh) debug("setting up multiplex master socket"); +#ifdef WINDOWS + /* + * ControlPath maps to a named pipe: there is no filesystem entry, so + * the umask/temp-path/link tricks below do not apply. Pipe name + * creation is atomic (FILE_FLAG_FIRST_PIPE_INSTANCE) and reports + * EADDRINUSE if the name is owned by another process. + */ + muxserver_sock = unix_listener(options.control_path, 64, 0); + if (muxserver_sock < 0) { + oerrno = errno; + if (oerrno == EINVAL || oerrno == EADDRINUSE) { + error("ControlSocket %s already exists, " + "disabling multiplexing", options.control_path); + free(options.control_path); + options.control_path = NULL; + options.control_master = SSHCTL_MASTER_NO; + return; + } + /* unix_listener() logs the error */ + cleanup_exit(255); + } +#else /* !WINDOWS */ /* * Use a temporary path before listen so we can pseudo-atomically * establish the listening socket in its final location to avoid @@ -1387,6 +1416,7 @@ muxserver_listen(struct ssh *ssh) unlink(options.control_path); free(options.control_path); options.control_path = orig_control_path; +#endif /* !WINDOWS */ set_nonblock(muxserver_sock); @@ -2379,6 +2409,19 @@ muxclient(const char *path) muxclient_command = SSHMUX_COMMAND_OPEN; } +#ifdef WINDOWS + /* + * tty sessions require the mux master to drive the client's console + * (raw mode, VT input translation, resize events), which is not + * implemented yet. Fall back to a separate connection. + */ + if (muxclient_command == SSHMUX_COMMAND_OPEN && tty_flag) { + debug("tty sessions are not yet supported over multiplexed " + "connections on Windows; opening a separate connection"); + return -1; + } +#endif + switch (options.control_master) { case SSHCTL_MASTER_AUTO: case SSHCTL_MASTER_AUTO_ASK: diff --git a/ssh.c b/ssh.c index 8eb338d3b938..bf05f0113768 100644 --- a/ssh.c +++ b/ssh.c @@ -1381,6 +1381,13 @@ main(int ac, char **av) strcmp(options.proxy_command, "-") == 0 && options.proxy_use_fdpass) fatal("ProxyCommand=- and ProxyUseFDPass are incompatible"); +#ifdef WINDOWS + /* ControlPersist requires fork(); the mux master must stay in foreground */ + if (options.control_persist) { + verbose("ControlPersist is not supported on Windows; disabling"); + options.control_persist = 0; + } +#endif if (options.update_hostkeys == SSH_UPDATE_HOSTKEYS_ASK) { if (options.control_persist && options.control_path != NULL) { debug("UpdateHostKeys=ask is incompatible with " From c1eca8a609e0815121d67ffa3736b596b69ee626 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 12 Jul 2026 13:49:11 +1000 Subject: [PATCH 02/14] Add Pester E2E tests for connection multiplexing Covers: master startup and -O check, remote command output and exit code propagation through the master, stdin pass-through, single shared TCP connection, -O forward with a live tunnel, tty fallback to a separate connection, second-master graceful degradation on a busy ControlPath, -O exit shutdown, and direct-connection fallback when no master is present (explicit ControlPath and ControlMaster=auto). Verified against Pester 3.4.0 (the version the E2E framework uses): 11/11 passing. Co-Authored-By: Claude Fable 5 --- regress/pesterTests/Multiplex.Tests.ps1 | 165 ++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 regress/pesterTests/Multiplex.Tests.ps1 diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 new file mode 100644 index 000000000000..69c3cd529282 --- /dev/null +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -0,0 +1,165 @@ +If ($PSVersiontable.PSVersion.Major -le 2) {$PSScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path} +Import-Module $PSScriptRoot\CommonUtils.psm1 -Force +$tC = 1 +$tI = 0 +$suite = "multiplex" + +Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" { + BeforeAll { + if($OpenSSHTestInfo -eq $null) + { + Throw "`$OpenSSHTestInfo is null. Please run Set-OpenSSHTestEnvironment to set test environments." + } + + $port = $OpenSSHTestInfo["Port"] + + $testDir = Join-Path $OpenSSHTestInfo["TestDataPath"] $suite + if(-not (Test-Path $testDir)) + { + $null = New-Item $testDir -ItemType directory -Force -ErrorAction SilentlyContinue + } + #skip on ps 2 becase non-interactive cmd require a ENTER before it returns on ps2 + $skip = $IsWindows -and ($PSVersionTable.PSVersion.Major -le 2) + + $controlPath = Join-Path $testDir "mux_ctl" + $sshExe = (Get-Command ssh).Source + $script:masterProc = $null + + function Start-MuxMaster + { + param([string]$MasterLog) + + $script:masterProc = Start-Process -FilePath $sshExe ` + -ArgumentList "-M", "-N", "-S", "`"$controlPath`"", "test_target" ` + -WindowStyle Hidden -RedirectStandardError $MasterLog -PassThru + + # wait until the master answers control requests + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) + { + ssh -S $controlPath -O check test_target 2>$null + if ($LASTEXITCODE -eq 0) { return $true } + if ($script:masterProc.HasExited) { return $false } + Start-Sleep -Milliseconds 500 + } + return $false + } + + function Stop-MuxMaster + { + if ($script:masterProc -eq $null) { return } + ssh -S $controlPath -O exit test_target 2>$null + if (-not $script:masterProc.WaitForExit(10000)) + { + Stop-Process -Id $script:masterProc.Id -Force -ErrorAction SilentlyContinue + } + $script:masterProc = $null + } + } + + AfterAll { + Stop-MuxMaster + } + + BeforeEach { + $stderrFile=Join-Path $testDir "$tC.$tI.stderr.txt" + $stdoutFile=Join-Path $testDir "$tC.$tI.stdout.txt" + $logFile = Join-Path $testDir "$tC.$tI.log.txt" + } + AfterEach {$tI++;} + + Context "$tC - mux master lifecycle and sessions" { + BeforeAll {$tI=1} + AfterAll{$tC++} + + It "$tC.$tI - master starts and answers -O check" -skip:$skip { + Start-MuxMaster -MasterLog $logFile | Should Be $true + iex "cmd /c `"ssh -S $controlPath -O check test_target 2> $stderrFile`"" + $LASTEXITCODE | Should Be 0 + $stderrFile | Should Contain "Master running" + } + + It "$tC.$tI - remote command through master returns output" -skip:$skip { + ssh -S $controlPath test_target echo mux-session-1234 | Set-Content $stdoutFile + $stdoutFile | Should Contain "mux-session-1234" + } + + It "$tC.$tI - exit codes propagate through master" -skip:$skip { + foreach ($i in (0,1,4,5,44)) { + ssh -S $controlPath test_target exit $i + $LASTEXITCODE | Should Be $i + } + } + + It "$tC.$tI - stdin passes through master" -skip:$skip { + iex "cmd /c `"echo mux-stdin-data | ssh -S $controlPath test_target findstr mux-stdin > $stdoutFile`"" + $stdoutFile | Should Contain "mux-stdin-data" + } + + It "$tC.$tI - sessions share the master's single TCP connection" -skip:$skip { + ssh -S $controlPath test_target echo again | Set-Content $stdoutFile + $stdoutFile | Should Contain "again" + $conns = @(Get-NetTCPConnection -OwningProcess $script:masterProc.Id -State Established -ErrorAction SilentlyContinue | Where-Object { $_.RemotePort -eq $port }) + $conns.Count | Should Be 1 + } + + It "$tC.$tI - -O forward adds a working local forwarding" -skip:$skip { + $fwdPort = 5433 + iex "cmd /c `"ssh -S $controlPath -O forward -L $($fwdPort):127.0.0.1:$port test_target 2> $stderrFile`"" + $LASTEXITCODE | Should Be 0 + # the tunnel targets the test sshd; reading its banner proves end-to-end flow + $client = New-Object System.Net.Sockets.TcpClient("127.0.0.1", $fwdPort) + $stream = $client.GetStream() + $stream.ReadTimeout = 10000 + $buf = New-Object byte[] 64 + $read = $stream.Read($buf, 0, 64) + $client.Close() + $banner = [System.Text.Encoding]::ASCII.GetString($buf, 0, $read) + $banner | Should Match "^SSH-2.0-" + } + + It "$tC.$tI - tty session falls back to a separate connection" -skip:$skip { + iex "cmd /c `"ssh -v -tt -S $controlPath test_target echo tty-fallback-ok > $stdoutFile 2> $stderrFile`"" + $stdoutFile | Should Contain "tty-fallback-ok" + $stderrFile | Should Contain "not yet supported over multiplexed" + } + + It "$tC.$tI - second master on the same ControlPath degrades gracefully" -skip:$skip { + # muxserver_listen() must detect the busy pipe, disable multiplexing + # for the second client and run its session over its own connection + iex "cmd /c `"ssh -M -S $controlPath test_target echo second-master-ok > $stdoutFile 2> $stderrFile`"" + $stdoutFile | Should Contain "second-master-ok" + $stderrFile | Should Contain "already exists, disabling multiplexing" + # first master is unaffected + ssh -S $controlPath -O check test_target 2>$null + $LASTEXITCODE | Should Be 0 + } + + It "$tC.$tI - -O exit shuts the master down" -skip:$skip { + iex "cmd /c `"ssh -S $controlPath -O exit test_target 2> $stderrFile`"" + $stderrFile | Should Contain "Exit request sent" + $script:masterProc.WaitForExit(10000) | Should Be $true + $script:masterProc = $null + # control requests must now fail + ssh -S $controlPath -O check test_target 2>$null + $LASTEXITCODE | Should Not Be 0 + } + } + + Context "$tC - graceful degradation without a master" { + BeforeAll {$tI=1} + AfterAll{$tC++} + + It "$tC.$tI - ControlPath with no master falls back to a direct connection" -skip:$skip { + ssh -S $controlPath test_target echo no-master-fallback | Set-Content $stdoutFile + $stdoutFile | Should Contain "no-master-fallback" + } + + It "$tC.$tI - ControlMaster auto without a master connects directly" -skip:$skip { + ssh -o ControlMaster=auto -o ControlPersist=no -S $controlPath test_target echo auto-ok | Set-Content $stdoutFile + $stdoutFile | Should Contain "auto-ok" + # ControlPersist is unsupported on Windows; nothing may linger + Stop-MuxMaster + } + } +} From 3db5ef2738372a01fa9c8df180c891c17c35c7e5 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 09:31:52 +1000 Subject: [PATCH 03/14] mux: master-side protocol for tty sessions over multiplexing (Windows) Adds the master-side half of client-side console relay support for multiplexed tty sessions on Windows (client half follows). Because Windows console handles are not usable across processes, the master cannot query a mux client's terminal size, so the client will send it explicitly; and old peers must not be disrupted. - Hello extension "tty-relay@win32.openssh.com" (provisional): the Windows master advertises it in its hello; a Windows client detects it. Unknown extensions are already ignored with a debug log on both sides, so this is non-breaking in every direction (verified against old/new client/master pairings). - MUX_C_WINSIZE (0x1000000e): client->master, {col,row,xpix,ypix}, no reply. mux_master_process_winsize() stashes the size in struct mux_master_state and, if a tty session channel is already open, forwards it as a "window-change" channel request. An unknown type on an old master yields MUX_S_FAILURE rather than a disconnect, and the client only sends this once the extension is negotiated. - client_session2_setup() gains a winsize parameter (NULL preserves the existing ioctl() behavior; POSIX callers pass NULL). On Windows the mux master passes the client-reported size, or zeros when none was received -- never falling back to ioctl(), which on Windows would return the master's own console size (w32_ioctl ignores the fd). - channel_send_window_changes() skips mux-owned channels (ctl_chan != -1) on Windows for the same reason: their rfd is a relay pipe and ioctl() would stamp the master's console size onto them. Their resizes arrive via MUX_C_WINSIZE instead. All additions are #ifdef WINDOWS or NULL-preserving; POSIX behavior is unchanged. Existing 11 mux Pester scenarios still pass. Co-Authored-By: Claude Fable 5 --- channels.c | 9 +++++ clientloop.c | 6 ++- clientloop.h | 4 +- mux.c | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++- ssh.c | 2 +- 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/channels.c b/channels.c index af6960f9f8bc..9996999579c1 100644 --- a/channels.c +++ b/channels.c @@ -4993,6 +4993,15 @@ channel_send_window_changes(struct ssh *ssh) if (sc->channels[i] == NULL || !sc->channels[i]->client_tty || sc->channels[i]->type != SSH_CHANNEL_OPEN) continue; +#ifdef WINDOWS + /* + * mux-owned sessions are resized via MUX_C_WINSIZE from the + * mux client; w32_ioctl() would report this process's own + * console size for their relay-pipe rfds, which is wrong. + */ + if (sc->channels[i]->ctl_chan != -1) + continue; +#endif if (ioctl(sc->channels[i]->rfd, TIOCGWINSZ, &ws) == -1) continue; channel_request_start(ssh, i, "window-change", 0); diff --git a/clientloop.c b/clientloop.c index dd74d47fdd99..db54cb8b4fc8 100644 --- a/clientloop.c +++ b/clientloop.c @@ -2717,7 +2717,7 @@ client_send_env(struct ssh *ssh, int id, const char *name, const char *val) void client_session2_setup(struct ssh *ssh, int id, int want_tty, int want_subsystem, const char *term, struct termios *tiop, int in_fd, struct sshbuf *cmd, - char **env) + char **env, const struct winsize *wsp) { size_t i, j, len; int matched, r; @@ -2733,7 +2733,9 @@ client_session2_setup(struct ssh *ssh, int id, int want_tty, int want_subsystem, struct winsize ws; /* Store window size in the packet. */ - if (ioctl(in_fd, TIOCGWINSZ, &ws) == -1) + if (wsp != NULL) + ws = *wsp; + else if (ioctl(in_fd, TIOCGWINSZ, &ws) == -1) memset(&ws, 0, sizeof(ws)); channel_request_start(ssh, id, "pty-req", 1); diff --git a/clientloop.h b/clientloop.h index ed3c54fa7239..87f5e85cf896 100644 --- a/clientloop.h +++ b/clientloop.h @@ -38,13 +38,15 @@ #include struct ssh; +struct winsize; /* Client side main loop for the interactive session. */ int client_loop(struct ssh *, int, int, int); int client_x11_get_proto(struct ssh *, const char *, const char *, u_int, u_int, char **, char **); void client_session2_setup(struct ssh *, int, int, int, - const char *, struct termios *, int, struct sshbuf *, char **); + const char *, struct termios *, int, struct sshbuf *, char **, + const struct winsize *); char *client_request_tun_fwd(struct ssh *, int, int, int, channel_open_fn *, void *); void client_stop_mux(void); diff --git a/mux.c b/mux.c index ff1b2f1bf81f..9376078aa387 100644 --- a/mux.c +++ b/mux.c @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -97,10 +98,18 @@ static volatile sig_atomic_t muxclient_terminate = 0; /* PID of multiplex server */ static u_int muxserver_pid = 0; +#ifdef WINDOWS +/* master advertised MUX_EXT_TTY_RELAY in its hello */ +static int muxclient_tty_relay = 0; +#endif + static Channel *mux_listener_channel = NULL; struct mux_master_state { int hello_rcvd; + /* last window size received from the mux client (MUX_C_WINSIZE) */ + struct winsize ws; + int ws_valid; }; /* mux protocol messages */ @@ -112,6 +121,7 @@ struct mux_master_state { #define MUX_C_CLOSE_FWD 0x10000007 #define MUX_C_NEW_STDIO_FWD 0x10000008 #define MUX_C_STOP_LISTENING 0x10000009 +#define MUX_C_WINSIZE 0x1000000e #define MUX_C_PROXY 0x1000000f #define MUX_C_EXT_INFO 0x20000001 #define MUX_S_OK 0x80000001 @@ -125,6 +135,14 @@ struct mux_master_state { #define MUX_S_PROXY 0x8000000f #define MUX_S_EXT_INFO 0x90000001 +/* + * Windows: hello extension advertised by masters that support tty sessions + * over multiplexed connections (client-side console relay + MUX_C_WINSIZE). + * Name is provisional pending maintainer review; the empty value is reserved + * for future versioning. + */ +#define MUX_EXT_TTY_RELAY "tty-relay@win32.openssh.com" + /* type codes for MUX_C_OPEN_FWD and MUX_C_CLOSE_FWD */ #define MUX_FWD_LOCAL 1 #define MUX_FWD_REMOTE 2 @@ -158,6 +176,10 @@ static int mux_master_process_proxy(struct ssh *, u_int, Channel *, struct sshbuf *, struct sshbuf *); static int mux_master_process_ext_info(struct ssh *, u_int, Channel *, struct sshbuf *, struct sshbuf *); +#ifdef WINDOWS +static int mux_master_process_winsize(struct ssh *, u_int, + Channel *, struct sshbuf *, struct sshbuf *); +#endif static const struct { u_int type; @@ -174,6 +196,9 @@ static const struct { { MUX_C_STOP_LISTENING, mux_master_process_stop_listening }, { MUX_C_PROXY, mux_master_process_proxy }, { MUX_C_EXT_INFO, mux_master_process_ext_info }, +#ifdef WINDOWS + { MUX_C_WINSIZE, mux_master_process_winsize }, +#endif { 0, NULL } }; @@ -542,6 +567,56 @@ mux_master_process_ext_info(struct ssh *ssh, u_int rid, return 0; } +#ifdef WINDOWS +/* + * MUX_C_WINSIZE: the mux client reports its terminal size. Windows masters + * cannot query the client's console themselves (console handles are not + * usable across processes), so the client sends the size explicitly: once + * before MUX_C_NEW_SESSION (the control channel is ordered, so this seeds + * the pty-req dimensions) and again on every local resize. No reply is sent. + */ +static int +mux_master_process_winsize(struct ssh *ssh, u_int rid, + Channel *c, struct sshbuf *m, struct sshbuf *reply) +{ + struct mux_master_state *state = (struct mux_master_state *)c->mux_ctx; + Channel *sc; + u_int col, row, xpix, ypix; + int r; + + if ((r = sshbuf_get_u32(m, &col)) != 0 || + (r = sshbuf_get_u32(m, &row)) != 0 || + (r = sshbuf_get_u32(m, &xpix)) != 0 || + (r = sshbuf_get_u32(m, &ypix)) != 0) { + error_f("malformed message"); + return -1; + } + + debug2_f("channel %d: winsize %ux%u", c->self, col, row); + + state->ws.ws_col = col; + state->ws.ws_row = row; + state->ws.ws_xpixel = xpix; + state->ws.ws_ypixel = ypix; + state->ws_valid = 1; + + /* forward to an established session as a window-change request */ + if (c->have_ctl_child_id && + (sc = channel_by_id(ssh, c->ctl_child_id)) != NULL && + sc->client_tty && sc->type == SSH_CHANNEL_OPEN) { + channel_request_start(ssh, sc->self, "window-change", 0); + if ((r = sshpkt_put_u32(ssh, col)) != 0 || + (r = sshpkt_put_u32(ssh, row)) != 0 || + (r = sshpkt_put_u32(ssh, xpix)) != 0 || + (r = sshpkt_put_u32(ssh, ypix)) != 0 || + (r = sshpkt_send(ssh)) != 0) + fatal_fr(r, "channel %u: send window-change", sc->self); + } + + return 0; +} +#endif /* WINDOWS */ + static int mux_master_process_terminate(struct ssh *ssh, u_int rid, Channel *c, struct sshbuf *m, struct sshbuf *reply) @@ -1222,6 +1297,12 @@ mux_master_read_cb(struct ssh *ssh, Channel *c) if ((r = sshbuf_put_cstring(out, "info")) != 0 || (r = sshbuf_put_cstring(out, "0")) != 0) fatal_fr(r, "put info extension"); +#ifdef WINDOWS + /* advertise tty session support (client-side console relay) */ + if ((r = sshbuf_put_cstring(out, MUX_EXT_TTY_RELAY)) != 0 || + (r = sshbuf_put_string(out, NULL, 0)) != 0) + fatal_fr(r, "reply extension"); +#endif if ((r = sshbuf_put_stringb(c->output, out)) != 0) fatal_fr(r, "enqueue"); debug3_f("channel %d: hello sent", c->self); @@ -1478,8 +1559,27 @@ mux_session_confirm(struct ssh *ssh, int id, int success, void *arg) if (cctx->want_agent_fwd && options.forward_agent) client_channel_reqest_agent_forwarding(ssh, id); +#ifdef WINDOWS + { + /* + * The client's console size arrives via MUX_C_WINSIZE (sent + * before the session request); rfd is a relay pipe here, and + * falling back to ioctl() would leak the master's own console + * size into the pty-req, so pass zeros when no size was seen. + */ + static const struct winsize zws; + struct mux_master_state *state = + (struct mux_master_state *)cc->mux_ctx; + + client_session2_setup(ssh, id, cctx->want_tty, + cctx->want_subsys, cctx->term, &cctx->tio, c->rfd, + cctx->cmd, cctx->env, + (state != NULL && state->ws_valid) ? &state->ws : &zws); + } +#else client_session2_setup(ssh, id, cctx->want_tty, cctx->want_subsys, - cctx->term, &cctx->tio, c->rfd, cctx->cmd, cctx->env); + cctx->term, &cctx->tio, c->rfd, cctx->cmd, cctx->env, NULL); +#endif debug3_f("sending success reply"); /* prepare reply */ @@ -1717,6 +1817,14 @@ mux_client_hello_exchange(int fd, int timeout_ms) error_fr(r, "parse extension"); goto out; } +#ifdef WINDOWS + if (strcmp(name, MUX_EXT_TTY_RELAY) == 0) { + debug2("master supports tty sessions over mux"); + muxclient_tty_relay = 1; + free(name); + continue; + } +#endif /* Process extensions. */ if (strcmp(name, "info") == 0) { debug("Received 'info' extension"); diff --git a/ssh.c b/ssh.c index bf05f0113768..b0c2f7d509ff 100644 --- a/ssh.c +++ b/ssh.c @@ -2226,7 +2226,7 @@ ssh_session2_setup(struct ssh *ssh, int id, int success, void *arg) term = getenv("TERM"); client_session2_setup(ssh, id, tty_flag, options.session_type == SESSION_TYPE_SUBSYSTEM, term, - NULL, fileno(stdin), command, environ); + NULL, fileno(stdin), command, environ, NULL); } /* open new channel for a session */ From d81bdf3dd6a6d5f3c5c14371dfdf2d4daafc9379 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 14:48:02 +1000 Subject: [PATCH 04/14] mux: client-side console relay for tty sessions over multiplexing (Windows) Implements the client half of multiplexed tty sessions on Windows. Because a Windows console handle is bound to its owning process's console and cannot be driven by the master across the process boundary, the mux client no longer passes console handles to the master. Instead, for each std fd that is a console, it substitutes a pipe (which the master drives exactly like redirected stdio) and pumps bytes between its own console and that pipe itself, reusing the same in-process terminal emulation a non-mux ssh already uses. - mux_relay_prepare/pump/close: for each console std fd, create a pipe, hand the master-facing end to mm_send_fd() in place of the real fd, and run a poll() loop (finite timeout, since SIGWINCH does not wake poll on Windows) moving console<->pipe bytes while still handling the control-channel packets (MUX_S_TTY_ALLOC_FAIL / MUX_S_EXIT_MESSAGE) and draining remote output before exit. Applies to both session and stdio-forward requests, and to non-tty sessions whose stdio is a console (the master's cross-process console I/O is equally broken there). With redirected stdio (e.g. CI) no fd is a console, the relay stays inactive, and behavior is byte-identical to before. - The passed pipe ends are held open until MUX_S_SESSION_OPENED, after which the master's duplicated handles keep them alive; failure paths close everything and leave the real std fds untouched so the caller can still fall back to a direct connection. - SIGWINCH is caught locally (never relayed via kill(), which w32_kill() would turn into TerminateProcess of a tracked child) and sent to the master as MUX_C_WINSIZE; an initial size is sent before the session request to seed the pty-req. - The pre-connect Windows tty fallback moves to after the hello exchange: tty sessions multiplex when the master advertised the tty-relay extension, and fall back to a separate connection otherwise, so old masters keep working. Verified on Windows 11 x64 (nested conpty): keystroke round-trip, initial window size matching the outer pty, Ctrl+C interrupting a remote command, and clean drain + "Shared connection closed" when the master is killed mid-session. POSIX and the existing 11 mux Pester scenarios are unaffected (relay is #ifdef WINDOWS and inactive without a console). Co-Authored-By: Claude Fable 5 --- mux.c | 533 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 513 insertions(+), 20 deletions(-) diff --git a/mux.c b/mux.c index 9376078aa387..9a1e389fdcc1 100644 --- a/mux.c +++ b/mux.c @@ -1767,6 +1767,392 @@ mux_client_read_packet(int fd, struct sshbuf *m) return mux_client_read_packet_timeout(fd, m, -1); } +#ifdef WINDOWS +/* + * Windows console relay for multiplexed passenger sessions. + * + * On POSIX the mux client passes its stdio file descriptors to the master + * via SCM_RIGHTS and the master does all the terminal I/O. On Windows, + * console handles are bound to the owning process's console and cannot be + * driven by the master across the process boundary. So when a std fd is a + * console, the client substitutes a pipe (which the master CAN drive, just + * like redirected stdio) and pumps bytes between its own console and that + * pipe itself, reusing the same in-process terminal emulation a non-mux + * ssh already uses. Window-size changes travel over the control channel as + * MUX_C_WINSIZE (see mux_master_process_winsize) instead of via ioctl on + * the master, and instead of relaying SIGWINCH with kill() (which on + * Windows would terminate the master process). + * + * Protocol additions (Windows only; see PROTOCOL.mux for the base + * protocol): + * + * 1. Masters advertise the hello extension "tty-relay@win32.openssh.com" + * with an empty value (reserved for future versioning). Peers that do + * not recognise the extension ignore it, per PROTOCOL.mux. + * + * 2. A client that saw the extension may send, at any time after the + * hello: + * uint32 MUX_C_WINSIZE + * uint32 request id + * uint32 columns + * uint32 rows + * uint32 x pixels + * uint32 y pixels + * No reply is sent and the request id is not consumed. Because the + * control channel is ordered, a MUX_C_WINSIZE sent before + * MUX_C_NEW_SESSION seeds the dimensions used in the session's pty-req; + * later messages become "window-change" channel requests. + * + * 3. A client that wants a tty but did not see the extension MUST NOT + * open a session over the multiplexed connection; it falls back to a + * separate direct connection instead (see muxclient()). + */ + +#define MUX_RELAY_BUF 8192 + +struct mux_relay_ent { + int is_console; /* this std fd was a console -> relayed */ + int is_input; /* 1 = console->master (stdin); 0 = ->console */ + int console_fd; /* the real console std fd (0/1/2), not owned */ + int local_pipe; /* our end of the substitute pipe (owned) */ + int passed; /* pipe end handed to the master (-1 if none) */ + int rd_done; /* source (console or pipe) hit EOF */ + int pipe_closed; /* local_pipe has been closed */ + char buf[MUX_RELAY_BUF]; + size_t buf_len; +}; + +struct mux_relay { + int active; /* at least one console fd is being relayed */ + struct mux_relay_ent ent[3]; /* indexed by std fd (0,1,2) */ + int nent; +}; + +#define MUX_RELAY_RD(e) ((e)->is_input ? (e)->console_fd : (e)->local_pipe) +#define MUX_RELAY_WR(e) ((e)->is_input ? (e)->local_pipe : (e)->console_fd) + +static void mux_relay_close_all(struct mux_relay *r); + +/* Set when a SIGWINCH is caught; drained by the pump loop. */ +static volatile sig_atomic_t muxclient_winch = 0; + +static void +control_client_sigwinch(int signo) +{ + (void)signo; + muxclient_winch = 1; +} + +/* Send the local console size to the master. Silent no-op without a tty. */ +static void +mux_client_send_winsize(int fd) +{ + struct sshbuf *m; + struct winsize ws; + int r; + + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1) + return; + if ((m = sshbuf_new()) == NULL) + fatal_f("sshbuf_new"); + /* no reply is sent for WINSIZE, so the request id is not consumed */ + if ((r = sshbuf_put_u32(m, MUX_C_WINSIZE)) != 0 || + (r = sshbuf_put_u32(m, muxclient_request_id)) != 0 || + (r = sshbuf_put_u32(m, (u_int)ws.ws_col)) != 0 || + (r = sshbuf_put_u32(m, (u_int)ws.ws_row)) != 0 || + (r = sshbuf_put_u32(m, (u_int)ws.ws_xpixel)) != 0 || + (r = sshbuf_put_u32(m, (u_int)ws.ws_ypixel)) != 0) + fatal_fr(r, "assemble winsize"); + if (mux_client_write_packet(fd, m) != 0) + debug_f("write winsize: %s", strerror(errno)); + sshbuf_free(m); +} + +/* + * Prepare the relay for a passenger session with nfds std fds (3 for a + * normal session, 2 for stdio forwarding). For each fd that is a console, + * a pipe is created and its master-facing end is recorded in passed_fd[i] + * so the caller sends that instead of the real fd. Returns 0 on success + * (relay may be inactive if no fd was a console), -1 on error with all + * pipes closed. Real std fds are never modified, so callers can still fall + * back to a direct connection. + */ +static int +mux_relay_prepare(struct mux_relay *r, int nfds, int passed_fd[3]) +{ + int i, p[2]; + + memset(r, 0, sizeof(*r)); + r->nent = nfds; + for (i = 0; i < nfds; i++) { + r->ent[i].passed = -1; + passed_fd[i] = i; /* default: pass the real fd */ + } + + if (!muxclient_tty_relay) + return 0; + + for (i = 0; i < nfds; i++) { + struct mux_relay_ent *e = &r->ent[i]; + + if (!isatty(i)) + continue; + if (pipe(p) == -1) { + error_f("pipe: %s", strerror(errno)); + mux_relay_close_all(r); + return -1; + } + e->is_console = 1; + e->console_fd = i; + if (i == STDIN_FILENO) { + /* console -> master: master reads the pipe read end */ + e->is_input = 1; + e->local_pipe = p[1]; /* we write */ + e->passed = p[0]; /* master reads */ + } else { + /* master -> console: master writes the pipe write end */ + e->is_input = 0; + e->local_pipe = p[0]; /* we read */ + e->passed = p[1]; /* master writes */ + } + passed_fd[i] = e->passed; + r->active = 1; + (void)fcntl(e->local_pipe, F_SETFL, O_NONBLOCK); + (void)fcntl(e->console_fd, F_SETFL, O_NONBLOCK); + } + return 0; +} + +/* Close the pipe ends handed to the master (call after the reply arrives). */ +static void +mux_relay_close_passed(struct mux_relay *r) +{ + int i; + + for (i = 0; i < r->nent; i++) { + if (r->ent[i].is_console && r->ent[i].passed != -1) { + close(r->ent[i].passed); + r->ent[i].passed = -1; + } + } +} + +/* Close every fd the relay still owns (pipe ends only; never the console). */ +static void +mux_relay_close_all(struct mux_relay *r) +{ + int i; + + for (i = 0; i < r->nent; i++) { + struct mux_relay_ent *e = &r->ent[i]; + if (!e->is_console) + continue; + if (e->passed != -1) { + close(e->passed); + e->passed = -1; + } + if (!e->pipe_closed) { + close(e->local_pipe); + e->pipe_closed = 1; + } + } + memset(r, 0, sizeof(*r)); +} + +/* Read from the source fd into the buffer if there is room. */ +static void +mux_relay_fill(struct mux_relay_ent *e) +{ + ssize_t n; + + if (e->rd_done || e->buf_len == sizeof(e->buf)) + return; + n = read(MUX_RELAY_RD(e), e->buf + e->buf_len, + sizeof(e->buf) - e->buf_len); + if (n > 0) + e->buf_len += n; + else if (n == 0) + e->rd_done = 1; /* EOF */ + else if (errno != EAGAIN && errno != EINTR) + e->rd_done = 1; /* broken pipe counts as EOF */ +} + +/* Write buffered bytes out to the destination fd. */ +static void +mux_relay_flush(struct mux_relay_ent *e) +{ + ssize_t n; + + while (e->buf_len > 0) { + n = write(MUX_RELAY_WR(e), e->buf, e->buf_len); + if (n > 0) { + memmove(e->buf, e->buf + n, e->buf_len - n); + e->buf_len -= n; + continue; + } + if (n == -1 && (errno == EAGAIN || errno == EINTR)) + return; /* retry next poll */ + /* destination gone: drop this direction */ + e->rd_done = 1; + e->buf_len = 0; + return; + } +} + +/* + * Run the passenger session, moving console<->pipe bytes while watching the + * control fd for tty-alloc-fail / exit-message / EOF. Returns when the + * master closes the control fd (session over). Mirrors the control-packet + * handling of the POSIX SCM_RIGHTS wait-loop. + */ +static void +mux_relay_pump(int fd, struct mux_relay *r, u_int sid, + u_int *exitval, int *exitval_seen, int *rawmode) +{ + struct sshbuf *m; + struct pollfd pfd[7]; + u_int type, esid; + int i, r2, ctl_idx, draining = 0; + int rd_idx[3], wr_idx[3]; + char *e; + time_t drain_deadline = 0; + + if ((m = sshbuf_new()) == NULL) + fatal_f("sshbuf_new"); + + for (;;) { + int nfds = 0; + + if (muxclient_terminate) + break; + if (muxclient_winch) { + muxclient_winch = 0; + if (tty_flag) + mux_client_send_winsize(fd); + } + + ctl_idx = -1; + if (!draining) { + ctl_idx = nfds; + pfd[nfds].fd = fd; + pfd[nfds].events = POLLIN; + pfd[nfds].revents = 0; + nfds++; + } + + for (i = 0; i < r->nent; i++) { + struct mux_relay_ent *ent = &r->ent[i]; + + rd_idx[i] = wr_idx[i] = -1; + if (!ent->is_console) + continue; + /* in the drain phase stop reading local console input */ + if (!(draining && ent->is_input) && !ent->rd_done && + ent->buf_len < sizeof(ent->buf)) { + rd_idx[i] = nfds; + pfd[nfds].fd = MUX_RELAY_RD(ent); + pfd[nfds].events = POLLIN; + pfd[nfds].revents = 0; + nfds++; + } + if (ent->buf_len > 0) { + wr_idx[i] = nfds; + pfd[nfds].fd = MUX_RELAY_WR(ent); + pfd[nfds].events = POLLOUT; + pfd[nfds].revents = 0; + nfds++; + } + } + + (void)poll(pfd, nfds, 200); /* finite: SIGWINCH won't wake it */ + + /* move data in both directions */ + for (i = 0; i < r->nent; i++) { + struct mux_relay_ent *ent = &r->ent[i]; + + if (!ent->is_console) + continue; + if (rd_idx[i] != -1 && + (pfd[rd_idx[i]].revents & (POLLIN | POLLHUP))) + mux_relay_fill(ent); + if (ent->buf_len > 0) + mux_relay_flush(ent); + /* propagate console EOF to the master by closing the pipe */ + if (ent->is_input && ent->rd_done && ent->buf_len == 0 && + !ent->pipe_closed) { + close(ent->local_pipe); + ent->pipe_closed = 1; + } + } + + /* control channel */ + if (ctl_idx != -1 && + (pfd[ctl_idx].revents & (POLLIN | POLLHUP))) { + sshbuf_reset(m); + if (mux_client_read_packet(fd, m) != 0) { + /* master closed the control fd: drain output */ + draining = 1; + drain_deadline = monotime() + 5; + } else { + if ((r2 = sshbuf_get_u32(m, &type)) != 0) + fatal_fr(r2, "parse type"); + switch (type) { + case MUX_S_TTY_ALLOC_FAIL: + if ((r2 = sshbuf_get_u32(m, &esid)) != 0) + fatal_fr(r2, "parse session ID"); + if (esid != sid) + fatal_f("tty alloc fail on unknown " + "session: my id %u theirs %u", + sid, esid); + leave_raw_mode(options.request_tty == + REQUEST_TTY_FORCE); + *rawmode = 0; + break; + case MUX_S_EXIT_MESSAGE: + if ((r2 = sshbuf_get_u32(m, &esid)) != 0) + fatal_fr(r2, "parse session ID"); + if (esid != sid) + fatal_f("exit on unknown session: " + "my id %u theirs %u", sid, esid); + if (*exitval_seen) + fatal_f("exitval sent twice"); + if ((r2 = sshbuf_get_u32(m, exitval)) != 0) + fatal_fr(r2, "parse exitval"); + *exitval_seen = 1; + break; + default: + if ((r2 = sshbuf_get_cstring(m, &e, + NULL)) != 0) + fatal_fr(r2, "parse error message"); + if (*rawmode) + leave_raw_mode(options.request_tty + == REQUEST_TTY_FORCE); + fatal_f("master returned error: %s", e); + } + } + } + + /* drain phase: leave once output is flushed or the cap is hit */ + if (draining) { + int pending = 0; + + for (i = STDOUT_FILENO; + i <= STDERR_FILENO && i < r->nent; i++) { + struct mux_relay_ent *ent = &r->ent[i]; + if (ent->is_console && + (!ent->rd_done || ent->buf_len > 0)) + pending = 1; + } + if (!pending || monotime() >= drain_deadline) + break; + } + } + + sshbuf_free(m); +} + +#endif /* WINDOWS */ + static int mux_client_hello_exchange(int fd, int timeout_ms) { @@ -2128,6 +2514,10 @@ mux_client_request_session(int fd) u_int i, echar, rid, sid, esid, exitval, type, exitval_seen; extern char **environ; int r, rawmode = 0; +#ifdef WINDOWS + struct mux_relay relay; + int passed_fd[3]; +#endif debug3_f("entering"); @@ -2149,6 +2539,21 @@ mux_client_request_session(int fd) if (options.escape_char != SSH_ESCAPECHAR_NONE) echar = (u_int)options.escape_char; +#ifdef WINDOWS + /* substitute pipes for console std fds (see relay comment above) */ + if (mux_relay_prepare(&relay, 3, passed_fd) == -1) { + error_f("cannot set up console relay"); + return -1; + } + /* + * Seed the master with our terminal size before the session request: + * the control channel is ordered, and the master cannot query our + * console itself. + */ + if (tty_flag && muxclient_tty_relay) + mux_client_send_winsize(fd); +#endif + if ((m = sshbuf_new()) == NULL) fatal_f("sshbuf_new"); if ((r = sshbuf_put_u32(m, MUX_C_NEW_SESSION)) != 0 || @@ -2181,10 +2586,18 @@ mux_client_request_session(int fd) fatal_f("write packet: %s", strerror(errno)); /* Send the stdio file descriptors */ +#ifdef WINDOWS + /* console fds were substituted with relay pipe ends */ + if (mm_send_fd(fd, passed_fd[0]) == -1 || + mm_send_fd(fd, passed_fd[1]) == -1 || + mm_send_fd(fd, passed_fd[2]) == -1) + fatal_f("send fds failed"); +#else if (mm_send_fd(fd, STDIN_FILENO) == -1 || mm_send_fd(fd, STDOUT_FILENO) == -1 || mm_send_fd(fd, STDERR_FILENO) == -1) fatal_f("send fds failed"); +#endif debug3_f("session request sent"); @@ -2193,7 +2606,7 @@ mux_client_request_session(int fd) if (mux_client_read_packet(fd, m) != 0) { error_f("read from master failed: %s", strerror(errno)); sshbuf_free(m); - return -1; + goto fail; } if ((r = sshbuf_get_u32(m, &type)) != 0 || @@ -2214,20 +2627,29 @@ mux_client_request_session(int fd) fatal_fr(r, "parse error message"); error("Master refused session request: %s", e); sshbuf_free(m); - return -1; + goto fail; case MUX_S_FAILURE: if ((r = sshbuf_get_cstring(m, &e, NULL)) != 0) fatal_fr(r, "parse error message"); error_f("session request failed: %s", e); sshbuf_free(m); - return -1; + goto fail; default: sshbuf_free(m); error_f("unexpected response from master 0x%08x", type); - return -1; + goto fail; } muxclient_request_id++; +#ifdef WINDOWS + /* + * The master duplicated the passed handles while processing the + * request, strictly before its reply, so our copies of the passed + * pipe ends can (and must) be dropped now. + */ + mux_relay_close_passed(&relay); +#endif + if (pledge("stdio proc tty", NULL) == -1) fatal_f("pledge(): %s", strerror(errno)); platform_pledge_mux(); @@ -2235,7 +2657,16 @@ mux_client_request_session(int fd) ssh_signal(SIGHUP, control_client_sighandler); ssh_signal(SIGINT, control_client_sighandler); ssh_signal(SIGTERM, control_client_sighandler); +#ifdef WINDOWS + /* + * Do not relay SIGWINCH via kill(): w32_kill() terminates a tracked + * child process for any signal. Resizes are detected locally and + * sent to the master as MUX_C_WINSIZE by the relay pump instead. + */ + ssh_signal(SIGWINCH, control_client_sigwinch); +#else ssh_signal(SIGWINCH, control_client_sigrelay); +#endif if (options.fork_after_authentication) daemon(1, 1); @@ -2254,7 +2685,18 @@ mux_client_request_session(int fd) * the client_fd; if this one closes early, the multiplex master will * terminate early too (possibly losing data). */ - for (exitval = 255, exitval_seen = 0;;) { + exitval = 255; + exitval_seen = 0; +#ifdef WINDOWS + if (relay.active) { + int eseen = 0; + + mux_relay_pump(fd, &relay, sid, &exitval, &eseen, &rawmode); + exitval_seen = (u_int)eseen; + mux_relay_close_all(&relay); + } else +#endif + for (;;) { sshbuf_reset(m); if (mux_client_read_packet(fd, m) != 0) break; @@ -2307,6 +2749,12 @@ mux_client_request_session(int fd) fprintf(stderr, "Shared connection to %s closed.\r\n", host); exit(exitval); + + fail: +#ifdef WINDOWS + mux_relay_close_all(&relay); +#endif + return -1; } static int @@ -2357,6 +2805,10 @@ mux_client_request_stdio_fwd(int fd) char *e; u_int type, rid, sid; int r; +#ifdef WINDOWS + struct mux_relay relay; + int passed_fd[3]; +#endif debug3_f("entering"); @@ -2370,6 +2822,14 @@ mux_client_request_stdio_fwd(int fd) if (options.stdin_null && stdfd_devnull(1, 0, 0) == -1) fatal_f("stdfd_devnull failed"); +#ifdef WINDOWS + /* substitute pipes for console std fds (see relay comment above) */ + if (mux_relay_prepare(&relay, 2, passed_fd) == -1) { + error_f("cannot set up console relay"); + return -1; + } +#endif + if ((m = sshbuf_new()) == NULL) fatal_f("sshbuf_new"); if ((r = sshbuf_put_u32(m, MUX_C_NEW_STDIO_FWD)) != 0 || @@ -2383,9 +2843,15 @@ mux_client_request_stdio_fwd(int fd) fatal_f("write packet: %s", strerror(errno)); /* Send the stdio file descriptors */ +#ifdef WINDOWS + if (mm_send_fd(fd, passed_fd[0]) == -1 || + mm_send_fd(fd, passed_fd[1]) == -1) + fatal_f("send fds failed"); +#else if (mm_send_fd(fd, STDIN_FILENO) == -1 || mm_send_fd(fd, STDOUT_FILENO) == -1) fatal_f("send fds failed"); +#endif if (pledge("stdio proc tty", NULL) == -1) fatal_f("pledge(): %s", strerror(errno)); @@ -2399,7 +2865,7 @@ mux_client_request_stdio_fwd(int fd) if (mux_client_read_packet(fd, m) != 0) { error_f("read from master failed: %s", strerror(errno)); sshbuf_free(m); - return -1; + goto fail; } if ((r = sshbuf_get_u32(m, &type)) != 0 || @@ -2427,18 +2893,37 @@ mux_client_request_stdio_fwd(int fd) default: sshbuf_free(m); error_f("unexpected response from master 0x%08x", type); - return -1; + goto fail; } muxclient_request_id++; +#ifdef WINDOWS + /* the master duplicated the handles before replying; drop our copies */ + mux_relay_close_passed(&relay); +#endif + ssh_signal(SIGHUP, control_client_sighandler); ssh_signal(SIGINT, control_client_sighandler); ssh_signal(SIGTERM, control_client_sighandler); +#ifndef WINDOWS + /* not on Windows: w32_kill() would terminate a tracked child */ ssh_signal(SIGWINCH, control_client_sigrelay); +#endif /* * Stick around until the controlee closes the client_fd. */ +#ifdef WINDOWS + if (relay.active) { + u_int exitval = 0; + int exitval_seen = 0, rawmode = 0; + + mux_relay_pump(fd, &relay, sid, &exitval, &exitval_seen, + &rawmode); + mux_relay_close_all(&relay); + return 0; + } +#endif sshbuf_reset(m); if (mux_client_read_packet(fd, m) != 0) { if (errno == EPIPE || @@ -2449,6 +2934,12 @@ mux_client_request_stdio_fwd(int fd) fatal_f("mux_client_read_packet: %s", strerror(errno)); } fatal_f("master returned unexpected message %u", type); + + fail: +#ifdef WINDOWS + mux_relay_close_all(&relay); +#endif + return -1; } static void @@ -2517,19 +3008,6 @@ muxclient(const char *path) muxclient_command = SSHMUX_COMMAND_OPEN; } -#ifdef WINDOWS - /* - * tty sessions require the mux master to drive the client's console - * (raw mode, VT input translation, resize events), which is not - * implemented yet. Fall back to a separate connection. - */ - if (muxclient_command == SSHMUX_COMMAND_OPEN && tty_flag) { - debug("tty sessions are not yet supported over multiplexed " - "connections on Windows; opening a separate connection"); - return -1; - } -#endif - switch (options.control_master) { case SSHCTL_MASTER_AUTO: case SSHCTL_MASTER_AUTO_ASK: @@ -2586,6 +3064,21 @@ muxclient(const char *path) return -1; } +#ifdef WINDOWS + /* + * tty sessions need the console relay (see above), which requires a + * master that understands MUX_C_WINSIZE. Fall back to a separate + * connection when talking to a master that doesn't advertise it. + */ + if (muxclient_command == SSHMUX_COMMAND_OPEN && tty_flag && + !muxclient_tty_relay) { + debug("master does not support tty sessions over multiplexed " + "connections; opening a separate connection"); + close(sock); + return -1; + } +#endif + switch (muxclient_command) { case SSHMUX_COMMAND_ALIVE_CHECK: if ((pid = mux_client_request_alive(sock)) == 0) From 737346b12f6391383dc46910e50dc3f0b29e4d2f Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 14:49:38 +1000 Subject: [PATCH 05/14] tests: assert tty sessions multiplex instead of falling back The mux client now runs tty sessions over the master via the console relay, so the Pester scenario that asserted a fallback to a separate connection is updated to assert multiplexing: the session gets a master session id and no fallback message is emitted. Exit-code propagation through a tty is a pre-existing Windows conpty limitation (a direct -tt connection behaves the same), so it stays covered by the existing non-tty exit-code scenario. Console-relay keystroke/resize behavior is verified manually (nested conpty) and documented in the PR. Co-Authored-By: Claude Fable 5 --- regress/pesterTests/Multiplex.Tests.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 index 69c3cd529282..3aaff5b3345f 100644 --- a/regress/pesterTests/Multiplex.Tests.ps1 +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -118,10 +118,16 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" $banner | Should Match "^SSH-2.0-" } - It "$tC.$tI - tty session falls back to a separate connection" -skip:$skip { - iex "cmd /c `"ssh -v -tt -S $controlPath test_target echo tty-fallback-ok > $stdoutFile 2> $stderrFile`"" - $stdoutFile | Should Contain "tty-fallback-ok" - $stderrFile | Should Contain "not yet supported over multiplexed" + It "$tC.$tI - tty session multiplexes through the master" -skip:$skip { + # -tt forces a pty; on Windows this now runs over the master + # (client-side console relay) instead of a separate connection. + # With redirected (pipe) stdio the relay is inert but the session + # still multiplexes: a master session id is assigned and no + # fallback message is emitted. + iex "cmd /c `"ssh -v -tt -S $controlPath test_target echo tty-mux-ok > $stdoutFile 2> $stderrFile`"" + $stdoutFile | Should Contain "tty-mux-ok" + $stderrFile | Should Contain "master session id" + $stderrFile | Should Not Contain "opening a separate connection" } It "$tC.$tI - second master on the same ControlPath degrades gracefully" -skip:$skip { From fa3d56a11407af5404dacc6fb7d3ce4ba907ea7f Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 08:46:01 +1000 Subject: [PATCH 06/14] ssh: ControlPersist support on Windows via an auto-spawned master Windows has no fork(), so ssh cannot background an already-authenticated connection the way POSIX ControlPersist does (control_persist_detach()). A live SSH transport also cannot be handed to another process. So on Windows ControlPersist is implemented by auto-starting a *separate* master process: - When the user wants a persistent master (ControlMaster auto + ControlPersist) and none is running, ssh_controlpersist_spawn() launches a fresh ssh process from saved_av plus overrides (-oControlMaster=yes -oSessionType=none) marked with the environment variable SSH_CONTROLPERSIST_MASTER. The child shares this process's console so it can prompt for authentication. The foreground process then waits for the child's control socket to appear and connects to it as an ordinary mux client (interactive tty sessions included, via the console relay). If the child cannot be established, ssh falls back to a direct, non-persistent connection. - The spawned master authenticates itself, sets up the control socket in ssh_session2(), then detaches from the shared console (w32_detach_console -> FreeConsole) so it survives the terminal and does not contend with the foreground client. Its lifetime is governed by the existing ControlPersist idle timeout (set_control_persist_exit_ time), which needs no fork and works unchanged. - The POSIX fork-based backgrounding block in ssh_session2() is compiled out on Windows; the blanket "ControlPersist is not supported" disable is removed. New Windows compat helpers (misc.c): w32_spawn_control_master (spawn sharing the console, not tracked as a child so it outlives us), w32_process_alive, w32_close_handle, w32_is_controlpersist_master, w32_detach_console. Verified on Windows 11 x64 (key auth): first connection auto-starts a master and returns promptly while the master persists; later connections reuse it over a single TCP connection; -O check / -O exit work; interactive tty multiplexes through the persistent master; the master self-exits after the ControlPersist idle timeout; and an unreachable target falls back cleanly to a direct connection without hanging. POSIX is unaffected (all additions are #ifdef WINDOWS). Co-Authored-By: Claude Fable 5 --- contrib/win32/win32compat/misc.c | 112 ++++++++++++++++++++++++++++++ ssh.c | 114 +++++++++++++++++++++++++++++-- 2 files changed, 219 insertions(+), 7 deletions(-) diff --git a/contrib/win32/win32compat/misc.c b/contrib/win32/win32compat/misc.c index 7e8f01b286ae..d86b24dc903a 100644 --- a/contrib/win32/win32compat/misc.c +++ b/contrib/win32/win32compat/misc.c @@ -481,6 +481,118 @@ daemon(int nochdir, int noclose) return 0; } +/* + * ssh ControlPersist support (Windows). Windows has no fork(), so a + * persistent mux master cannot be produced by backgrounding an + * already-authenticated connection the way POSIX does. Instead the + * foreground process spawns a fresh ssh process to act as the master; that + * process authenticates itself (sharing this console so it can prompt) and + * then detaches. These helpers implement the spawn / liveness / detach + * primitives; the policy lives in ssh.c. + */ + +/* environment marker identifying the spawned persistent master process */ +#define W32_CONTROLPERSIST_ENV "SSH_CONTROLPERSIST_MASTER" + +/* + * Spawn a detached background ssh process (this same executable) with the + * given arguments to act as a persistent mux master. The child shares this + * process's console so it can prompt for authentication, and is deliberately + * NOT registered as a tracked child, so it survives after this process + * exits. Returns the child process HANDLE as intptr_t (caller CloseHandle), + * or -1 on failure. + */ +intptr_t +w32_spawn_control_master(char *const args[]) +{ + wchar_t exe_w[MAX_PATH]; + char *exe = NULL, *cmdline = NULL; + wchar_t *cmdline_w = NULL; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + intptr_t ret = -1; + + if (GetModuleFileNameW(NULL, exe_w, MAX_PATH) == 0) { + error("%s: GetModuleFileName failed: %d", __func__, GetLastError()); + return -1; + } + if ((exe = utf16_to_utf8(exe_w)) == NULL) { + errno = ENOMEM; + return -1; + } + /* build "" ; exe path is absolute so no module prepend */ + if ((cmdline = build_commandline_string(exe, args, FALSE)) == NULL) + goto done; + if ((cmdline_w = utf8_to_utf16(cmdline)) == NULL) { + errno = ENOMEM; + goto done; + } + + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + + /* the child reads this marker to learn it is the persistent master */ + SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, L"1"); + /* + * No CREATE_NEW_CONSOLE/DETACHED_PROCESS: the child shares our console + * so it can prompt for authentication. It calls FreeConsole() once its + * control socket is up (see w32_detach_console). Handles are not + * inherited; the master opens its own connection. + */ + if (!CreateProcessW(NULL, cmdline_w, NULL, NULL, FALSE, + 0, NULL, NULL, &si, &pi)) { + error("%s: CreateProcess failed: %d", __func__, GetLastError()); + SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, NULL); + goto done; + } + SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, NULL); + CloseHandle(pi.hThread); + ret = (intptr_t)pi.hProcess; + +done: + free(exe); + free(cmdline); + free(cmdline_w); + return ret; +} + +/* 1 if the spawned process is still running, 0 if it has exited */ +int +w32_process_alive(intptr_t proc) +{ + return WaitForSingleObject((HANDLE)proc, 0) == WAIT_TIMEOUT; +} + +/* close a process handle returned by w32_spawn_control_master */ +void +w32_close_handle(intptr_t h) +{ + if (h != -1 && h != 0) + CloseHandle((HANDLE)h); +} + +/* TRUE if this process was spawned as a persistent master (see above) */ +int +w32_is_controlpersist_master(void) +{ + char *val = NULL; + size_t len = 0; + int ret; + + _dupenv_s(&val, &len, W32_CONTROLPERSIST_ENV); + ret = (val != NULL); + free(val); + return ret; +} + +/* detach the persistent master from the shared console once auth is done */ +void +w32_detach_console(void) +{ + FreeConsole(); +} + int w32_ioctl(int d, int request, ...) { diff --git a/ssh.c b/ssh.c index b0c2f7d509ff..f550ef41390e 100644 --- a/ssh.c +++ b/ssh.c @@ -187,6 +187,15 @@ usage(void) static int ssh_session2(struct ssh *, const struct ssh_conn_info *); static void load_public_identity_files(const struct ssh_conn_info *); static void main_sigchld_handler(int); +#ifdef WINDOWS +static int ssh_controlpersist_spawn(struct ssh *); +/* ControlPersist spawn/detach primitives, in contrib/win32/win32compat/misc.c */ +intptr_t w32_spawn_control_master(char *const args[]); +int w32_process_alive(intptr_t proc); +void w32_close_handle(intptr_t h); +int w32_is_controlpersist_master(void); +void w32_detach_console(void); +#endif /* ~/ expand a list of paths. NB. assumes path[n] is heap-allocated. */ static void @@ -1381,13 +1390,6 @@ main(int ac, char **av) strcmp(options.proxy_command, "-") == 0 && options.proxy_use_fdpass) fatal("ProxyCommand=- and ProxyUseFDPass are incompatible"); -#ifdef WINDOWS - /* ControlPersist requires fork(); the mux master must stay in foreground */ - if (options.control_persist) { - verbose("ControlPersist is not supported on Windows; disabling"); - options.control_persist = 0; - } -#endif if (options.update_hostkeys == SSH_UPDATE_HOSTKEYS_ASK) { if (options.control_persist && options.control_path != NULL) { debug("UpdateHostKeys=ask is incompatible with " @@ -1686,6 +1688,20 @@ main(int ac, char **av) ssh_packet_set_mux(ssh); goto skip_connect; } +#ifdef WINDOWS + /* + * No master is running. If ControlPersist is requested and we + * are not ourselves the spawned master, start one (a separate + * process, since we cannot fork) and connect to it. On failure, + * fall back to a direct, non-persistent connection. The master + * process keeps control_persist so its idle timeout applies. + */ + if (options.control_persist && !w32_is_controlpersist_master()) { + if (ssh_controlpersist_spawn(ssh)) + goto skip_connect; + options.control_persist = 0; + } +#endif } /* @@ -1881,6 +1897,76 @@ main(int ac, char **av) return exit_status; } +#ifdef WINDOWS +/* + * Windows ControlPersist. There is no fork(), so we cannot background an + * already-authenticated connection like POSIX does. Instead, when the user + * wants a persistent master and none is running yet, spawn a fresh ssh + * process to be that master: it authenticates itself (sharing our console so + * it can prompt), sets up the control socket, and detaches. We then connect + * to it as an ordinary mux client. Returns 1 if a session ran against the + * spawned master (does not return in the common case, since muxclient() + * exits), 0 if the master could not be established (caller falls back to a + * direct, non-persistent connection). + */ +static int +ssh_controlpersist_spawn(struct ssh *ssh) +{ + char **args = NULL; + intptr_t proc; + int i, n, ret = 0, sock; + time_t deadline; + + /* only the auto-master, no-existing-master, session case applies */ + if (!options.control_persist || options.control_path == NULL || + w32_is_controlpersist_master() || + (options.control_master != SSHCTL_MASTER_AUTO && + options.control_master != SSHCTL_MASTER_AUTO_ASK)) + return 0; + + /* args = forced master overrides + this invocation's args (saved_av) */ + for (n = 0; saved_av[n] != NULL; n++) + ; + args = xcalloc(n + 3, sizeof(*args)); + args[0] = "-oControlMaster=yes"; + args[1] = "-oSessionType=none"; + for (i = 1; i < n; i++) /* skip saved_av[0] (argv0) */ + args[i + 1] = saved_av[i]; + args[n + 1] = NULL; + + debug_f("starting persistent mux master for %s", options.control_path); + proc = w32_spawn_control_master(args); + free(args); + if (proc == -1) { + error("could not start persistent control master"); + return 0; + } + + /* + * Wait for the master to authenticate and create its control socket, + * then connect. Generous deadline: interactive auth may prompt. + */ + deadline = monotime() + 120; + while (monotime() < deadline) { + sock = muxclient(options.control_path); + if (sock >= 0) { + /* SSHMUX_COMMAND_PROXY returns the socket */ + ssh_packet_set_connection(ssh, sock, sock); + ssh_packet_set_mux(ssh); + ret = 1; + break; + } + if (!w32_process_alive(proc)) { + debug_f("persistent master exited before it was ready"); + break; + } + usleep(200000); + } + w32_close_handle(proc); + return ret; +} +#endif /* WINDOWS */ + static void control_persist_detach(void) { @@ -2296,6 +2382,19 @@ ssh_session2(struct ssh *ssh, const struct ssh_conn_info *cinfo) if (!ssh_packet_get_mux(ssh)) muxserver_listen(ssh); +#ifdef WINDOWS + /* + * A persistent master spawned by ssh_controlpersist_spawn() shares the + * console with the foreground process so it can prompt for auth. Now + * that authentication is done and the control socket is up, detach + * from the console so we survive the terminal and don't contend with + * the foreground client. The idle timeout (set_control_persist_exit_ + * time) then governs our lifetime. Windows has no fork(), so the POSIX + * backgrounding below does not apply. + */ + if (w32_is_controlpersist_master() && muxserver_sock != -1) + w32_detach_console(); +#else /* * If we are in control persist mode and have a working mux listen * socket, then prepare to background ourselves and have a foreground @@ -2319,6 +2418,7 @@ ssh_session2(struct ssh *ssh, const struct ssh_conn_info *cinfo) need_controlpersist_detach = 1; options.fork_after_authentication = 1; } +#endif /* * ControlPersist mux listen socket setup failed, attempt the * stdio forward setup that we skipped earlier. From 2216ee0cf87c6d32c9a57561ae6002e500793b4b Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 08:46:14 +1000 Subject: [PATCH 07/14] tests: ControlPersist scenarios for the auto-spawned master Adds a Pester context covering Windows ControlPersist: a first connection auto-starts a persistent master and returns while it persists, a subsequent connection reuses it, and -O exit stops it. Uses Start-Process for the auto-spawning invocations so the spawned master's console does not block the test runner. Interactive-auth prompting and the console-relay data path remain manual-only (CI has pipe stdio); the existing 11 scenarios are unchanged (14 total). Co-Authored-By: Claude Fable 5 --- regress/pesterTests/Multiplex.Tests.ps1 | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 index 3aaff5b3345f..48d35e20db1d 100644 --- a/regress/pesterTests/Multiplex.Tests.ps1 +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -164,8 +164,53 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" It "$tC.$tI - ControlMaster auto without a master connects directly" -skip:$skip { ssh -o ControlMaster=auto -o ControlPersist=no -S $controlPath test_target echo auto-ok | Set-Content $stdoutFile $stdoutFile | Should Contain "auto-ok" - # ControlPersist is unsupported on Windows; nothing may linger + # ControlPersist=no: no master may linger Stop-MuxMaster } } + + Context "$tC - ControlPersist auto-spawned master" { + BeforeAll { + $tI=1 + $cpPath = Join-Path $testDir "cp_ctl" + # ControlPersist=yes: the auto-started master persists until -O exit + # (a numeric timeout would risk expiring mid-test) + $cpOpts = "-o", "ControlMaster=auto", "-o", "ControlPersist=yes", + "-o", "ControlPath=`"$cpPath`"" + } + AfterAll { + # make sure the persistent master does not leak between runs + ssh -o ControlPath="$cpPath" -O exit test_target 2>$null + $tC++ + } + + # Windows has no fork(), so ControlPersist auto-starts a separate master + # process (which authenticates itself) and connects to it as a client. + It "$tC.$tI - first connection auto-starts a persistent master" -skip:$skip { + # Start-Process (own console) so the spawned master does not block us + $p = Start-Process -FilePath $sshExe ` + -ArgumentList ($cpOpts + @("test_target", "echo cp-first")) ` + -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -PassThru + $p.WaitForExit(30000) | Should Be $true + $stdoutFile | Should Contain "cp-first" + # the master should have persisted and answer control requests + ssh -o ControlPath="$cpPath" -O check test_target 2>$null + $LASTEXITCODE | Should Be 0 + } + + It "$tC.$tI - subsequent connection reuses the persistent master" -skip:$skip { + $p = Start-Process -FilePath $sshExe ` + -ArgumentList @("-o", "ControlPath=`"$cpPath`"", "test_target", "echo cp-reuse") ` + -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -PassThru + $p.WaitForExit(20000) | Should Be $true + $stdoutFile | Should Contain "cp-reuse" + } + + It "$tC.$tI - -O exit stops the persistent master" -skip:$skip { + iex "cmd /c `"ssh -o ControlPath=$cpPath -O exit test_target 2> $stderrFile`"" + $stderrFile | Should Contain "Exit request sent" + ssh -o ControlPath="$cpPath" -O check test_target 2>$null + $LASTEXITCODE | Should Not Be 0 + } + } } From 1cb36b26b4ef6c7f9007e85a1ac4b7cbeacf9520 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 12:49:37 +1000 Subject: [PATCH 08/14] tests: make the tty-multiplex assertion robust for CI The tty scenario forced a pty (-tt) and asserted the remote command's output in the redirected stdout. Capturing a forced pty's stdout under redirection is unreliable in the headless CI agent (the interactive Terminal test is skipped there for the same reason), and the assertion flaked: it passed on the tty commit's build and failed on the next. Assert multiplexing from the client's own debug output instead -- a master session id is assigned and no fallback message is emitted -- which does not depend on pty output flushing. This still fails if a tty session wrongly falls back to a separate connection, so coverage is preserved. Console-relay keystroke/output behavior remains manually verified. Co-Authored-By: Claude Fable 5 --- regress/pesterTests/Multiplex.Tests.ps1 | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 index 48d35e20db1d..6d95b5a2aaa0 100644 --- a/regress/pesterTests/Multiplex.Tests.ps1 +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -120,12 +120,13 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" It "$tC.$tI - tty session multiplexes through the master" -skip:$skip { # -tt forces a pty; on Windows this now runs over the master - # (client-side console relay) instead of a separate connection. - # With redirected (pipe) stdio the relay is inert but the session - # still multiplexes: a master session id is assigned and no - # fallback message is emitted. - iex "cmd /c `"ssh -v -tt -S $controlPath test_target echo tty-mux-ok > $stdoutFile 2> $stderrFile`"" - $stdoutFile | Should Contain "tty-mux-ok" + # instead of falling back to a separate connection. The proof of + # multiplexing is the client's own debug output (a master session + # id is assigned and no fallback message is emitted). The remote + # command's pty output is NOT asserted here: capturing a forced + # pty's stdout under redirection is unreliable headless (the + # interactive Terminal test is skipped in CI for the same reason). + iex "cmd /c `"ssh -v -tt -S $controlPath test_target echo tty-mux-ok 2> $stderrFile`"" $stderrFile | Should Contain "master session id" $stderrFile | Should Not Contain "opening a separate connection" } From 8751b04e995b455f19b33c0c1e0a272d5a5bca92 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 14:35:55 +1000 Subject: [PATCH 09/14] mux: align a struct field comment (whitespace only) No functional change. Co-Authored-By: Claude Fable 5 --- mux.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mux.c b/mux.c index 9a1e389fdcc1..afcf90c6446e 100644 --- a/mux.c +++ b/mux.c @@ -1814,7 +1814,7 @@ struct mux_relay_ent { int is_console; /* this std fd was a console -> relayed */ int is_input; /* 1 = console->master (stdin); 0 = ->console */ int console_fd; /* the real console std fd (0/1/2), not owned */ - int local_pipe; /* our end of the substitute pipe (owned) */ + int local_pipe; /* our end of the substitute pipe (owned) */ int passed; /* pipe end handed to the master (-1 if none) */ int rd_done; /* source (console or pipe) hit EOF */ int pipe_closed; /* local_pipe has been closed */ From 19d0e392e7ee867d2d36d1f82b57aaff0269833f Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 17:25:51 +1000 Subject: [PATCH 10/14] mux: address PR review feedback (winsize stdin fallback, typo) Copilot flagged two issues on the upstream PR: - mux_client_send_winsize() only queried TIOCGWINSZ on stdout, so a tty session with stdout redirected (stdin the only tty) would skip sending MUX_C_WINSIZE and seed the pty-req with a 0x0 size. Fall back to stdin when stdout is not a tty. - Fix "becase" -> "because" in a Multiplex.Tests.ps1 comment. Co-Authored-By: Claude Opus 4.8 --- mux.c | 4 +++- regress/pesterTests/Multiplex.Tests.ps1 | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mux.c b/mux.c index afcf90c6446e..f38a9ce41898 100644 --- a/mux.c +++ b/mux.c @@ -1851,7 +1851,9 @@ mux_client_send_winsize(int fd) struct winsize ws; int r; - if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1) + /* prefer stdout, but fall back to stdin if only that is a tty */ + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 && + ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1) return; if ((m = sshbuf_new()) == NULL) fatal_f("sshbuf_new"); diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 index 6d95b5a2aaa0..dee153151d55 100644 --- a/regress/pesterTests/Multiplex.Tests.ps1 +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -18,7 +18,7 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" { $null = New-Item $testDir -ItemType directory -Force -ErrorAction SilentlyContinue } - #skip on ps 2 becase non-interactive cmd require a ENTER before it returns on ps2 + #skip on ps 2 because non-interactive cmd require a ENTER before it returns on ps2 $skip = $IsWindows -and ($PSVersionTable.PSVersion.Major -le 2) $controlPath = Join-Path $testDir "mux_ctl" From 26d07fa77f978f1e43a090a19d3f2e051aad755d Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 26 Jul 2026 01:37:45 +1000 Subject: [PATCH 11/14] mux: event-based readiness handshake for the ControlPersist master Review feedback on the upstream PR: instead of the foreground client polling muxclient() every 200ms until the spawned persistent master's control socket appears, have the master signal a ready event once its control socket is up, and wait on {event, master process} with WaitForMultipleObjects. The client now connects the moment the master is ready and notices immediately if it dies during auth. The event is named (Local\, pid + perf counter) and the name travels in the existing environment marker, whose value was previously just "1". Passing the event handle via PROC_THREAD_ATTRIBUTE_HANDLE_LIST was tried first, but CreateProcess propagates a console parent's standard handles into a console child regardless of the handle list, so any bInheritHandles=TRUE spawn leaves the long-lived master holding the client's redirected stdio open. A named event needs no inheritance. While here, address two more issues in the spawn path: - Build the child's environment block explicitly instead of temporarily mutating our own environment around CreateProcess (review feedback). - Pass explicit null std handles (STARTF_USESTDHANDLES). Without the flag, Windows duplicates a console parent's std handles into a console child even with bInheritHandles=FALSE, so the spawned master held the client's redirected stdio open (pre-existing issue; verified by failing to delete the client's stderr file while the master lived). Auth prompts are unaffected: readpassphrase() uses conio on the shared console, not the std handles. Co-Authored-By: Claude Fable 5 --- contrib/win32/win32compat/misc.c | 131 +++++++++++++++++++++++++++---- ssh.c | 49 ++++++------ 2 files changed, 142 insertions(+), 38 deletions(-) diff --git a/contrib/win32/win32compat/misc.c b/contrib/win32/win32compat/misc.c index d86b24dc903a..193666c11ed4 100644 --- a/contrib/win32/win32compat/misc.c +++ b/contrib/win32/win32compat/misc.c @@ -491,27 +491,61 @@ daemon(int nochdir, int noclose) * primitives; the policy lives in ssh.c. */ -/* environment marker identifying the spawned persistent master process */ +/* environment marker identifying the spawned persistent master process; + * its value names the ready event the master signals (see below) */ #define W32_CONTROLPERSIST_ENV "SSH_CONTROLPERSIST_MASTER" +/* copy of this process's environment block with one extra "VAR=value" entry */ +static wchar_t * +env_block_append(const wchar_t *entry) +{ + wchar_t *parent, *block = NULL, *p; + size_t plen, elen; + + if ((parent = GetEnvironmentStringsW()) == NULL) + return NULL; + for (p = parent; *p != L'\0'; p += wcslen(p) + 1) + ; + plen = p - parent; + elen = wcslen(entry) + 1; + if ((block = malloc((plen + elen + 1) * sizeof(wchar_t))) != NULL) { + memcpy(block, parent, plen * sizeof(wchar_t)); + memcpy(block + plen, entry, elen * sizeof(wchar_t)); + block[plen + elen] = L'\0'; + } + FreeEnvironmentStringsW(parent); + return block; +} + /* * Spawn a detached background ssh process (this same executable) with the * given arguments to act as a persistent mux master. The child shares this * process's console so it can prompt for authentication, and is deliberately * NOT registered as a tracked child, so it survives after this process - * exits. Returns the child process HANDLE as intptr_t (caller CloseHandle), - * or -1 on failure. + * exits. *ready_event receives an event HANDLE (as intptr_t) that the child + * signals once its control socket is up; the child opens it by the name + * carried in the environment marker (see w32_signal_controlpersist_ready). + * A named event is used rather than handle inheritance: for a console child + * CreateProcess propagates the parent's standard handles regardless of any + * PROC_THREAD_ATTRIBUTE_HANDLE_LIST restriction, and the long-lived master + * must not hold the client's redirected stdio open. Returns the child + * process HANDLE as intptr_t (caller w32_close_handle's both), or -1 on + * failure. */ intptr_t -w32_spawn_control_master(char *const args[]) +w32_spawn_control_master(char *const args[], intptr_t *ready_event) { - wchar_t exe_w[MAX_PATH]; + wchar_t exe_w[MAX_PATH], event_name[64], env_entry[96]; char *exe = NULL, *cmdline = NULL; - wchar_t *cmdline_w = NULL; + wchar_t *cmdline_w = NULL, *env = NULL; + LARGE_INTEGER qpc; + HANDLE event = NULL; STARTUPINFOW si; PROCESS_INFORMATION pi; intptr_t ret = -1; + *ready_event = -1; + if (GetModuleFileNameW(NULL, exe_w, MAX_PATH) == 0) { error("%s: GetModuleFileName failed: %d", __func__, GetLastError()); return -1; @@ -528,12 +562,38 @@ w32_spawn_control_master(char *const args[]) goto done; } + /* named event (session-local) the child signals once its control + * socket is up; pid + perf counter makes the name unique */ + QueryPerformanceCounter(&qpc); + swprintf_s(event_name, _countof(event_name), + L"Local\\ssh-controlpersist-%u-%08x%08x", GetCurrentProcessId(), + (unsigned int)qpc.HighPart, (unsigned int)qpc.LowPart); + if ((event = CreateEventW(NULL, TRUE, FALSE, event_name)) == NULL) { + error("%s: CreateEvent failed: %d", __func__, GetLastError()); + goto done; + } + + /* the child reads this marker to learn it is the persistent master + * and which event to signal when its control socket is up */ + swprintf_s(env_entry, _countof(env_entry), + L"" W32_CONTROLPERSIST_ENV L"=%s", event_name); + if ((env = env_block_append(env_entry)) == NULL) { + errno = ENOMEM; + goto done; + } + memset(&si, 0, sizeof(si)); si.cb = sizeof(si); + /* + * Explicit null std handles: without STARTF_USESTDHANDLES, Windows + * duplicates a console parent's std handles into a console child even + * with bInheritHandles=FALSE, and the long-lived master must not keep + * the client's redirected stdio open. Auth prompts are unaffected: + * they use the shared console directly (conio), not the std handles. + */ + si.dwFlags = STARTF_USESTDHANDLES; memset(&pi, 0, sizeof(pi)); - /* the child reads this marker to learn it is the persistent master */ - SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, L"1"); /* * No CREATE_NEW_CONSOLE/DETACHED_PROCESS: the child shares our console * so it can prompt for authentication. It calls FreeConsole() once its @@ -541,27 +601,70 @@ w32_spawn_control_master(char *const args[]) * inherited; the master opens its own connection. */ if (!CreateProcessW(NULL, cmdline_w, NULL, NULL, FALSE, - 0, NULL, NULL, &si, &pi)) { + CREATE_UNICODE_ENVIRONMENT, env, NULL, &si, &pi)) { error("%s: CreateProcess failed: %d", __func__, GetLastError()); - SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, NULL); goto done; } - SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, NULL); CloseHandle(pi.hThread); + *ready_event = (intptr_t)event; ret = (intptr_t)pi.hProcess; done: + if (ret == -1 && event != NULL) + CloseHandle(event); + free(env); free(exe); free(cmdline); free(cmdline_w); return ret; } -/* 1 if the spawned process is still running, 0 if it has exited */ +/* + * Wait for the spawned master to become ready or fail. Returns 1 when the + * ready event was signaled (the control socket is up), 0 when the process + * exited without signaling it, -1 on timeout or error. + */ int -w32_process_alive(intptr_t proc) +w32_wait_controlpersist_ready(intptr_t proc, intptr_t ready_event, + int timeout_ms) { - return WaitForSingleObject((HANDLE)proc, 0) == WAIT_TIMEOUT; + HANDLE handles[2] = { (HANDLE)ready_event, (HANDLE)proc }; + + switch (WaitForMultipleObjects(2, handles, FALSE, (DWORD)timeout_ms)) { + case WAIT_OBJECT_0: + return 1; + case WAIT_OBJECT_0 + 1: + return 0; + default: + return -1; + } +} + +/* + * In a spawned persistent master: signal the ready event named by the + * environment marker to unblock the spawning process's wait. No-op if the + * event cannot be opened (e.g. the spawning process already gave up). + */ +void +w32_signal_controlpersist_ready(void) +{ + char *val = NULL; + wchar_t *name_w = NULL; + size_t len = 0; + HANDLE event; + + _dupenv_s(&val, &len, W32_CONTROLPERSIST_ENV); + if (val == NULL) + return; + if ((name_w = utf8_to_utf16(val)) != NULL && + (event = OpenEventW(EVENT_MODIFY_STATE, FALSE, name_w)) != NULL) { + SetEvent(event); + CloseHandle(event); + } else + debug3("%s: cannot signal ready event: %d", __func__, + GetLastError()); + free(name_w); + free(val); } /* close a process handle returned by w32_spawn_control_master */ diff --git a/ssh.c b/ssh.c index f550ef41390e..d4518e0ef1fc 100644 --- a/ssh.c +++ b/ssh.c @@ -190,10 +190,12 @@ static void main_sigchld_handler(int); #ifdef WINDOWS static int ssh_controlpersist_spawn(struct ssh *); /* ControlPersist spawn/detach primitives, in contrib/win32/win32compat/misc.c */ -intptr_t w32_spawn_control_master(char *const args[]); -int w32_process_alive(intptr_t proc); +intptr_t w32_spawn_control_master(char *const args[], intptr_t *ready_event); +int w32_wait_controlpersist_ready(intptr_t proc, intptr_t ready_event, + int timeout_ms); void w32_close_handle(intptr_t h); int w32_is_controlpersist_master(void); +void w32_signal_controlpersist_ready(void); void w32_detach_console(void); #endif @@ -1913,9 +1915,8 @@ static int ssh_controlpersist_spawn(struct ssh *ssh) { char **args = NULL; - intptr_t proc; - int i, n, ret = 0, sock; - time_t deadline; + intptr_t proc, ready_event; + int i, n, r, ret = 0, sock; /* only the auto-master, no-existing-master, session case applies */ if (!options.control_persist || options.control_path == NULL || @@ -1935,7 +1936,7 @@ ssh_controlpersist_spawn(struct ssh *ssh) args[n + 1] = NULL; debug_f("starting persistent mux master for %s", options.control_path); - proc = w32_spawn_control_master(args); + proc = w32_spawn_control_master(args, &ready_event); free(args); if (proc == -1) { error("could not start persistent control master"); @@ -1943,25 +1944,22 @@ ssh_controlpersist_spawn(struct ssh *ssh) } /* - * Wait for the master to authenticate and create its control socket, - * then connect. Generous deadline: interactive auth may prompt. + * Wait for the master to signal (via the named ready event) that it + * has authenticated and its control socket is up, then connect. + * Generous timeout: interactive auth may prompt. */ - deadline = monotime() + 120; - while (monotime() < deadline) { - sock = muxclient(options.control_path); - if (sock >= 0) { + if ((r = w32_wait_controlpersist_ready(proc, ready_event, + 120 * 1000)) == 1) { + if ((sock = muxclient(options.control_path)) >= 0) { /* SSHMUX_COMMAND_PROXY returns the socket */ ssh_packet_set_connection(ssh, sock, sock); ssh_packet_set_mux(ssh); ret = 1; - break; - } - if (!w32_process_alive(proc)) { - debug_f("persistent master exited before it was ready"); - break; } - usleep(200000); - } + } else + debug_f("persistent master %s", r == 0 ? + "exited before it was ready" : "was not ready in time"); + w32_close_handle(ready_event); w32_close_handle(proc); return ret; } @@ -2386,14 +2384,17 @@ ssh_session2(struct ssh *ssh, const struct ssh_conn_info *cinfo) /* * A persistent master spawned by ssh_controlpersist_spawn() shares the * console with the foreground process so it can prompt for auth. Now - * that authentication is done and the control socket is up, detach - * from the console so we survive the terminal and don't contend with - * the foreground client. The idle timeout (set_control_persist_exit_ - * time) then governs our lifetime. Windows has no fork(), so the POSIX + * that authentication is done and the control socket is up, signal + * readiness to the waiting foreground client and detach from the + * console so we survive the terminal and don't contend with that + * client. The idle timeout (set_control_persist_exit_time) then + * governs our lifetime. Windows has no fork(), so the POSIX * backgrounding below does not apply. */ - if (w32_is_controlpersist_master() && muxserver_sock != -1) + if (w32_is_controlpersist_master() && muxserver_sock != -1) { + w32_signal_controlpersist_ready(); w32_detach_console(); + } #else /* * If we are in control persist mode and have a working mux listen From 728d70df8b878b04c977f6a5eaa789f2a13df2fd Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 26 Jul 2026 01:38:05 +1000 Subject: [PATCH 12/14] mux: tag AF_UNIX listener pios instead of testing context != NULL Review feedback on the upstream PR: fileio_close() recognized the named-pipe AF_UNIX listener by internal.context being non-NULL, which only works while nothing else reaching fileio_close() uses that field. Add a SOCK_BOUND state, set it at bind() time (listen() upgrades it to SOCK_LISTENING as before), and key the listener cleanup on the state tag instead - the same way the listener is already discriminated in w32_io_is_io_available() and select(). The bound-but-never-listening case matters because a failed listen() still needs the listener state freed on close. Co-Authored-By: Claude Fable 5 --- contrib/win32/win32compat/fileio.c | 4 +++- contrib/win32/win32compat/w32fd.h | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/contrib/win32/win32compat/fileio.c b/contrib/win32/win32compat/fileio.c index 8dae80f74502..5078b9c8d25e 100644 --- a/contrib/win32/win32compat/fileio.c +++ b/contrib/win32/win32compat/fileio.c @@ -339,6 +339,7 @@ fileio_afunix_bind(struct w32_io* pio, const char* sun_path) pio->handle = h; pio->internal.context = state; + pio->internal.state = SOCK_BOUND; /* tag checked by fileio_close */ ret = 0; cleanup: @@ -1358,7 +1359,8 @@ fileio_close(struct w32_io* pio) debug4("fileclose - pio:%p", pio); /* bound/listening AF_UNIX socket emulated over named pipes */ - if (pio->internal.context) { + if (pio->internal.state == SOCK_BOUND || + pio->internal.state == SOCK_LISTENING) { struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; if (WINHANDLE(pio) != 0 && WINHANDLE(pio) != INVALID_HANDLE_VALUE) { diff --git a/contrib/win32/win32compat/w32fd.h b/contrib/win32/win32compat/w32fd.h index d3c94d96d807..d5b134e33675 100644 --- a/contrib/win32/win32compat/w32fd.h +++ b/contrib/win32/win32compat/w32fd.h @@ -62,7 +62,8 @@ enum w32_io_sock_state { SOCK_INITIALIZED = 0, SOCK_LISTENING = 1, /*listen called on socket*/ SOCK_CONNECTING = 2, /*connect called on socket, connect is in progress*/ - SOCK_READY = 3 /*recv and send can be done*/ + SOCK_READY = 3, /*recv and send can be done*/ + SOCK_BOUND = 4 /*bind called on AF_UNIX (named pipe) socket*/ }; /* From 35a364992069397f33be1da7e289e0e05152a61c Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 15 Sep 2026 00:43:51 +1000 Subject: [PATCH 13/14] win32: harden mux transport and ControlPersist lifecycle Authenticate both pipe endpoints against the same SID, integrity and elevation boundary. Retain the checked process handle for fd duplication, validate descriptor records, and replace lossy pipe-name mangling with a canonical-path/SID hash. Never unlink filesystem data for pipe endpoints. Drain listener cancellation before freeing OVERLAPPED storage and retain the connected listener instance when rearming fails. Preserve autoask consent through direct fallback and parse the original passenger arguments before applying master-only session state. Use an inherited readiness event with explicit startup stdio and an atomic kill-on-close job; cancel abandoned startups before fallback. Clear child startup state before configuration helpers can inherit it. Restore relay console flags, cancel blocked relay writes, and query the attached output screen buffer independently of stdio redirection. Check for size changes when console resize notifications are absent. Complete empty mux control-channel drains before Windows poll, fixing -W teardown. POSIX behavior is unchanged. Existing development masters need restarting for the new pipe names; unsupported job-list startup falls back directly. --- channels.c | 15 + clientloop.c | 4 +- contrib/win32/openssh/config.h.vs | 2 +- contrib/win32/win32compat/fileio.c | 99 ++++--- contrib/win32/win32compat/misc.c | 316 +++++++++++++++------- contrib/win32/win32compat/misc_internal.h | 2 +- contrib/win32/win32compat/w32fd.c | 169 ++++++++---- misc.c | 15 +- misc.h | 1 + mux.c | 89 +++--- ssh.c | 87 ++++-- 11 files changed, 542 insertions(+), 257 deletions(-) diff --git a/channels.c b/channels.c index 9996999579c1..2a888adc3793 100644 --- a/channels.c +++ b/channels.c @@ -2898,6 +2898,21 @@ channel_prepare_poll(struct ssh *ssh, struct pollfd **pfdp, u_int *npfd_allocp, channel_handler(ssh, CHAN_PRE, timeout); +#ifdef WINDOWS + /* A later session's cleanup may have started an earlier mux channel's + * output drain. Named pipes have no shutdown() wakeup, so finish an + * empty drain before polling or a stdio-forward passenger can hang. */ + for (i = 0; i < sc->channels_alloc; i++) { + Channel *c = sc->channels[i]; + if (c != NULL && c->type == SSH_CHANNEL_MUX_CLIENT && + c->ostate == CHAN_OUTPUT_WAIT_DRAIN && + sshbuf_len(c->output) == 0) { + channel_pre_mux_client(ssh, c); + channel_garbage_collect(ssh, c); + } + } +#endif + if (oalloc != sc->channels_alloc) { /* shouldn't happen */ fatal_f("channels_alloc changed during CHAN_PRE " diff --git a/clientloop.c b/clientloop.c index db54cb8b4fc8..de047d8d845d 100644 --- a/clientloop.c +++ b/clientloop.c @@ -2870,7 +2870,7 @@ void client_stop_mux(void) { if (options.control_path != NULL && muxserver_sock != -1) - unlink(options.control_path); + unix_unlink(options.control_path); /* * If we are in persist mode, or don't have a shell, signal that we * should close when all active channels are closed. @@ -2887,7 +2887,7 @@ cleanup_exit(int i) { leave_raw_mode(options.request_tty == REQUEST_TTY_FORCE); if (options.control_path != NULL && muxserver_sock != -1) - unlink(options.control_path); + unix_unlink(options.control_path); ssh_kill_proxy_command(); _exit(i); } diff --git a/contrib/win32/openssh/config.h.vs b/contrib/win32/openssh/config.h.vs index 3de8b5b4fb48..4fe56aa3450a 100644 --- a/contrib/win32/openssh/config.h.vs +++ b/contrib/win32/openssh/config.h.vs @@ -465,7 +465,7 @@ /* #undef HAVE_GETPAGESIZE */ /* Define to 1 if you have the `getpeereid' function. */ -/* #undef HAVE_GETPEEREID */ +#define HAVE_GETPEEREID 1 /* Define to 1 if you have the `getpeerucred' function. */ /* #undef HAVE_GETPEERUCRED */ diff --git a/contrib/win32/win32compat/fileio.c b/contrib/win32/win32compat/fileio.c index 5078b9c8d25e..bf010996a0ca 100644 --- a/contrib/win32/win32compat/fileio.c +++ b/contrib/win32/win32compat/fileio.c @@ -46,6 +46,7 @@ #include "misc_internal.h" #include "debug.h" #include +#include /* internal read buffer size */ #define READ_BUFFER_SIZE 100*1024 @@ -135,7 +136,6 @@ fileio_connect(struct w32_io* pio, char* name) } if ((name_w = afunix_pipe_name(name)) == NULL) { - errno = ENOMEM; return -1; } @@ -201,8 +201,14 @@ struct afunix_listener_state { static wchar_t * afunix_pipe_name(const char *sun_path) { - wchar_t *path_w = NULL, *ret = NULL, *p; - size_t len; + wchar_t *path_w = NULL, *full = NULL, *ret = NULL, *p; + PSID sid = NULL; + HCRYPTPROV provider = 0; + HCRYPTHASH hash = 0; + BYTE digest[32]; + DWORD size = sizeof(digest); + size_t i, len; + static const wchar_t hex[] = L"0123456789abcdef"; if ((path_w = utf8_to_utf16(sun_path)) == NULL) { errno = ENOMEM; @@ -213,27 +219,48 @@ afunix_pipe_name(const char *sun_path) if (*p == L'/') *p = L'\\'; - if (_wcsnicmp(path_w, AFUNIX_PIPE_PREFIX, AFUNIX_PIPE_PREFIX_LEN) == 0) + if (_wcsnicmp(path_w, AFUNIX_PIPE_PREFIX, AFUNIX_PIPE_PREFIX_LEN) == 0) { + if (wcslen(path_w) - AFUNIX_PIPE_PREFIX_LEN > AFUNIX_PIPE_NAME_MAX) { + free(path_w); + errno = ENAMETOOLONG; + return NULL; + } return path_w; - - /* map filesystem-style path to \\.\pipe\openssh-uds- */ - for (p = path_w; *p; p++) - if (*p == L'\\' || *p == L':') - *p = L'-'; - - if (wcslen(path_w) > AFUNIX_PIPE_NAME_MAX - wcslen(L"openssh-uds-")) { - free(path_w); - errno = ENAMETOOLONG; - return NULL; } - len = AFUNIX_PIPE_PREFIX_LEN + wcslen(L"openssh-uds-") + wcslen(path_w) + 1; - if ((ret = malloc(len * sizeof(wchar_t))) == NULL) { - free(path_w); + /* Preserve path boundaries and resolve relative paths before hashing. + * The SID namespaces filesystem-style paths per user. Case is preserved; + * literal pipe names above retain their existing Windows semantics. */ + if ((full = _wfullpath(NULL, path_w, 0)) == NULL || + (sid = get_sid(NULL)) == NULL) + goto done; + if (!CryptAcquireContextW(&provider, NULL, NULL, PROV_RSA_AES, + CRYPT_VERIFYCONTEXT) || + !CryptCreateHash(provider, CALG_SHA_256, 0, 0, &hash) || + !CryptHashData(hash, (BYTE *)full, + (DWORD)((wcslen(full) + 1) * sizeof(wchar_t)), 0) || + !CryptHashData(hash, sid, GetLengthSid(sid), 0) || + !CryptGetHashParam(hash, HP_HASHVAL, digest, &size, 0)) { + errno = EIO; + goto done; + } + len = AFUNIX_PIPE_PREFIX_LEN + wcslen(L"openssh-uds-v2-"); + if ((ret = calloc(len + sizeof(digest) * 2 + 1, sizeof(wchar_t))) == NULL) { errno = ENOMEM; - return NULL; - } - swprintf_s(ret, len, L"%s%s%s", AFUNIX_PIPE_PREFIX, L"openssh-uds-", path_w); + goto done; + } + wcscpy_s(ret, len + 1, AFUNIX_PIPE_PREFIX L"openssh-uds-v2-"); + for (i = 0; i < sizeof(digest); i++) { + ret[len + i * 2] = hex[digest[i] >> 4]; + ret[len + i * 2 + 1] = hex[digest[i] & 15]; + } +done: + if (hash) + CryptDestroyHash(hash); + if (provider) + CryptReleaseContext(provider, 0); + free(sid); + free(full); free(path_w); return ret; } @@ -434,18 +461,21 @@ fileio_afunix_accept(struct w32_io* pio) } memset(accepted, 0, sizeof(struct w32_io)); - connected = WINHANDLE(pio); - state->client_connected = FALSE; - /* stand up the next instance before handing out the connected one */ if ((next = afunix_create_instance(state, FALSE)) == INVALID_HANDLE_VALUE) { - /* listener is degraded; subsequent accepts will fail */ - error("afunix accept - failed to create next pipe instance, errno:%d", errno); - pio->handle = 0; - } else { - pio->handle = next; - if (afunix_listener_arm(pio) != 0) - error("afunix accept - failed to arm next pipe instance"); + free(accepted); + return NULL; + } + connected = WINHANDLE(pio); + state->client_connected = FALSE; + pio->handle = next; + if (afunix_listener_arm(pio) != 0) { + CloseHandle(next); + pio->handle = connected; + state->client_connected = TRUE; + SetEvent(pio->read_overlapped.hEvent); + free(accepted); + return NULL; } accepted->handle = connected; @@ -1364,7 +1394,14 @@ fileio_close(struct w32_io* pio) struct afunix_listener_state* state = (struct afunix_listener_state*)pio->internal.context; if (WINHANDLE(pio) != 0 && WINHANDLE(pio) != INVALID_HANDLE_VALUE) { - CancelIo(WINHANDLE(pio)); + if (state->connect_pending) { + DWORD bytes; + /* Cancellation is asynchronous, including when accept races + * with close. Keep OVERLAPPED and its event alive until done. */ + CancelIoEx(WINHANDLE(pio), &pio->read_overlapped); + GetOverlappedResult(WINHANDLE(pio), + &pio->read_overlapped, &bytes, TRUE); + } CloseHandle(WINHANDLE(pio)); } if (pio->read_overlapped.hEvent) diff --git a/contrib/win32/win32compat/misc.c b/contrib/win32/win32compat/misc.c index 193666c11ed4..2eee2b23d263 100644 --- a/contrib/win32/win32compat/misc.c +++ b/contrib/win32/win32compat/misc.c @@ -491,16 +491,22 @@ daemon(int nochdir, int noclose) * primitives; the policy lives in ssh.c. */ -/* environment marker identifying the spawned persistent master process; - * its value names the ready event the master signals (see below) */ +/* Child-only marker containing an inherited readiness event handle. */ #define W32_CONTROLPERSIST_ENV "SSH_CONTROLPERSIST_MASTER" +/* The port targets older SDK APIs; this optional attribute needs Windows 10. */ +#ifndef PROC_THREAD_ATTRIBUTE_JOB_LIST +#define PROC_THREAD_ATTRIBUTE_JOB_LIST ProcThreadAttributeValue(13, FALSE, TRUE, FALSE) +#endif +static int controlpersist_master = -1; +static HANDLE controlpersist_ready_event; -/* copy of this process's environment block with one extra "VAR=value" entry */ +/* Insert/replace a VAR=value entry, preserving Windows' sorted environment. */ static wchar_t * -env_block_append(const wchar_t *entry) +env_block_set(const wchar_t *entry) { - wchar_t *parent, *block = NULL, *p; - size_t plen, elen; + wchar_t *parent, *block = NULL, *p, *out; + size_t plen, elen, keylen, len; + int inserted = 0; if ((parent = GetEnvironmentStringsW()) == NULL) return NULL; @@ -508,10 +514,26 @@ env_block_append(const wchar_t *entry) ; plen = p - parent; elen = wcslen(entry) + 1; + keylen = wcscspn(entry, L"=") + 1; if ((block = malloc((plen + elen + 1) * sizeof(wchar_t))) != NULL) { - memcpy(block, parent, plen * sizeof(wchar_t)); - memcpy(block + plen, entry, elen * sizeof(wchar_t)); - block[plen + elen] = L'\0'; + out = block; + for (p = parent; *p; p += len) { + len = wcslen(p) + 1; + if (!inserted && _wcsicmp(entry, p) < 0) { + memcpy(out, entry, elen * sizeof(wchar_t)); + out += elen; + inserted = 1; + } + if (_wcsnicmp(p, entry, keylen) != 0) { + memcpy(out, p, len * sizeof(wchar_t)); + out += len; + } + } + if (!inserted) { + memcpy(out, entry, elen * sizeof(wchar_t)); + out += elen; + } + *out = L'\0'; } FreeEnvironmentStringsW(parent); return block; @@ -521,32 +543,34 @@ env_block_append(const wchar_t *entry) * Spawn a detached background ssh process (this same executable) with the * given arguments to act as a persistent mux master. The child shares this * process's console so it can prompt for authentication, and is deliberately - * NOT registered as a tracked child, so it survives after this process - * exits. *ready_event receives an event HANDLE (as intptr_t) that the child - * signals once its control socket is up; the child opens it by the name - * carried in the environment marker (see w32_signal_controlpersist_ready). - * A named event is used rather than handle inheritance: for a console child - * CreateProcess propagates the parent's standard handles regardless of any - * PROC_THREAD_ATTRIBUTE_HANDLE_LIST restriction, and the long-lived master - * must not hold the client's redirected stdio open. Returns the child - * process HANDLE as intptr_t (caller w32_close_handle's both), or -1 on - * failure. + * NOT registered as a tracked child. A kill-on-close job owns it until + * readiness is acknowledged; abandoning startup cannot leave an orphan. + * Only the event and deliberately chosen startup stdio handles are inherited. */ intptr_t -w32_spawn_control_master(char *const args[], intptr_t *ready_event) +w32_spawn_control_master(char *const args[], intptr_t *ready_event, + intptr_t *startup_job) { - wchar_t exe_w[MAX_PATH], event_name[64], env_entry[96]; + wchar_t exe_w[32768], env_entry[96]; char *exe = NULL, *cmdline = NULL; wchar_t *cmdline_w = NULL, *env = NULL; - LARGE_INTEGER qpc; - HANDLE event = NULL; - STARTUPINFOW si; + HANDLE event = NULL, job = NULL, inherited[4] = { NULL }; + STARTUPINFOEXW si; PROCESS_INFORMATION pi; + SECURITY_ATTRIBUTES sa = { sizeof(sa), NULL, TRUE }; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; + SIZE_T attr_size = 0; + DWORD len; + int i, attr_initialized = 0; intptr_t ret = -1; *ready_event = -1; + *startup_job = -1; + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); - if (GetModuleFileNameW(NULL, exe_w, MAX_PATH) == 0) { + len = GetModuleFileNameW(NULL, exe_w, _countof(exe_w)); + if (len == 0 || len >= _countof(exe_w)) { error("%s: GetModuleFileName failed: %d", __func__, GetLastError()); return -1; } @@ -562,13 +586,7 @@ w32_spawn_control_master(char *const args[], intptr_t *ready_event) goto done; } - /* named event (session-local) the child signals once its control - * socket is up; pid + perf counter makes the name unique */ - QueryPerformanceCounter(&qpc); - swprintf_s(event_name, _countof(event_name), - L"Local\\ssh-controlpersist-%u-%08x%08x", GetCurrentProcessId(), - (unsigned int)qpc.HighPart, (unsigned int)qpc.LowPart); - if ((event = CreateEventW(NULL, TRUE, FALSE, event_name)) == NULL) { + if ((event = CreateEventW(&sa, TRUE, FALSE, NULL)) == NULL) { error("%s: CreateEvent failed: %d", __func__, GetLastError()); goto done; } @@ -576,42 +594,90 @@ w32_spawn_control_master(char *const args[], intptr_t *ready_event) /* the child reads this marker to learn it is the persistent master * and which event to signal when its control socket is up */ swprintf_s(env_entry, _countof(env_entry), - L"" W32_CONTROLPERSIST_ENV L"=%s", event_name); - if ((env = env_block_append(env_entry)) == NULL) { + L"" W32_CONTROLPERSIST_ENV L"=%llu", (unsigned long long)(uintptr_t)event); + if ((env = env_block_set(env_entry)) == NULL) { errno = ENOMEM; goto done; } - memset(&si, 0, sizeof(si)); - si.cb = sizeof(si); - /* - * Explicit null std handles: without STARTF_USESTDHANDLES, Windows - * duplicates a console parent's std handles into a console child even - * with bInheritHandles=FALSE, and the long-lived master must not keep - * the client's redirected stdio open. Auth prompts are unaffected: - * they use the shared console directly (conio), not the std handles. - */ - si.dwFlags = STARTF_USESTDHANDLES; - memset(&pi, 0, sizeof(pi)); + /* Explicit stdio prevents implicit duplication of redirected stdout. + * Prompt echo uses a fresh console output handle. Retain stderr only + * during authentication; ssh_session2 closes it before signaling ready. */ + inherited[0] = event; + inherited[1] = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | + FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL); + inherited[2] = CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL); + if (inherited[2] == INVALID_HANDLE_VALUE) + inherited[2] = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | + FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL); + if (!DuplicateHandle(GetCurrentProcess(), GetStdHandle(STD_ERROR_HANDLE), + GetCurrentProcess(), &inherited[3], 0, TRUE, DUPLICATE_SAME_ACCESS)) + inherited[3] = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | + FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL); + for (i = 1; i < 4; i++) + if (inherited[i] == NULL || inherited[i] == INVALID_HANDLE_VALUE) + goto done; + si.StartupInfo.cb = sizeof(si); + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = inherited[1]; + si.StartupInfo.hStdOutput = inherited[2]; + si.StartupInfo.hStdError = inherited[3]; + InitializeProcThreadAttributeList(NULL, 2, 0, &attr_size); + if ((si.lpAttributeList = malloc(attr_size)) == NULL) + goto done; + if (!InitializeProcThreadAttributeList(si.lpAttributeList, 2, 0, &attr_size)) + goto done; + attr_initialized = 1; + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), NULL, NULL)) + goto done; + memset(&limits, 0, sizeof(limits)); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if ((job = CreateJobObjectW(NULL, NULL)) == NULL || + !SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) + goto done; + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0, + PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job), NULL, NULL)) + goto done; /* * No CREATE_NEW_CONSOLE/DETACHED_PROCESS: the child shares our console * so it can prompt for authentication. It calls FreeConsole() once its - * control socket is up (see w32_detach_console). Handles are not - * inherited; the master opens its own connection. + * control socket is up. Assign the job atomically at creation, so even + * parent termination during CreateProcess cannot strand an unowned child. */ - if (!CreateProcessW(NULL, cmdline_w, NULL, NULL, FALSE, - CREATE_UNICODE_ENVIRONMENT, env, NULL, &si, &pi)) { + if (!CreateProcessW(exe_w, cmdline_w, NULL, NULL, TRUE, + CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + env, NULL, &si.StartupInfo, &pi)) { error("%s: CreateProcess failed: %d", __func__, GetLastError()); goto done; } - CloseHandle(pi.hThread); *ready_event = (intptr_t)event; + *startup_job = (intptr_t)job; ret = (intptr_t)pi.hProcess; done: - if (ret == -1 && event != NULL) - CloseHandle(event); + if (ret == -1) { + if (pi.hProcess) { + TerminateProcess(pi.hProcess, 255); + WaitForSingleObject(pi.hProcess, INFINITE); + CloseHandle(pi.hProcess); + } + if (job) + CloseHandle(job); + if (event) + CloseHandle(event); + } + if (pi.hThread) + CloseHandle(pi.hThread); + for (i = 1; i < 4; i++) + if (inherited[i] && inherited[i] != INVALID_HANDLE_VALUE) + CloseHandle(inherited[i]); + if (attr_initialized) + DeleteProcThreadAttributeList(si.lpAttributeList); + free(si.lpAttributeList); free(env); free(exe); free(cmdline); @@ -629,42 +695,59 @@ w32_wait_controlpersist_ready(intptr_t proc, intptr_t ready_event, int timeout_ms) { HANDLE handles[2] = { (HANDLE)ready_event, (HANDLE)proc }; + ULONGLONG deadline = GetTickCount64() + timeout_ms, now; + + for (;;) { + switch (WaitForMultipleObjects(2, handles, FALSE, 0)) { + case WAIT_OBJECT_0: + return 1; + case WAIT_OBJECT_0 + 1: + return 0; + case WAIT_TIMEOUT: + break; + default: + return -1; + } + if ((now = GetTickCount64()) >= deadline || + wait_for_any_event(handles, 2, (DWORD)(deadline - now)) == -1) + return -1; + } +} - switch (WaitForMultipleObjects(2, handles, FALSE, (DWORD)timeout_ms)) { - case WAIT_OBJECT_0: - return 1; - case WAIT_OBJECT_0 + 1: - return 0; - default: - return -1; +/* Release the job only after readiness, otherwise stop this startup tree. */ +int +w32_finish_controlpersist_startup(intptr_t proc, intptr_t startup_job, int ready) +{ + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; + int ret = 0; + + if (ready) { + memset(&limits, 0, sizeof(limits)); + if (!SetInformationJobObject((HANDLE)startup_job, + JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { + ready = 0; + ret = -1; + } + } + if (!ready) { + if (!TerminateJobObject((HANDLE)startup_job, 255)) + ret = -1; + if (WaitForSingleObject((HANDLE)proc, 10000) != WAIT_OBJECT_0) + ret = -1; } + CloseHandle((HANDLE)startup_job); + return ret; } -/* - * In a spawned persistent master: signal the ready event named by the - * environment marker to unblock the spawning process's wait. No-op if the - * event cannot be opened (e.g. the spawning process already gave up). - */ +/* Signal readiness only after dropping startup stdio and the console. */ void w32_signal_controlpersist_ready(void) { - char *val = NULL; - wchar_t *name_w = NULL; - size_t len = 0; - HANDLE event; - - _dupenv_s(&val, &len, W32_CONTROLPERSIST_ENV); - if (val == NULL) - return; - if ((name_w = utf8_to_utf16(val)) != NULL && - (event = OpenEventW(EVENT_MODIFY_STATE, FALSE, name_w)) != NULL) { - SetEvent(event); - CloseHandle(event); - } else - debug3("%s: cannot signal ready event: %d", __func__, - GetLastError()); - free(name_w); - free(val); + if (controlpersist_ready_event == NULL || + !SetEvent(controlpersist_ready_event)) + fatal("cannot signal persistent master readiness"); + CloseHandle(controlpersist_ready_event); + controlpersist_ready_event = NULL; } /* close a process handle returned by w32_spawn_control_master */ @@ -680,13 +763,32 @@ int w32_is_controlpersist_master(void) { char *val = NULL; + char *end; size_t len = 0; - int ret; + unsigned long long value; + DWORD flags; + + if (controlpersist_master != -1) + return controlpersist_master; _dupenv_s(&val, &len, W32_CONTROLPERSIST_ENV); - ret = (val != NULL); + controlpersist_master = val != NULL; + if (val != NULL) { + /* Do not propagate the marker or event into ProxyCommand children. */ + if (_putenv_s(W32_CONTROLPERSIST_ENV, "") != 0) + fatal("cannot clear persistent master environment marker"); + errno = 0; + value = _strtoui64(val, &end, 10); + controlpersist_ready_event = (HANDLE)(uintptr_t)value; + if (errno != 0 || end == val || *end != '\0' || value == 0 || + value != (uintptr_t)value || (intptr_t)value < 0 || + !GetHandleInformation(controlpersist_ready_event, &flags) || + !(flags & HANDLE_FLAG_INHERIT) || + !SetHandleInformation(controlpersist_ready_event, HANDLE_FLAG_INHERIT, 0)) + fatal("invalid persistent master environment marker"); + } free(val); - return ret; + return controlpersist_master; } /* detach the persistent master from the shared console once auth is done */ @@ -696,6 +798,33 @@ w32_detach_console(void) FreeConsole(); } +/* A mux client may have only console stdin, with both outputs redirected. + * GetConsoleScreenBufferInfo needs an output handle, not the input handle. */ +int +w32_get_console_winsize(struct winsize *ws) +{ + CONSOLE_SCREEN_BUFFER_INFO info; + HANDLE output; + BOOL ok; + + output = CreateFileW(L"CONOUT$", GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); + if (output == INVALID_HANDLE_VALUE) { + errno = ENOTTY; + return -1; + } + ok = GetConsoleScreenBufferInfo(output, &info); + CloseHandle(output); + if (!ok) { + errno = ENOTTY; + return -1; + } + memset(ws, 0, sizeof(*ws)); + ws->ws_col = info.srWindow.Right - info.srWindow.Left + 1; + ws->ws_row = info.srWindow.Bottom - info.srWindow.Top + 1; + return 0; +} + int w32_ioctl(int d, int request, ...) { @@ -2266,31 +2395,22 @@ bash_to_win_path(const char *in, char *out, const size_t out_len) * getpeereid() emulation for AF_UNIX sockets emulated over named pipes. * There are no numeric uids on Windows; the contract provided is: succeed * with euid == geteuid() iff the pipe peer process runs as the same Windows - * user, so that callers comparing against getuid()/geteuid() get the right - * answer. Note that the pipe's DACL (owner + SYSTEM) enforces this too. + * user, integrity level and elevation state. This also authenticates a + * server to its clients, which the server-controlled DACL cannot do. */ int getpeereid(int s, uid_t *euid, gid_t *egid) { - HANDLE h; - DWORD peer_pid = 0; + HANDLE h, peer; if ((h = w32_fd_to_handle(s)) == NULL || h == INVALID_HANDLE_VALUE) { errno = EBADF; return -1; } - if (!GetNamedPipeClientProcessId(h, &peer_pid) && - !GetNamedPipeServerProcessId(h, &peer_pid)) { - debug3("%s - cannot determine pipe peer, error: %d", __func__, GetLastError()); - errno = ENOTSUP; + if ((peer = w32_open_pipe_peer(h, 0)) == NULL) return -1; - } - - if (peer_pid != GetCurrentProcessId() && !w32_is_pid_same_user(peer_pid)) { - errno = EPERM; - return -1; - } + CloseHandle(peer); *euid = geteuid(); *egid = getegid(); diff --git a/contrib/win32/win32compat/misc_internal.h b/contrib/win32/win32compat/misc_internal.h index ad6d0fd2389f..18cf0cbc0dcb 100644 --- a/contrib/win32/win32compat/misc_internal.h +++ b/contrib/win32/win32compat/misc_internal.h @@ -73,7 +73,7 @@ int file_in_chroot_jail(HANDLE); int file_in_chroot_jail_helper(wchar_t*); PSID lookup_sid(const wchar_t* name_utf16, PSID psid, DWORD * psid_len); PSID get_sid(const char*); -BOOL w32_is_pid_same_user(DWORD pid); +HANDLE w32_open_pipe_peer(HANDLE pipe, DWORD access); int am_system(); int is_conpty_supported(); int exec_command_with_pty(int * pid, char* cmd, int in, int out, int err, unsigned int col, unsigned int row, int ttyfd); diff --git a/contrib/win32/win32compat/w32fd.c b/contrib/win32/win32compat/w32fd.c index e392e6cc7d27..cf6e87edc792 100644 --- a/contrib/win32/win32compat/w32fd.c +++ b/contrib/win32/win32compat/w32fd.c @@ -1028,9 +1028,38 @@ w32_dup(int oldfd) HANDLE w32_fd_to_handle(int fd) { + if (fd < 0 || fd >= MAX_FDS || fd_table.w32_ios[fd] == NULL) { + errno = EBADF; + return NULL; + } return fd_table.w32_ios[fd]->handle; } +/* Relay pipes must not block close waiting for a master that stopped reading. + * A non-forced close defers EOF until the last buffered write completes. */ +int +w32_close_mux_pipe(int fd, int force) +{ + struct w32_io *pio; + + CHECK_FD(fd); + pio = fd_table.w32_ios[fd]; + if (pio->type != NONSOCK_FD || FILETYPE(pio) != FILE_TYPE_PIPE) { + errno = EINVAL; + return -1; + } + if (!force && pio->write_details.pending) + return 1; + if (!CancelIoEx(pio->handle, NULL) && GetLastError() != ERROR_NOT_FOUND) { + errno = errno_from_Win32Error(GetLastError()); + return -1; + } + /* ReadFileEx/WriteFileEx own pio until their completion APCs run. */ + while (pio->read_details.pending || pio->write_details.pending) + SleepEx(INFINITE, TRUE); + return w32_close(fd); +} + /* wraps a raw win32 handle in a new fd table entry */ static int w32_allocate_fd_for_handle(HANDLE h, int type) @@ -1071,47 +1100,76 @@ struct w32_fdpass_msg { }; #pragma pack(pop) -/* TRUE if process pid runs as the same Windows user as us */ -BOOL -w32_is_pid_same_user(DWORD pid) +static void * +fdpass_token_info(HANDLE token, TOKEN_INFORMATION_CLASS info_class) { - BOOL ret = FALSE; - HANDLE proc = NULL, token = NULL; - TOKEN_USER *peer_info = NULL; - PSID my_sid = NULL; - DWORD info_len = 0; - - if ((my_sid = get_sid(NULL)) == NULL) { - error("fdpass - cannot retrieve own SID"); - goto done; - } - if ((proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)) == NULL) { - error("fdpass - OpenProcess(%d) failed, error: %d", pid, GetLastError()); - goto done; - } - if (!OpenProcessToken(proc, TOKEN_QUERY, &token)) { - error("fdpass - OpenProcessToken failed, error: %d", GetLastError()); - goto done; + DWORD len = 0; + void *info; + + if (GetTokenInformation(token, info_class, NULL, 0, &len) || + GetLastError() != ERROR_INSUFFICIENT_BUFFER || + (info = malloc(len)) == NULL) + return NULL; + if (!GetTokenInformation(token, info_class, info, len, &len)) { + free(info); + return NULL; } - if (GetTokenInformation(token, TokenUser, NULL, 0, &info_len) == TRUE || - (peer_info = (TOKEN_USER*)malloc(info_len)) == NULL) + return info; +} + +/* Mux is confined to one user and elevation/integrity level. Return an + * authenticated process handle, held through any subsequent duplication. + * GetNamedPipeInfo selects the opposite endpoint even for same-process tests. */ +HANDLE +w32_open_pipe_peer(HANDLE pipe, DWORD access) +{ + HANDLE proc = NULL, tokens[2] = { NULL, NULL }; + TOKEN_USER *users[2] = { NULL, NULL }; + TOKEN_MANDATORY_LABEL *levels[2] = { NULL, NULL }; + TOKEN_ELEVATION elevations[2]; + DWORD flags, pid = 0, checked_pid = 0, len; + BOOL ok = FALSE; + int i; + + if (!GetNamedPipeInfo(pipe, &flags, NULL, NULL, NULL) || + !(flags & PIPE_SERVER_END ? GetNamedPipeClientProcessId(pipe, &pid) : + GetNamedPipeServerProcessId(pipe, &pid)) || pid == 0 || + (proc = OpenProcess(access | PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, pid)) == NULL) goto done; - if (GetTokenInformation(token, TokenUser, peer_info, info_len, &info_len) == FALSE) + if (!(flags & PIPE_SERVER_END ? + GetNamedPipeClientProcessId(pipe, &checked_pid) : + GetNamedPipeServerProcessId(pipe, &checked_pid)) || checked_pid != pid || + !OpenProcessToken(proc, TOKEN_QUERY, &tokens[0]) || + !OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tokens[1])) goto done; - ret = EqualSid(my_sid, peer_info->User.Sid); - if (!ret) - error("fdpass - peer process %d is a different user", pid); - + for (i = 0; i < 2; i++) { + if ((users[i] = fdpass_token_info(tokens[i], TokenUser)) == NULL || + (levels[i] = fdpass_token_info(tokens[i], TokenIntegrityLevel)) == NULL || + !GetTokenInformation(tokens[i], TokenElevation, &elevations[i], + sizeof(elevations[i]), &len)) + goto done; + } + ok = EqualSid(users[0]->User.Sid, users[1]->User.Sid) && + EqualSid(levels[0]->Label.Sid, levels[1]->Label.Sid) && + elevations[0].TokenIsElevated == elevations[1].TokenIsElevated; done: - if (peer_info) - free(peer_info); - if (my_sid) - free(my_sid); - if (token) - CloseHandle(token); - if (proc) - CloseHandle(proc); - return ret; + if (!ok) + debug3("mux peer authentication failed: pid %lu, checked %lu, error %lu", + pid, checked_pid, GetLastError()); + for (i = 0; i < 2; i++) { + free(users[i]); + free(levels[i]); + if (tokens[i]) + CloseHandle(tokens[i]); + } + if (!ok) { + if (proc) + CloseHandle(proc); + errno = EPERM; + return NULL; + } + return proc; } /* read/write full buffer on possibly nonblocking fd, pumping APCs */ @@ -1181,7 +1239,7 @@ w32_fdpass_recv(int sock) { struct w32_fdpass_msg msg; HANDLE src_proc = NULL, dup = NULL; - DWORD pipe_client_pid = 0; + DWORD filetype, mode; int fd = -1, type; CHECK_FD(sock); @@ -1191,30 +1249,24 @@ w32_fdpass_recv(int sock) return -1; } - if (msg.magic != W32_FDPASS_MAGIC) { - error("fdpass - bad magic 0x%08x from fd %d", msg.magic, sock); + if (msg.magic != W32_FDPASS_MAGIC || + (msg.type != NONSOCK_FD && msg.type != NONSOCK_SYNC_FD) || + msg.handle == 0 || msg.handle != (uintptr_t)msg.handle || + (intptr_t)msg.handle < 0) { + error("fdpass - invalid descriptor record from fd %d", sock); errno = EINVAL; return -1; } - /* when the transport is a named pipe, the claimed pid must match the peer */ - if (fd_table.w32_ios[sock]->type == NONSOCK_FD && - GetNamedPipeClientProcessId(fd_table.w32_ios[sock]->handle, &pipe_client_pid) && - pipe_client_pid != 0 && pipe_client_pid != GetCurrentProcessId() && - pipe_client_pid != msg.pid) { - error("fdpass - claimed pid %d does not match pipe peer %d", - msg.pid, pipe_client_pid); - errno = EPERM; + if (fd_table.w32_ios[sock]->type != NONSOCK_FD) { + errno = ENOTSOCK; return -1; } - - if (!w32_is_pid_same_user(msg.pid)) { - errno = EPERM; + if ((src_proc = w32_open_pipe_peer(fd_table.w32_ios[sock]->handle, + PROCESS_DUP_HANDLE)) == NULL) return -1; - } - - if ((src_proc = OpenProcess(PROCESS_DUP_HANDLE, FALSE, msg.pid)) == NULL) { - error("fdpass - OpenProcess(%d) for dup failed, error: %d", msg.pid, GetLastError()); + if (GetProcessId(src_proc) != msg.pid) { + CloseHandle(src_proc); errno = EPERM; return -1; } @@ -1227,6 +1279,13 @@ w32_fdpass_recv(int sock) return -1; } CloseHandle(src_proc); + filetype = GetFileType(dup); + if ((filetype != FILE_TYPE_DISK && filetype != FILE_TYPE_PIPE && + filetype != FILE_TYPE_CHAR) || GetConsoleMode(dup, &mode)) { + CloseHandle(dup); + errno = ENOTSUP; + return -1; + } /* * sender's fd classification decides sync vs async io; inherited stdio @@ -1235,7 +1294,7 @@ w32_fdpass_recv(int sock) * using async io on its end. */ type = (msg.type == NONSOCK_FD) ? NONSOCK_FD : NONSOCK_SYNC_FD; - if (GetFileType(dup) == FILE_TYPE_CHAR) + if (filetype == FILE_TYPE_CHAR) type = NONSOCK_SYNC_FD; if ((fd = w32_allocate_fd_for_handle(dup, type)) == -1) { diff --git a/misc.c b/misc.c index 4e75ef0593ca..7eef2a3ab323 100644 --- a/misc.c +++ b/misc.c @@ -2080,6 +2080,17 @@ lowercase(char *s) *s = tolower((u_char)*s); } +int +unix_unlink(const char *path) +{ +#ifdef WINDOWS + /* Named-pipe endpoints disappear on close; never delete a real file. */ + return 0; +#else + return unlink(path); +#endif +} + int unix_listener(const char *path, int backlog, int unlink_first) { @@ -2103,7 +2114,7 @@ unix_listener(const char *path, int backlog, int unlink_first) return -1; } if (unlink_first == 1) { - if (unlink(path) != 0 && errno != ENOENT) + if (unix_unlink(path) != 0 && errno != ENOENT) error("unlink(%s): %.100s", path, strerror(errno)); } if (bind(sock, (struct sockaddr *)&sunaddr, sizeof(sunaddr)) == -1) { @@ -2117,7 +2128,7 @@ unix_listener(const char *path, int backlog, int unlink_first) saved_errno = errno; error_f("cannot listen on path %s: %s", path, strerror(errno)); close(sock); - unlink(path); + unix_unlink(path); errno = saved_errno; return -1; } diff --git a/misc.h b/misc.h index 5744326cdd7d..af25f8f80a9f 100644 --- a/misc.h +++ b/misc.h @@ -103,6 +103,7 @@ time_t monotime(void); double monotime_double(void); void lowercase(char *s); int unix_listener(const char *, int, int); +int unix_unlink(const char *); int valid_domain(char *, int, const char **); int valid_env_name(const char *); const char *atoi_err(const char *, int *); diff --git a/mux.c b/mux.c index f38a9ce41898..b6e6e3c41865 100644 --- a/mux.c +++ b/mux.c @@ -1783,37 +1783,19 @@ mux_client_read_packet(int fd, struct sshbuf *m) * the master, and instead of relaying SIGWINCH with kill() (which on * Windows would terminate the master process). * - * Protocol additions (Windows only; see PROTOCOL.mux for the base - * protocol): - * - * 1. Masters advertise the hello extension "tty-relay@win32.openssh.com" - * with an empty value (reserved for future versioning). Peers that do - * not recognise the extension ignore it, per PROTOCOL.mux. - * - * 2. A client that saw the extension may send, at any time after the - * hello: - * uint32 MUX_C_WINSIZE - * uint32 request id - * uint32 columns - * uint32 rows - * uint32 x pixels - * uint32 y pixels - * No reply is sent and the request id is not consumed. Because the - * control channel is ordered, a MUX_C_WINSIZE sent before - * MUX_C_NEW_SESSION seeds the dimensions used in the session's pty-req; - * later messages become "window-change" channel requests. - * - * 3. A client that wants a tty but did not see the extension MUST NOT - * open a session over the multiplexed connection; it falls back to a - * separate direct connection instead (see muxclient()). + * See PROTOCOL.mux for extension negotiation and the window-size message. */ #define MUX_RELAY_BUF 8192 +int w32_get_console_winsize(struct winsize *); +int w32_close_mux_pipe(int, int); + struct mux_relay_ent { int is_console; /* this std fd was a console -> relayed */ int is_input; /* 1 = console->master (stdin); 0 = ->console */ int console_fd; /* the real console std fd (0/1/2), not owned */ + int console_flags; /* saved F_GETFL, restored on every return path */ int local_pipe; /* our end of the substitute pipe (owned) */ int passed; /* pipe end handed to the master (-1 if none) */ int rd_done; /* source (console or pipe) hit EOF */ @@ -1847,13 +1829,16 @@ control_client_sigwinch(int signo) static void mux_client_send_winsize(int fd) { + static struct winsize last_ws; + static int size_sent; struct sshbuf *m; struct winsize ws; int r; - /* prefer stdout, but fall back to stdin if only that is a tty */ - if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 && - ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1) + if ((!isatty(STDIN_FILENO) && !isatty(STDOUT_FILENO) && + !isatty(STDERR_FILENO)) || w32_get_console_winsize(&ws) == -1) + return; + if (size_sent && memcmp(&ws, &last_ws, sizeof(ws)) == 0) return; if ((m = sshbuf_new()) == NULL) fatal_f("sshbuf_new"); @@ -1867,6 +1852,10 @@ mux_client_send_winsize(int fd) fatal_fr(r, "assemble winsize"); if (mux_client_write_packet(fd, m) != 0) debug_f("write winsize: %s", strerror(errno)); + else { + last_ws = ws; + size_sent = 1; + } sshbuf_free(m); } @@ -1876,13 +1865,12 @@ mux_client_send_winsize(int fd) * a pipe is created and its master-facing end is recorded in passed_fd[i] * so the caller sends that instead of the real fd. Returns 0 on success * (relay may be inactive if no fd was a console), -1 on error with all - * pipes closed. Real std fds are never modified, so callers can still fall - * back to a direct connection. + * pipes closed and original console flags restored, allowing fallback. */ static int mux_relay_prepare(struct mux_relay *r, int nfds, int passed_fd[3]) { - int i, p[2]; + int i, p[2], flags; memset(r, 0, sizeof(*r)); r->nent = nfds; @@ -1899,6 +1887,10 @@ mux_relay_prepare(struct mux_relay *r, int nfds, int passed_fd[3]) if (!isatty(i)) continue; + if ((flags = fcntl(i, F_GETFL, 0)) == -1) { + mux_relay_close_all(r); + return -1; + } if (pipe(p) == -1) { error_f("pipe: %s", strerror(errno)); mux_relay_close_all(r); @@ -1906,6 +1898,7 @@ mux_relay_prepare(struct mux_relay *r, int nfds, int passed_fd[3]) } e->is_console = 1; e->console_fd = i; + e->console_flags = flags; if (i == STDIN_FILENO) { /* console -> master: master reads the pipe read end */ e->is_input = 1; @@ -1919,8 +1912,11 @@ mux_relay_prepare(struct mux_relay *r, int nfds, int passed_fd[3]) } passed_fd[i] = e->passed; r->active = 1; - (void)fcntl(e->local_pipe, F_SETFL, O_NONBLOCK); - (void)fcntl(e->console_fd, F_SETFL, O_NONBLOCK); + if (fcntl(e->local_pipe, F_SETFL, O_NONBLOCK) == -1 || + fcntl(e->console_fd, F_SETFL, flags | O_NONBLOCK) == -1) { + mux_relay_close_all(r); + return -1; + } } return 0; } @@ -1954,9 +1950,12 @@ mux_relay_close_all(struct mux_relay *r) e->passed = -1; } if (!e->pipe_closed) { - close(e->local_pipe); + if (w32_close_mux_pipe(e->local_pipe, 1) == -1) + error_f("close relay pipe: %s", strerror(errno)); e->pipe_closed = 1; } + if (fcntl(e->console_fd, F_SETFL, e->console_flags) == -1) + error_f("restore console flags: %s", strerror(errno)); } memset(r, 0, sizeof(*r)); } @@ -2018,6 +2017,7 @@ mux_relay_pump(int fd, struct mux_relay *r, u_int sid, int rd_idx[3], wr_idx[3]; char *e; time_t drain_deadline = 0; + double next_winsize = 0; if ((m = sshbuf_new()) == NULL) fatal_f("sshbuf_new"); @@ -2027,10 +2027,13 @@ mux_relay_pump(int fd, struct mux_relay *r, u_int sid, if (muxclient_terminate) break; - if (muxclient_winch) { + /* Resize events can be absent under redirection or backpressure. + * Check dimensions too; unchanged sizes are not sent. */ + if (!draining && tty_flag && + (muxclient_winch || monotime_double() >= next_winsize)) { muxclient_winch = 0; - if (tty_flag) - mux_client_send_winsize(fd); + next_winsize = monotime_double() + 0.2; + mux_client_send_winsize(fd); } ctl_idx = -1; @@ -2082,8 +2085,8 @@ mux_relay_pump(int fd, struct mux_relay *r, u_int sid, /* propagate console EOF to the master by closing the pipe */ if (ent->is_input && ent->rd_done && ent->buf_len == 0 && !ent->pipe_closed) { - close(ent->local_pipe); - ent->pipe_closed = 1; + if (w32_close_mux_pipe(ent->local_pipe, 0) == 0) + ent->pipe_closed = 1; } } @@ -3044,7 +3047,7 @@ muxclient(const char *path) if (errno == ECONNREFUSED && options.control_master != SSHCTL_MASTER_NO) { debug("Stale control socket %.100s, unlinking", path); - unlink(path); + unix_unlink(path); } else if (errno == ENOENT) { debug("Control socket \"%.100s\" does not exist", path); } else { @@ -3054,6 +3057,16 @@ muxclient(const char *path) close(sock); return -1; } +#ifdef WINDOWS + /* A pipe DACL protects the creator, not clients of a squatted name. */ + { + uid_t uid; + gid_t gid; + + if (getpeereid(sock, &uid, &gid) == -1 || uid != geteuid()) + fatal("Control socket peer authentication failed for %.100s", path); + } +#endif set_nonblock(sock); /* Timeout on initial connection only. */ diff --git a/ssh.c b/ssh.c index d4518e0ef1fc..2f936355a33b 100644 --- a/ssh.c +++ b/ssh.c @@ -190,12 +190,15 @@ static void main_sigchld_handler(int); #ifdef WINDOWS static int ssh_controlpersist_spawn(struct ssh *); /* ControlPersist spawn/detach primitives, in contrib/win32/win32compat/misc.c */ -intptr_t w32_spawn_control_master(char *const args[], intptr_t *ready_event); +intptr_t w32_spawn_control_master(char *const args[], intptr_t *ready_event, + intptr_t *startup_job); int w32_wait_controlpersist_ready(intptr_t proc, intptr_t ready_event, int timeout_ms); void w32_close_handle(intptr_t h); int w32_is_controlpersist_master(void); void w32_signal_controlpersist_ready(void); +int w32_finish_controlpersist_startup(intptr_t proc, intptr_t startup_job, + int ready); void w32_detach_console(void); #endif @@ -685,6 +688,10 @@ main(int ac, char **av) /* Ensure that fds 0, 1 and 2 are open or directed to /dev/null */ sanitise_stdfd(); +#ifdef WINDOWS + /* Consume startup state before configuration can run Match exec helpers. */ + (void)w32_is_controlpersist_master(); +#endif /* * Discard other fds that are hanging around. These can cause problem @@ -1429,6 +1436,24 @@ main(int ac, char **av) log_verbose_add(options.log_verbose[j]); } +#ifdef WINDOWS + /* Parse the passenger invocation normally, then suppress its session in + * the spawned master. Prepending -N would conflict with -s/SessionType. */ + if (w32_is_controlpersist_master()) { + options.control_master = SSHCTL_MASTER_YES; + options.session_type = SESSION_TYPE_NONE; + options.request_tty = REQUEST_TTY_NO; + options.stdin_null = 1; + options.fork_after_authentication = 0; + options.permit_local_command = 0; + free(options.stdio_forward_host); + options.stdio_forward_host = NULL; + free(options.remote_command); + options.remote_command = NULL; + sshbuf_reset(command); + } +#endif + if (options.request_tty == REQUEST_TTY_YES || options.request_tty == REQUEST_TTY_FORCE) tty_flag = 1; @@ -1891,7 +1916,7 @@ main(int ac, char **av) pwfree(pw); if (options.control_path != NULL && muxserver_sock != -1) - unlink(options.control_path); + unix_unlink(options.control_path); /* Kill ProxyCommand if it is running. */ ssh_kill_proxy_command(); @@ -1914,9 +1939,8 @@ main(int ac, char **av) static int ssh_controlpersist_spawn(struct ssh *ssh) { - char **args = NULL; - intptr_t proc, ready_event; - int i, n, r, ret = 0, sock; + intptr_t proc, ready_event, startup_job; + int r, sock; /* only the auto-master, no-existing-master, session case applies */ if (!options.control_persist || options.control_path == NULL || @@ -1925,43 +1949,43 @@ ssh_controlpersist_spawn(struct ssh *ssh) options.control_master != SSHCTL_MASTER_AUTO_ASK)) return 0; - /* args = forced master overrides + this invocation's args (saved_av) */ - for (n = 0; saved_av[n] != NULL; n++) - ; - args = xcalloc(n + 3, sizeof(*args)); - args[0] = "-oControlMaster=yes"; - args[1] = "-oSessionType=none"; - for (i = 1; i < n; i++) /* skip saved_av[0] (argv0) */ - args[i + 1] = saved_av[i]; - args[n + 1] = NULL; + /* A detached master cannot reliably obtain per-passenger consent. */ + if (options.control_master == SSHCTL_MASTER_AUTO_ASK) { + logit("ControlPersist with ControlMaster=autoask is not supported " + "on Windows; using a direct connection"); + return 0; + } debug_f("starting persistent mux master for %s", options.control_path); - proc = w32_spawn_control_master(args, &ready_event); - free(args); + proc = w32_spawn_control_master(saved_av + 1, &ready_event, &startup_job); if (proc == -1) { error("could not start persistent control master"); return 0; } /* - * Wait for the master to signal (via the named ready event) that it + * Wait for the master to signal (via the inherited ready event) that it * has authenticated and its control socket is up, then connect. * Generous timeout: interactive auth may prompt. */ - if ((r = w32_wait_controlpersist_ready(proc, ready_event, - 120 * 1000)) == 1) { - if ((sock = muxclient(options.control_path)) >= 0) { - /* SSHMUX_COMMAND_PROXY returns the socket */ - ssh_packet_set_connection(ssh, sock, sock); - ssh_packet_set_mux(ssh); - ret = 1; - } - } else + r = w32_wait_controlpersist_ready(proc, ready_event, 120 * 1000); + if (r != 1) debug_f("persistent master %s", r == 0 ? "exited before it was ready" : "was not ready in time"); + /* Release ownership before muxclient(), which normally exits. Failure + * cancels only this startup and its descendants, never another master. */ + r = w32_finish_controlpersist_startup(proc, startup_job, r == 1); w32_close_handle(ready_event); w32_close_handle(proc); - return ret; + if (r == -1) + fatal("could not clean up persistent master startup"); + /* A competing startup may have won the bind even if our child failed. */ + if ((sock = muxclient(options.control_path)) >= 0) { + ssh_packet_set_connection(ssh, sock, sock); + ssh_packet_set_mux(ssh); + return 1; + } + return 0; } #endif /* WINDOWS */ @@ -2391,9 +2415,14 @@ ssh_session2(struct ssh *ssh, const struct ssh_conn_info *cinfo) * governs our lifetime. Windows has no fork(), so the POSIX * backgrounding below does not apply. */ - if (w32_is_controlpersist_master() && muxserver_sock != -1) { - w32_signal_controlpersist_ready(); + if (w32_is_controlpersist_master()) { + if (muxserver_sock == -1) + fatal("persistent master could not establish its control socket"); + /* Drop startup-only diagnostics before publishing readiness. */ + if (stdfd_devnull(1, 1, 1) == -1) + fatal_f("stdfd_devnull failed"); w32_detach_console(); + w32_signal_controlpersist_ready(); } #else /* From 37f0916c7b87185385abf1cf363edae0dad05065 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 15 Sep 2026 00:44:56 +1000 Subject: [PATCH 14/14] regress: cover Windows mux safety and document its protocol Exercise pipe-name boundaries, peer/fd validation, filesystem preservation, listener cancellation and blocked relay writes in win32compat unit tests. Strengthen Pester mux-use assertions, isolate forwarding ports, clean up timed-out processes, and cover autoask, subsystem startup, SFTP reuse and stdio-forward teardown. Document pipe naming, the trust boundary, handle transfer and tty extension in PROTOCOL.mux. Local validation: Release x64 build and artifact check; the new compat cases pass before an unrelated symlink-privilege failure. The isolated loopback/ConPTY matrix covers persistence, SFTP/SCP reuse, -W, redirected console dimensions, resize, passphrase auth and lower-integrity rejection. Pester syntax was checked; full Windows CI and POSIX tests remain pending. --- PROTOCOL.mux | 49 ++++++ regress/pesterTests/Multiplex.Tests.ps1 | 161 +++++++++++++++++-- regress/unittests/win32compat/socket_tests.c | 144 +++++++++++++++++ 3 files changed, 341 insertions(+), 13 deletions(-) diff --git a/PROTOCOL.mux b/PROTOCOL.mux index fef2e13d436f..7f5e868add3b 100644 --- a/PROTOCOL.mux +++ b/PROTOCOL.mux @@ -279,6 +279,55 @@ The MUX_S_PERMISSION_DENIED and MUX_S_FAILURE include a reason: #define MUX_FWD_REMOTE 2 #define MUX_FWD_DYNAMIC 3 +12. Windows transport and console relay + +On Windows, filesystem-style ControlPaths name local pipes, not filesystem +objects. The pipe name is "\\.\pipe\openssh-uds-v2-" followed by the lower-case +hex SHA-256 of the absolute, lexically normalized UTF-16LE path (including +its terminating NUL) and the current user's binary SID. Path case is +preserved. Literal "\\.\pipe\..." names bypass this mapping. There is no +fallback to the earlier, collision-prone path mangling; restart development +masters after upgrading. Endpoint cleanup closes handles and never unlinks +ControlPath from the filesystem. + +Both peers must have the same Windows user SID, integrity level and elevation +state. The client authenticates the pipe server before its hello. Descriptor +transfer replaces SCM_RIGHTS with a packed, little-endian record: + + uint32 0x77465044 (magic) + uint32 sender process ID + uint64 sender handle + uint32 I/O type (2 = overlapped, 3 = synchronous) + +The receiver checks the pipe peer against the claimed PID and duplicates +from that authenticated process handle. Only file/device/pipe handles are +supported; sockets and console handles cannot be passed. The sender must +keep its handles open until MUX_S_SESSION_OPENED acknowledges the transfer. + +Windows masters advertise "tty-relay@win32.openssh.com" in their hello with +an empty value. Clients replace console descriptors with pipes and relay +console I/O locally. After seeing this extension, a client may send: + + uint32 MUX_C_WINSIZE (0x1000000e) + uint32 request id + uint32 columns + uint32 rows + uint32 x pixels + uint32 y pixels + +There is no reply and the request ID is not consumed. A message preceding +MUX_C_NEW_SESSION supplies the initial pty dimensions; later messages send +window-change requests to that session. Zero pixel dimensions mean unknown. +A client requesting a tty must fall back to a direct connection if the +master does not advertise this extension. + +Windows ControlPersist auto-starts a separate, initially owned master and +waits for authentication/listener readiness before sending a mux request. +Startup uses Windows 10 job-list process attributes; failure falls back to +a direct connection. ControlMaster=autoask does not auto-start a persistent +master on Windows: it falls back directly without weakening consent policy. +SFTP/SCP retain their upstream ControlMaster=no policy (reuse only). + XXX TODO XXX extended status (e.g. report open channels / forwards) XXX lock (maybe) diff --git a/regress/pesterTests/Multiplex.Tests.ps1 b/regress/pesterTests/Multiplex.Tests.ps1 index dee153151d55..b58a8164ea66 100644 --- a/regress/pesterTests/Multiplex.Tests.ps1 +++ b/regress/pesterTests/Multiplex.Tests.ps1 @@ -18,13 +18,30 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" { $null = New-Item $testDir -ItemType directory -Force -ErrorAction SilentlyContinue } - #skip on ps 2 because non-interactive cmd require a ENTER before it returns on ps2 + # Non-interactive commands require Enter before returning on PS 2. $skip = $IsWindows -and ($PSVersionTable.PSVersion.Major -le 2) $controlPath = Join-Path $testDir "mux_ctl" $sshExe = (Get-Command ssh).Source $script:masterProc = $null + function Wait-MuxClient + { + param($Process, [int]$Timeout = 30000) + if (-not $Process.WaitForExit($Timeout)) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + throw "Mux client $($Process.Id) timed out" + } + $Process.ExitCode | Should Be 0 + } + + function Assert-MuxSession + { + param([string]$Log) + $Log | Should Contain "master session id" + $Log | Should Not Contain "opening a separate connection" + } + function Start-MuxMaster { param([string]$MasterLog) @@ -80,32 +97,43 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" } It "$tC.$tI - remote command through master returns output" -skip:$skip { - ssh -S $controlPath test_target echo mux-session-1234 | Set-Content $stdoutFile + ssh -v -S $controlPath test_target echo mux-session-1234 2>$stderrFile | Set-Content $stdoutFile $stdoutFile | Should Contain "mux-session-1234" + Assert-MuxSession $stderrFile } It "$tC.$tI - exit codes propagate through master" -skip:$skip { foreach ($i in (0,1,4,5,44)) { - ssh -S $controlPath test_target exit $i + ssh -v -S $controlPath test_target exit $i 2>$stderrFile $LASTEXITCODE | Should Be $i + Assert-MuxSession $stderrFile } } It "$tC.$tI - stdin passes through master" -skip:$skip { - iex "cmd /c `"echo mux-stdin-data | ssh -S $controlPath test_target findstr mux-stdin > $stdoutFile`"" + iex "cmd /c `"echo mux-stdin-data | ssh -v -S $controlPath test_target findstr mux-stdin > $stdoutFile 2> $stderrFile`"" $stdoutFile | Should Contain "mux-stdin-data" + Assert-MuxSession $stderrFile } It "$tC.$tI - sessions share the master's single TCP connection" -skip:$skip { - ssh -S $controlPath test_target echo again | Set-Content $stdoutFile + ssh -v -S $controlPath test_target echo again 2>$stderrFile | Set-Content $stdoutFile $stdoutFile | Should Contain "again" + Assert-MuxSession $stderrFile $conns = @(Get-NetTCPConnection -OwningProcess $script:masterProc.Id -State Established -ErrorAction SilentlyContinue | Where-Object { $_.RemotePort -eq $port }) $conns.Count | Should Be 1 } It "$tC.$tI - -O forward adds a working local forwarding" -skip:$skip { - $fwdPort = 5433 - iex "cmd /c `"ssh -S $controlPath -O forward -L $($fwdPort):127.0.0.1:$port test_target 2> $stderrFile`"" + # Reserve an ephemeral candidate and retry if another process wins it. + for ($attempt = 0; $attempt -lt 5; $attempt++) { + $reservation = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + $reservation.Start() + $fwdPort = $reservation.LocalEndpoint.Port + $reservation.Stop() + ssh -S $controlPath -O forward -L "$($fwdPort):127.0.0.1:$port" test_target 2>$stderrFile + if ($LASTEXITCODE -eq 0) { break } + } $LASTEXITCODE | Should Be 0 # the tunnel targets the test sshd; reading its banner proves end-to-end flow $client = New-Object System.Net.Sockets.TcpClient("127.0.0.1", $fwdPort) @@ -190,10 +218,11 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" It "$tC.$tI - first connection auto-starts a persistent master" -skip:$skip { # Start-Process (own console) so the spawned master does not block us $p = Start-Process -FilePath $sshExe ` - -ArgumentList ($cpOpts + @("test_target", "echo cp-first")) ` - -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -PassThru - $p.WaitForExit(30000) | Should Be $true + -ArgumentList ($cpOpts + @("-v", "test_target", "echo cp-first")) ` + -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -PassThru + Wait-MuxClient $p $stdoutFile | Should Contain "cp-first" + Assert-MuxSession $stderrFile # the master should have persisted and answer control requests ssh -o ControlPath="$cpPath" -O check test_target 2>$null $LASTEXITCODE | Should Be 0 @@ -201,10 +230,11 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" It "$tC.$tI - subsequent connection reuses the persistent master" -skip:$skip { $p = Start-Process -FilePath $sshExe ` - -ArgumentList @("-o", "ControlPath=`"$cpPath`"", "test_target", "echo cp-reuse") ` - -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -PassThru - $p.WaitForExit(20000) | Should Be $true + -ArgumentList @("-v", "-o", "ControlPath=`"$cpPath`"", "test_target", "echo cp-reuse") ` + -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -PassThru + Wait-MuxClient $p 20000 $stdoutFile | Should Contain "cp-reuse" + Assert-MuxSession $stderrFile } It "$tC.$tI - -O exit stops the persistent master" -skip:$skip { @@ -214,4 +244,109 @@ Describe "E2E scenarios for connection multiplexing (ControlMaster)" -Tags "CI" $LASTEXITCODE | Should Not Be 0 } } + + Context "$tC - mux safety regressions" { + BeforeAll {$tI=1} + AfterAll {$tC++} + + It "$tC.$tI - control endpoint cleanup preserves a regular file" -skip:$skip { + "sentinel-$PID" | Set-Content $controlPath + try { + foreach ($operation in @("exit", "stop")) { + Start-MuxMaster -MasterLog $logFile | Should Be $true + ssh -S $controlPath -O $operation test_target 2>$null + $LASTEXITCODE | Should Be 0 + if ($operation -eq "exit") { + $script:masterProc.WaitForExit(10000) | Should Be $true + } + Stop-MuxMaster + (Get-Content $controlPath) | Should Be "sentinel-$PID" + } + } finally { + Stop-MuxMaster + Remove-Item -LiteralPath $controlPath -ErrorAction SilentlyContinue + } + } + + It "$tC.$tI - autoask never becomes an unprompted persistent master" -skip:$skip { + ssh -v -o BatchMode=yes -o ControlMaster=autoask -o ControlPersist=yes ` + -S $controlPath test_target echo consent-preserved 2>$stderrFile | Set-Content $stdoutFile + $stdoutFile | Should Contain "consent-preserved" + $stderrFile | Should Contain "using a direct connection" + ssh -S $controlPath -O check test_target 2>$null + $LASTEXITCODE | Should Not Be 0 + } + + It "$tC.$tI - subsystem startup persists and SFTP reuses the master" -skip:$skip { + $batch = Join-Path $testDir "mux-sftp.batch" + $inputFile = Join-Path $testDir "mux-sftp-init" + "pwd`nquit" | Set-Content $batch -Encoding ASCII + # SFTP v3 INIT followed by EOF: no remote filesystem changes. + [System.IO.File]::WriteAllBytes($inputFile, [byte[]](0,0,0,5,1,0,0,0,3)) + try { + # sftp/scp force ControlMaster=no on every platform. Exercise + # explicit ssh -s startup, then the utilities' reuse path. + $p = Start-Process -FilePath $sshExe ` + -ArgumentList @("-v", "-oControlMaster=auto", "-oControlPersist=30", ` + "-S", "`"$controlPath`"", "-s", "test_target", "sftp") ` + -WindowStyle Hidden -RedirectStandardInput $inputFile ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -PassThru + Wait-MuxClient $p + Assert-MuxSession $stderrFile + $p = Start-Process -FilePath (Join-Path (Split-Path $sshExe) "sftp.exe") ` + -ArgumentList @("-v", "-b", "`"$batch`"", "-oControlMaster=auto", ` + "-oControlPersist=30", "-oControlPath=`"$controlPath`"", "test_target") ` + -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -PassThru + Wait-MuxClient $p + Assert-MuxSession $stderrFile + ssh -S $controlPath -O check test_target 2>$null + $LASTEXITCODE | Should Be 0 + ssh -v -S $controlPath test_target echo after-sftp 2>$stderrFile | Set-Content $stdoutFile + $stdoutFile | Should Contain "after-sftp" + Assert-MuxSession $stderrFile + } finally { + ssh -S $controlPath -O exit test_target 2>$null + Remove-Item -LiteralPath $batch -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $inputFile -ErrorAction SilentlyContinue + } + } + + It "$tC.$tI - stdio forwarding exits after the remote endpoint closes" -skip:$skip { + $inputFile = Join-Path $testDir "mux-stdio-input" + "mux-stdio-eof" | Set-Content $inputFile -Encoding ASCII + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + $client = $null + $p = $null + try { + $listener.Start() + $accept = $listener.AcceptTcpClientAsync() + $p = Start-Process -FilePath $sshExe ` + -ArgumentList @("-v", "-oControlMaster=auto", "-oControlPersist=30", ` + "-S", "`"$controlPath`"", "-W", "127.0.0.1:$($listener.LocalEndpoint.Port)", "test_target") ` + -WindowStyle Hidden -RedirectStandardInput $inputFile ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -PassThru + $accept.Wait(10000) | Should Be $true + $client = $accept.Result + $stream = $client.GetStream() + $stream.ReadTimeout = 10000 + $stream.WriteTimeout = 10000 + $buf = New-Object byte[] 1024 + while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { + $stream.Write($buf, 0, $n) + } + $client.Close() + Wait-MuxClient $p 10000 + $stdoutFile | Should Contain "mux-stdio-eof" + Assert-MuxSession $stderrFile + ssh -S $controlPath -O check test_target 2>$null + $LASTEXITCODE | Should Be 0 + } finally { + if ($client) { $client.Close() } + $listener.Stop() + if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force } + ssh -S $controlPath -O exit test_target 2>$null + Remove-Item -LiteralPath $inputFile -ErrorAction SilentlyContinue + } + } + } } diff --git a/regress/unittests/win32compat/socket_tests.c b/regress/unittests/win32compat/socket_tests.c index 8169cf4b0822..ad13e87fc534 100644 --- a/regress/unittests/win32compat/socket_tests.c +++ b/regress/unittests/win32compat/socket_tests.c @@ -9,6 +9,9 @@ #include #include #include +#include +#include "misc.h" +#include "monitor_fdpass.h" #include "../test_helper/test_helper.h" #include "tests.h" @@ -16,6 +19,8 @@ #define BACKLOG 2 #define SMALL_RECV_BUF_SIZE 128 +int w32_close_mux_pipe(int, int); + #pragma warning(disable:4267) int listen_fd, accept_fd, connect_fd, retValue; @@ -700,6 +705,144 @@ socket_typical_ssh_payload_tests() { freeaddrinfo(servinfo); } +static int +mux_test_bind(const char *path) +{ + struct sockaddr_un addr; + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + + ASSERT_INT_NE(fd, -1); + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + ASSERT_INT_LT(strlcpy(addr.sun_path, path, sizeof(addr.sun_path)), + sizeof(addr.sun_path)); + ASSERT_INT_EQ(bind(fd, (struct sockaddr *)&addr, sizeof(addr)), 0); + return fd; +} + +static void +mux_pipe_tests(void) +{ + char path[128], other[128], absolute[PATH_MAX]; + struct sockaddr_un addr; + int listener, second, client, peer, p[2], received, i, sentinel; + uid_t uid; + gid_t gid; + char byte; + /* Same packed layout as the descriptor-transfer wire record. */ + unsigned char invalid[20] = { 0 }; + unsigned int magic = 0x77465044, bad_pid = 0, type = 2; + unsigned __int64 handle = 1; + + TEST_START("mux pipe path boundaries and absolute normalization"); + snprintf(path, sizeof(path), "mux-%lu/a/b-c", GetCurrentProcessId()); + snprintf(other, sizeof(other), "mux-%lu/a-b/c", GetCurrentProcessId()); + listener = mux_test_bind(path); + second = mux_test_bind(other); + ASSERT_INT_EQ(close(second), 0); + ASSERT_INT_EQ(close(listener), 0); + + snprintf(path, sizeof(path), "mux-%lu-ctl", GetCurrentProcessId()); + ASSERT_INT_NE(GetFullPathNameA(path, sizeof(absolute), absolute, NULL), 0); + listener = mux_test_bind(path); + ASSERT_INT_EQ(listen(listener, 1), 0); + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strlcpy(addr.sun_path, absolute, sizeof(addr.sun_path)); + client = socket(AF_UNIX, SOCK_STREAM, 0); + ASSERT_INT_NE(client, -1); + ASSERT_INT_EQ(connect(client, (struct sockaddr *)&addr, sizeof(addr)), 0); + peer = accept(listener, NULL, NULL); + ASSERT_INT_NE(peer, -1); + ASSERT_INT_EQ(getpeereid(client, &uid, &gid), 0); + ASSERT_INT_EQ(uid, geteuid()); + ASSERT_INT_EQ(getpeereid(peer, &uid, &gid), 0); + TEST_DONE(); + + TEST_START("mux descriptor transfer and forged sender rejection"); + ASSERT_INT_EQ(pipe(p), 0); + ASSERT_INT_EQ(mm_send_fd(client, p[0]), 0); + received = mm_receive_fd(peer); + ASSERT_INT_NE(received, -1); + ASSERT_INT_EQ(write(p[1], "x", 1), 1); + ASSERT_INT_EQ(read(received, &byte, 1), 1); + ASSERT_CHAR_EQ(byte, 'x'); + ASSERT_INT_EQ(close(received), 0); + ASSERT_INT_EQ(close(p[0]), 0); + ASSERT_INT_EQ(close(p[1]), 0); + memcpy(invalid, &magic, 4); + memcpy(invalid + 4, &bad_pid, 4); + memcpy(invalid + 8, &handle, 8); + memcpy(invalid + 16, &type, 4); + ASSERT_INT_EQ(write(client, invalid, sizeof(invalid)), sizeof(invalid)); + ASSERT_INT_EQ(mm_receive_fd(peer), -1); + ASSERT_INT_EQ(errno, EPERM); + ASSERT_INT_EQ(close(peer), 0); + ASSERT_INT_EQ(close(client), 0); + ASSERT_INT_EQ(close(listener), 0); + TEST_DONE(); + + TEST_START("mux endpoint cleanup preserves filesystem data"); + sentinel = open(path, O_CREAT | O_EXCL | O_RDWR, 0600); + ASSERT_INT_NE(sentinel, -1); + ASSERT_INT_EQ(write(sentinel, "s", 1), 1); + listener = mux_test_bind(path); + ASSERT_INT_EQ(listen(listener, 1), 0); + ASSERT_INT_EQ(close(listener), 0); + ASSERT_INT_EQ(unix_unlink(path), 0); + ASSERT_INT_EQ(lseek(sentinel, 0, SEEK_SET), 0); + ASSERT_INT_EQ(read(sentinel, &byte, 1), 1); + ASSERT_CHAR_EQ(byte, 's'); + ASSERT_INT_EQ(getpeereid(sentinel, &uid, &gid), -1); + ASSERT_INT_EQ(close(sentinel), 0); + ASSERT_INT_EQ(unlink(path), 0); + TEST_DONE(); + + TEST_START("mux identical relative paths in different working directories"); + { + wchar_t cwd[PATH_MAX]; + /* w32_getcwd lowercases; preserve the case used by path hashing. */ + ASSERT_INT_NE(GetCurrentDirectoryW(PATH_MAX, cwd), 0); + listener = mux_test_bind(path); + ASSERT_INT_EQ(chdir(".."), 0); + second = mux_test_bind(path); + ASSERT_INT_NE(SetCurrentDirectoryW(cwd), 0); + ASSERT_INT_EQ(close(second), 0); + ASSERT_INT_EQ(close(listener), 0); + } + TEST_DONE(); + + TEST_START("mux pending listener cancellation and reconnect"); + for (i = 0; i < 100; i++) { + listener = mux_test_bind(path); + ASSERT_INT_EQ(listen(listener, 1), 0); + if (i & 1) { + client = socket(AF_UNIX, SOCK_STREAM, 0); + ASSERT_INT_NE(client, -1); + ASSERT_INT_EQ(connect(client, (struct sockaddr *)&addr, + sizeof(addr)), 0); + ASSERT_INT_EQ(close(client), 0); + } + ASSERT_INT_EQ(close(listener), 0); + } + TEST_DONE(); + + TEST_START("mux relay shutdown cancels a backpressured pipe write"); + { + char buf[65536] = { 0 }; + ULONGLONG started; + ASSERT_INT_EQ(pipe(p), 0); + ASSERT_INT_EQ(fcntl(p[1], F_SETFL, O_NONBLOCK), 0); + ASSERT_INT_EQ(write(p[1], buf, sizeof(buf)), sizeof(buf)); + ASSERT_INT_EQ(w32_close_mux_pipe(p[1], 0), 1); + started = GetTickCount64(); + ASSERT_INT_EQ(w32_close_mux_pipe(p[1], 1), 0); + ASSERT_INT_LT(GetTickCount64() - started, 2000); + ASSERT_INT_EQ(close(p[0]), 0); + } + TEST_DONE(); +} + void socket_tests() { @@ -708,4 +851,5 @@ socket_tests() socket_nonblocking_io_tests(); socket_select_tests(); socket_typical_ssh_payload_tests(); + mux_pipe_tests(); }