diff --git a/enums/op.in b/enums/op.in
index 5b5e21b..5013892 100644
--- a/enums/op.in
+++ b/enums/op.in
@@ -28,3 +28,4 @@ set-xattr, 0
 create-xattr, 1
 replace-xattr, 1
 closefrom, 0
+close-range, 0
diff --git a/ports/linux/guts/close_range.c b/ports/linux/guts/close_range.c
index 4bd2fe1..15258be 100644
--- a/ports/linux/guts/close_range.c
+++ b/ports/linux/guts/close_range.c
@@ -6,14 +6,55 @@
  * int close_range(unsigned int lowfd, unsigned int maxfd, int flags)
  *      int rc = -1;
  */
+	pseudo_msg_t *msg;
 
-        (void) lowfd;
-        (void) maxfd;
-        (void) flags;
-        /* for now pretend the kernel doesn't support it regardless 
-           which users are supposed to be able to handle */
-        errno = ENOSYS;
-        rc = -1;
+	/* The kernel rejects both of these outright, so check before doing
+	 * anything: otherwise a call which should have failed cleanly takes
+	 * descriptors with it on the way out.
+	 */
+	if (flags & ~(CLOSE_RANGE_UNSHARE | CLOSE_RANGE_CLOEXEC)) {
+		errno = EINVAL;
+		return -1;
+	}
+	if (lowfd > maxfd) {
+		errno = EINVAL;
+		return -1;
+	}
+
+	/* CLOSE_RANGE_UNSHARE has to take effect before anything is closed:
+	 * while the descriptor table is still shared, closing a descriptor
+	 * would close it for everyone sharing the table, not just for us.
+	 */
+	if (flags & CLOSE_RANGE_UNSHARE) {
+		if (unshare(CLONE_FILES) == -1)
+			return -1;
+		flags &= ~CLOSE_RANGE_UNSHARE;
+	}
+
+	/* CLOSE_RANGE_CLOEXEC closes nothing, it only marks descriptors, and
+	 * pseudo's own are close-on-exec already (pseudo_fd() sets that on
+	 * every one of them), so there is nothing here to protect.
+	 */
+	if (flags & CLOSE_RANGE_CLOEXEC)
+		return real_close_range(lowfd, maxfd, flags);
+
+	/* Descriptors are ints, so a range starting above INT_MAX cannot hold
+	 * any of pseudo's own and there is nothing to step around. Worth its
+	 * own case because pseudo_client_op() takes the low end as an int.
+	 */
+	if (lowfd > INT_MAX)
+		return real_close_range(lowfd, maxfd, flags);
+
+	/* Same shape as closefrom(): the client op closes the descriptors its
+	 * own are mixed in with by hand, stepping around the ones pseudo needs
+	 * to keep, and hands back the first fd the kernel can safely be turned
+	 * loose on.
+	 */
+	msg = pseudo_client_op(OP_CLOSE_RANGE, 0, lowfd, -1, 0, 0, maxfd);
+	if (maxfd >= (unsigned int) msg->fd)
+		rc = real_close_range(msg->fd, maxfd, flags);
+	else
+		rc = 0;
 
 /*      return rc;
  * }
diff --git a/ports/linux/portdefs.h b/ports/linux/portdefs.h
index 19bb232..1f1a41a 100644
--- a/ports/linux/portdefs.h
+++ b/ports/linux/portdefs.h
@@ -35,6 +35,22 @@ GLIBC_COMPAT_SYMBOL(memcpy,2.0);
 #include <sys/prctl.h>
 #include <linux/seccomp.h>
 
+/* close_range()'s flags, and unshare(), are only declared by glibc under
+ * _GNU_SOURCE, which pseudo does not build with. <linux/close_range.h> is
+ * not an option either: it is absent on hosts with pre-5.9 kernel headers,
+ * the same problem SYS_openat2 has below. Both values are kernel ABI.
+ */
+#ifndef CLOSE_RANGE_UNSHARE
+#define CLOSE_RANGE_UNSHARE (1U << 1)
+#endif
+#ifndef CLOSE_RANGE_CLOEXEC
+#define CLOSE_RANGE_CLOEXEC (1U << 2)
+#endif
+#ifndef CLONE_FILES
+#define CLONE_FILES 0x00000400
+#endif
+extern int unshare(int flags);
+
 #ifndef _STAT_VER
 #if defined (__aarch64__) || defined (__riscv)
 #define _STAT_VER 0
diff --git a/pseudo_client.c b/pseudo_client.c
index 7041366..1acd948 100644
--- a/pseudo_client.c
+++ b/pseudo_client.c
@@ -993,6 +993,28 @@ pseudo_client_closefrom(int fd) {
 	}
 }
 
+/* Like pseudo_client_closefrom(), but bounded: close_range() has a top end,
+ * so entries above it have to be left alone.
+ */
+static void
+pseudo_client_close_range(int lowfd, unsigned int maxfd) {
+	int i, top;
+
+	if (lowfd < 0 || lowfd >= nfds)
+		return;
+
+	top = (maxfd >= (unsigned int) nfds) ? nfds - 1 : (int) maxfd;
+	for (i = lowfd; i <= top; ++i) {
+		free(fd_paths[i]);
+		fd_paths[i] = 0;
+
+		if (i < linked_nfds) {
+			free(linked_fd_paths[i]);
+			linked_fd_paths[i] = 0;
+		}
+	}
+}
+
 /* spawn server */
 static int
 client_spawn_server(void) {
@@ -1633,6 +1655,7 @@ pseudo_client_op(pseudo_op_t op, int access, int fd, int dirfd, const char *path
 	static size_t alloced_len = 0;
 	int strip_slash;
 	int startfd, i;
+	unsigned int close_range_maxfd = 0;
 
 #ifdef PSEUDO_PROFILING
 	struct timeval tv1_op, tv2_op;
@@ -1753,6 +1776,13 @@ pseudo_client_op(pseudo_op_t op, int access, int fd, int dirfd, const char *path
 	}
 #endif
 
+	if (op == OP_CLOSE_RANGE) {
+		va_list ap;
+		va_start(ap, buf);
+		close_range_maxfd = va_arg(ap, unsigned int);
+		va_end(ap);
+	}
+
 	if (op == OP_RENAME) {
 		va_list ap;
 		if (!path) {
@@ -1959,6 +1989,36 @@ pseudo_client_op(pseudo_op_t op, int access, int fd, int dirfd, const char *path
 		msg.fd = startfd;
 		do_request = 0;
 		break;
+	case OP_CLOSE_RANGE:
+		/* no request needed */
+		startfd = fd;
+		if (pseudo_util_debug_fd > startfd)
+			startfd = pseudo_util_debug_fd + 1;
+		if (pseudo_localstate_dir_fd > startfd)
+			startfd = pseudo_localstate_dir_fd + 1;
+		if (pseudo_pwd_fd > startfd)
+			startfd = pseudo_pwd_fd + 1;
+		if (pseudo_grp_fd > startfd)
+			startfd = pseudo_grp_fd + 1;
+		if (connect_fd > startfd)
+			startfd = connect_fd + 1;
+		/* the fds below startfd are the ones our own are mixed in
+		 * with, so close those by hand and skip the ones we need
+		 */
+		for (i = fd; i < startfd && (unsigned int) i <= close_range_maxfd; ++i) {
+			if (i == pseudo_util_debug_fd || i == pseudo_localstate_dir_fd || i == pseudo_pwd_fd ||
+					i == pseudo_grp_fd || i == connect_fd)
+				continue;
+			pseudo_client_close(i);
+			close(i);
+		}
+		if (close_range_maxfd >= (unsigned int) startfd)
+			pseudo_client_close_range(startfd, close_range_maxfd);
+		/* tell the caller to start at startfd instead of fd */
+		result = &msg;
+		msg.fd = startfd;
+		do_request = 0;
+		break;
 	case OP_CLOSE:
 		/* no request needed */
 		if (fd >= 0) {
