From 4cf48787f1b5d54c603426694f71dab8c04c32e7 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 12 Jul 2026 10:21:44 +1000 Subject: [PATCH 01/12] 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 b4c436864dc4..fb1d37b9162d 100644 --- a/contrib/win32/win32compat/w32fd.c +++ b/contrib/win32/win32compat/w32fd.c @@ -275,6 +275,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); } @@ -284,6 +287,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); } @@ -337,7 +343,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; @@ -345,11 +350,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; @@ -396,11 +425,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); @@ -411,9 +437,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]); @@ -795,8 +820,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; @@ -1004,6 +1030,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 a07727a8e743..d93f498fe737 100644 --- a/monitor_fdpass.c +++ b/monitor_fdpass.c @@ -48,6 +48,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) { @@ -183,3 +200,4 @@ mm_receive_fd(int sock) return -1; #endif } +#endif /* !WINDOWS */ diff --git a/mux.c b/mux.c index 415024f74e8f..aaa6bfdbfb0a 100644 --- a/mux.c +++ b/mux.c @@ -447,8 +447,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; @@ -1270,10 +1275,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 || @@ -1282,6 +1289,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 @@ -1338,6 +1367,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); @@ -2274,6 +2304,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 ea94d25106cf..4e098c6c0460 100644 --- a/ssh.c +++ b/ssh.c @@ -1393,6 +1393,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 9db890371f7a6796363f033e6842f1958773d647 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 12 Jul 2026 13:49:11 +1000 Subject: [PATCH 02/12] 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 5333ccbf2194f5fdaa7f869ea100fa658aecddc0 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 09:31:52 +1000 Subject: [PATCH 03/12] 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 | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++- ssh.c | 2 +- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/channels.c b/channels.c index 6caab482710e..ddc3b3084f02 100644 --- a/channels.c +++ b/channels.c @@ -4919,6 +4919,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 f3350a83b1fc..fccc46ed4d94 100644 --- a/clientloop.c +++ b/clientloop.c @@ -2687,7 +2687,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; @@ -2706,7 +2706,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 4bc7bcd7c4f2..45a3b1cbf0fd 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 aaa6bfdbfb0a..7cd4b6ec62d5 100644 --- a/mux.c +++ b/mux.c @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -116,10 +117,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 */ @@ -131,6 +140,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_S_OK 0x80000001 #define MUX_S_PERMISSION_DENIED 0x80000002 @@ -142,6 +152,14 @@ struct mux_master_state { #define MUX_S_TTY_ALLOC_FAIL 0x80000008 #define MUX_S_PROXY 0x8000000f +/* + * 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 @@ -168,6 +186,10 @@ static int mux_master_process_stop_listening(struct ssh *, u_int, Channel *, struct sshbuf *, struct sshbuf *); static int mux_master_process_proxy(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; @@ -183,6 +205,9 @@ static const struct { { MUX_C_NEW_STDIO_FWD, mux_master_process_stdio_fwd }, { MUX_C_STOP_LISTENING, mux_master_process_stop_listening }, { MUX_C_PROXY, mux_master_process_proxy }, +#ifdef WINDOWS + { MUX_C_WINSIZE, mux_master_process_winsize }, +#endif { 0, NULL } }; @@ -507,6 +532,56 @@ mux_master_process_alive_check(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) @@ -1172,7 +1247,12 @@ mux_master_read_cb(struct ssh *ssh, Channel *c) if ((r = sshbuf_put_u32(out, MUX_MSG_HELLO)) != 0 || (r = sshbuf_put_u32(out, SSHMUX_VER)) != 0) fatal_fr(r, "reply"); - /* no extensions */ +#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); @@ -1433,8 +1513,27 @@ mux_session_confirm(struct ssh *ssh, int id, int success, void *arg) fatal_fr(r, "send"); } +#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 */ @@ -1672,6 +1771,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 debug2("Unrecognised master extension \"%s\"", name); free(name); } diff --git a/ssh.c b/ssh.c index 4e098c6c0460..45e8bef81c7e 100644 --- a/ssh.c +++ b/ssh.c @@ -2245,7 +2245,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 d39f53a5280cb0559d453d43a3e6f5eb8ce1b212 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 14:48:02 +1000 Subject: [PATCH 04/12] 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 7cd4b6ec62d5..fe016fdcd67c 100644 --- a/mux.c +++ b/mux.c @@ -1721,6 +1721,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) { @@ -2025,6 +2411,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"); @@ -2046,6 +2436,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 || @@ -2078,10 +2483,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"); @@ -2090,7 +2503,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 || @@ -2111,20 +2524,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(); @@ -2132,7 +2554,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); @@ -2151,7 +2582,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; @@ -2204,6 +2646,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 @@ -2254,6 +2702,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"); @@ -2267,6 +2719,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 || @@ -2280,9 +2740,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)); @@ -2296,7 +2762,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 || @@ -2324,18 +2790,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 || @@ -2344,6 +2829,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 @@ -2411,19 +2902,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: @@ -2480,6 +2958,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 0b8e80287fe350c9898feb5fc0b0f4491885f5a1 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Mon, 13 Jul 2026 14:49:38 +1000 Subject: [PATCH 05/12] 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 41d72561f703d33d2f4380ba1e3a644f50cdaf1a Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 08:46:01 +1000 Subject: [PATCH 06/12] 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 45e8bef81c7e..83f2718fc03f 100644 --- a/ssh.c +++ b/ssh.c @@ -198,6 +198,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 @@ -1393,13 +1402,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 " @@ -1687,6 +1689,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 } /* @@ -1890,6 +1906,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) { @@ -2314,6 +2400,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 @@ -2337,6 +2436,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 c5a37d37b9408174885ae8d9b265c65dfa26671d Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 08:46:14 +1000 Subject: [PATCH 07/12] 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 7b70b0d73005e8d40a4e3a8a8b6a9eea6f598649 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 12:49:37 +1000 Subject: [PATCH 08/12] 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 b6bdd5999c73d9c32d774be97910a341a0ff843b Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 14:35:55 +1000 Subject: [PATCH 09/12] 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 fe016fdcd67c..d665fde411ea 100644 --- a/mux.c +++ b/mux.c @@ -1768,7 +1768,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 a186749135cf52426d9d5e80dbf235882598f962 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Tue, 14 Jul 2026 17:25:51 +1000 Subject: [PATCH 10/12] 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 d665fde411ea..4a210efaf21c 100644 --- a/mux.c +++ b/mux.c @@ -1805,7 +1805,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 65acd62c57a4b855c36a08950c4aaabe7d57bde9 Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 26 Jul 2026 01:37:45 +1000 Subject: [PATCH 11/12] 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 83f2718fc03f..bb2378ba3dd0 100644 --- a/ssh.c +++ b/ssh.c @@ -201,10 +201,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 @@ -1922,9 +1924,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 || @@ -1944,7 +1945,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"); @@ -1952,25 +1953,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; } @@ -2404,14 +2402,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 61bc0a65c065a63294a4deb6765ed17cdc5108ca Mon Sep 17 00:00:00 2001 From: Duncan Maitland Date: Sun, 26 Jul 2026 01:38:05 +1000 Subject: [PATCH 12/12] 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*/ }; /*