Skip to content

Commit b1039b8

Browse files
committed
Implement connections to TCP-based services
Both IPv4 and IPv6 are supported. The port or both host and port can be taken from the service argument instead of the symbolic link name. Of course, there are full unit tests. Fixes: QubesOS/qubes-issues#9037
1 parent 09d079b commit b1039b8

File tree

6 files changed

+232
-10
lines changed

6 files changed

+232
-10
lines changed

.gitlab-ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ checks:tests:
3131
variables:
3232
PYTEST_ADDOPTS: "--color=yes"
3333
script:
34-
- xvfb-run ./run-tests
34+
- DOCKER_BUG_BREAKS_IPV6=1 xvfb-run ./run-tests
3535
after_script:
3636
- (cd libqrexec; gcov *.c)
3737
- (cd daemon; gcov *.c)

libqrexec/exec.c

+128-6
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@
2727
#include <stddef.h>
2828
#include <limits.h>
2929

30-
#include <sys/socket.h>
3130
#include <sys/types.h>
31+
#include <sys/socket.h>
3232
#include <sys/stat.h>
3333
#include <sys/un.h>
3434
#include <sys/wait.h>
35+
#include <netdb.h>
3536
#include <unistd.h>
3637
#include <fcntl.h>
3738
#include "qrexec.h"
@@ -411,7 +412,7 @@ struct qrexec_parsed_command *parse_qubes_rpc_command(
411412
/* Parse service name ("qubes.Service") */
412413

413414
const char *const plus = memchr(start, '+', descriptor_len);
414-
size_t const name_len = plus != NULL ? (size_t)(plus - start) : descriptor_len;
415+
size_t name_len = plus != NULL ? (size_t)(plus - start) : descriptor_len;
415416
if (name_len > NAME_MAX) {
416417
LOG(ERROR, "Service name too long to execute (length %zu)", name_len);
417418
goto err;
@@ -423,6 +424,7 @@ struct qrexec_parsed_command *parse_qubes_rpc_command(
423424
cmd->service_name = memdupnul(start, name_len);
424425
if (!cmd->service_name)
425426
goto err;
427+
cmd->arg = plus != NULL ? plus + 1 : NULL;
426428

427429
/* If there is no service argument, add a trailing "+" to the descriptor */
428430
cmd->service_descriptor = memdupnul(start, descriptor_len + (plus == NULL));
@@ -480,7 +482,7 @@ int execute_qubes_rpc_command(const char *cmdline, int *pid, int *stdin_fd,
480482
}
481483

482484
int execute_parsed_qubes_rpc_command(
483-
const struct qrexec_parsed_command *cmd, int *pid, int *stdin_fd,
485+
struct qrexec_parsed_command *cmd, int *pid, int *stdin_fd,
484486
int *stdout_fd, int *stderr_fd, struct buffer *stdin_buffer) {
485487
if (cmd->service_descriptor) {
486488
// Proper Qubes RPC call
@@ -501,9 +503,81 @@ int execute_parsed_qubes_rpc_command(
501503
pid, stdin_fd, stdout_fd, stderr_fd);
502504
}
503505
}
506+
static bool validate_port(const char *port) {
507+
#define MAXPORT "65535"
508+
#define MAXPORTLEN (sizeof MAXPORT - 1)
509+
if (*port < '1' || *port > '9')
510+
return false;
511+
const char *p = port + 1;
512+
for (; *p != 0; ++p) {
513+
if (*p < '0' || *p > '9')
514+
return false;
515+
}
516+
if (p - port > (ptrdiff_t)MAXPORTLEN)
517+
return false;
518+
if (p - port < (ptrdiff_t)MAXPORTLEN)
519+
return true;
520+
return memcmp(port, MAXPORT, MAXPORTLEN) <= 0;
521+
#undef MAXPORT
522+
#undef MAXPORTLEN
523+
}
524+
525+
static int qubes_tcp_connect(const char *host, const char *port)
526+
{
527+
// Work around a glibc bug: overly-large port numbers not rejected
528+
if (!validate_port(port)) {
529+
LOG(ERROR, "Invalid port number %s", port);
530+
return -1;
531+
}
532+
/* If there is ':' or '%' in the host, then this must be an IPv6 address, not IPv4. */
533+
bool const must_be_ipv6_addr = strchr(host, ':') != NULL || strchr(host, '%') != NULL;
534+
LOG(DEBUG, "Connecting to %s%s%s:%s",
535+
must_be_ipv6_addr ? "[" : "",
536+
host,
537+
must_be_ipv6_addr ? "]" : "",
538+
port);
539+
struct addrinfo hints = {
540+
.ai_flags = AI_NUMERICSERV | AI_NUMERICHOST,
541+
.ai_family = must_be_ipv6_addr ? AF_INET6 : AF_UNSPEC,
542+
.ai_socktype = SOCK_STREAM,
543+
.ai_protocol = IPPROTO_TCP,
544+
}, *addrs;
545+
int rc = getaddrinfo(host, port, &hints, &addrs);
546+
if (rc != 0) {
547+
/* data comes from symlink or from qrexec service argument, which has already
548+
* been sanitized */
549+
LOG(ERROR, "getaddrinfo(%s, %s) failed: %s", host, port, gai_strerror(rc));
550+
return -1;
551+
}
552+
rc = -1;
553+
assert(addrs != NULL && "getaddrinfo() returned zero addresses");
554+
assert(addrs->ai_next == NULL &&
555+
"getaddrinfo() returned multiple addresses despite AI_NUMERICHOST | AI_NUMERICSERV");
556+
int sockfd = socket(addrs->ai_family,
557+
addrs->ai_socktype | SOCK_CLOEXEC,
558+
addrs->ai_protocol);
559+
if (sockfd < 0)
560+
goto freeaddrs;
561+
{
562+
int one = 1;
563+
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) != 0)
564+
abort();
565+
}
566+
int res = connect(sockfd, addrs->ai_addr, addrs->ai_addrlen);
567+
if (res != 0) {
568+
PERROR("connect");
569+
close(sockfd);
570+
} else {
571+
rc = sockfd;
572+
LOG(DEBUG, "Connection succeeded");
573+
}
574+
freeaddrs:
575+
freeaddrinfo(addrs);
576+
return rc;
577+
}
504578

505579
bool find_qrexec_service(
506-
const struct qrexec_parsed_command *cmd,
580+
struct qrexec_parsed_command *cmd,
507581
int *socket_fd, struct buffer *stdin_buffer) {
508582
assert(cmd->service_descriptor);
509583

@@ -529,10 +603,11 @@ bool find_qrexec_service(
529603
return false;
530604
}
531605

606+
const char *desc = cmd->command + RPC_REQUEST_COMMAND_LEN + 1;
532607
if (S_ISSOCK(statbuf.st_mode)) {
533608
/* Socket-based service. */
534609
int s;
535-
if ((s = socket(AF_UNIX, SOCK_STREAM, 0)) == -1) {
610+
if ((s = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0)) == -1) {
536611
PERROR("socket");
537612
return false;
538613
}
@@ -544,12 +619,59 @@ bool find_qrexec_service(
544619

545620
if (cmd->send_service_descriptor) {
546621
/* send part after "QUBESRPC ", including trailing NUL */
547-
const char *desc = cmd->command + RPC_REQUEST_COMMAND_LEN + 1;
548622
buffer_append(stdin_buffer, desc, strlen(desc) + 1);
549623
}
550624

551625
*socket_fd = s;
552626
return true;
627+
} else if (S_ISLNK(statbuf.st_mode)) {
628+
/* TCP-based service */
629+
assert(path_buffer.buflen >= (int)sizeof "/dev/tcp");
630+
assert(memcmp(path_buffer.data, "/dev/tcp/", sizeof "/dev/tcp") == 0);
631+
char *address = path_buffer.data + sizeof "/dev/tcp";
632+
char *slash = strchr(address, '/');
633+
char *host, *port;
634+
if (slash == NULL) {
635+
if (cmd->arg == NULL || *cmd->arg == ' ') {
636+
LOG(ERROR, "No or empty argument provided, cannot connect to %s",
637+
path_buffer.data);
638+
return -1;
639+
}
640+
char *ptr = cmd->service_descriptor + (cmd->arg - desc);
641+
if (*address == '\0') {
642+
/* Get both host and port from service arguments */
643+
host = ptr;
644+
port = strrchr(ptr, '+');
645+
if (port == NULL) {
646+
LOG(ERROR, "No host provided, cannot connect at %s", path_buffer.data);
647+
return -1;
648+
}
649+
*port = '\0';
650+
for (char *p = host; p < port; ++p) {
651+
if (*p == '_') {
652+
LOG(ERROR, "Underscore not allowed in hostname %s", host);
653+
return -1;
654+
}
655+
if (*p == '+')
656+
*p = ':';
657+
}
658+
port++;
659+
} else {
660+
/* Get just port from service arguments */
661+
host = address;
662+
port = ptr;
663+
}
664+
} else {
665+
*slash = '\0';
666+
host = address;
667+
port = slash + 1;
668+
}
669+
int res = qubes_tcp_connect(host, port);
670+
if (res == -1)
671+
return false;
672+
*socket_fd = res;
673+
cmd->send_service_descriptor = false;
674+
return true;
553675
}
554676

555677
if (euidaccess(path_buffer.data, X_OK) == 0) {

libqrexec/libqrexec-utils.h

+11-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ struct buffer {
4949
#define WRITE_STDIN_BUFFERED 1 /* something still in the buffer */
5050
#define WRITE_STDIN_ERROR 2 /* write error, errno set */
5151

52-
/* Parsed Qubes RPC or legacy command. */
52+
/* Parsed Qubes RPC or legacy command.
53+
* The size of this structure is not part of the public API or ABI.
54+
* Only use instances allocated by libqrexec-utils. */
5355
struct qrexec_parsed_command {
5456
const char *cmdline;
5557

@@ -83,6 +85,13 @@ struct qrexec_parsed_command {
8385

8486
/* For socket-based services: Should the service descriptor be sent? */
8587
bool send_service_descriptor;
88+
89+
/* Remaining fields are private to libqrexec-utils. Do not access them
90+
* directly - they may change in any update. */
91+
92+
/* Pointer to the argument, or NULL if there is no argument.
93+
* Same buffer as "command" and "cmdline". */
94+
const char *arg;
8695
};
8796

8897
/* Parse a command, return NULL on failure. Uses cmd->cmdline
@@ -142,7 +151,7 @@ int write_stdin(int fd, const char *data, int len, struct buffer *buffer);
142151
* nonzero on failure.
143152
*/
144153
int execute_parsed_qubes_rpc_command(
145-
const struct qrexec_parsed_command *cmd, int *pid, int *stdin_fd,
154+
struct qrexec_parsed_command *cmd, int *pid, int *stdin_fd,
146155
int *stdout_fd, int *stderr_fd, struct buffer *stdin_buffer);
147156

148157
/**

libqrexec/process_io.c

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ static void close_stdout(int fd, bool restore_block) {
7676
} else if (shutdown(fd, SHUT_RD) == -1) {
7777
if (errno == ENOTSOCK)
7878
close(fd);
79-
else
79+
else if (errno != ENOTCONN) /* can happen with TCP, harmless */
8080
PERROR("shutdown close_stdout");
8181
}
8282
}

qrexec/tests/socket/agent.py

+90
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os.path
2323
import os
2424
import tempfile
25+
import socket
2526
import shutil
2627
import struct
2728
import getpass
@@ -686,6 +687,95 @@ def test_connect_socket_no_metadata(self):
686687
)
687688
self.check_dom0(dom0)
688689

690+
def test_connect_socket_tcp(self):
691+
socket_path = os.path.join(
692+
self.tempdir, "rpc", "qubes.SocketService+arg"
693+
)
694+
port = 65534
695+
host = "127.0.0.1"
696+
os.symlink(f"/dev/tcp/{host}/{port}", socket_path)
697+
self._test_tcp(socket.AF_INET, "qubes.SocketService+arg", host, port)
698+
699+
def _test_tcp_raw(self, family: int, service: str, host: str, port: int, accept=True):
700+
server = socket.socket(family, socket.SOCK_STREAM, socket.IPPROTO_TCP)
701+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
702+
server.bind((host, port))
703+
server.listen(1)
704+
server = qrexec.QrexecServer(server)
705+
self.addCleanup(server.close)
706+
707+
target, dom0 = self.execute_qubesrpc(service, "domX")
708+
if accept:
709+
server.accept()
710+
message = b"stdin data"
711+
target.send_message(qrexec.MSG_DATA_STDIN, message)
712+
target.send_message(qrexec.MSG_DATA_STDIN, b"")
713+
if accept:
714+
self.assertEqual(server.recvall(len(message)), message)
715+
server.sendall(b"stdout data")
716+
server.close()
717+
messages = target.recv_all_messages()
718+
self.check_dom0(dom0)
719+
return util.sort_messages(messages)
720+
721+
def _test_tcp(self, family: int, service: str, host: str, port: int) -> None:
722+
# No stderr
723+
self.assertListEqual(
724+
self._test_tcp_raw(family, service, host, port),
725+
[
726+
(qrexec.MSG_DATA_STDOUT, b"stdout data"),
727+
(qrexec.MSG_DATA_STDOUT, b""),
728+
(qrexec.MSG_DATA_EXIT_CODE, b"\0\0\0\0"),
729+
],
730+
)
731+
732+
def test_connect_socket_tcp_port_from_arg(self):
733+
socket_path = os.path.join(
734+
self.tempdir, "rpc", "qubes.SocketService"
735+
)
736+
port = 65533
737+
host = "127.0.0.1"
738+
os.symlink(f"/dev/tcp/{host}", socket_path)
739+
self._test_tcp(socket.AF_INET, f"qubes.SocketService+{port}", host, port)
740+
741+
def test_connect_socket_tcp_host_and_port_from_arg(self):
742+
socket_path = os.path.join(
743+
self.tempdir, "rpc", "qubes.SocketService"
744+
)
745+
port = 65535
746+
host = "127.0.0.1"
747+
os.symlink(f"/dev/tcp/", socket_path)
748+
self._test_tcp(socket.AF_INET, f"qubes.SocketService+{host}+{port}", host, port)
749+
750+
@unittest.skipIf(os.environ.get("DOCKER_BUG_BREAKS_IPV6"),
751+
"Disabling IPv6 test due to Docker bug")
752+
def test_connect_socket_tcp_ipv6(self):
753+
socket_path = os.path.join(
754+
self.tempdir, "rpc", "qubes.SocketService"
755+
)
756+
port = 65532
757+
host = "::1"
758+
os.symlink(f"/dev/tcp/", socket_path)
759+
self._test_tcp(socket.AF_INET6, f"qubes.SocketService+{host.replace(':', '+')}+{port}", host, port)
760+
761+
def test_connect_socket_tcp_unexpected_host(self):
762+
socket_path = os.path.join(
763+
self.tempdir, "rpc", "qubes.SocketService"
764+
)
765+
port = 65535
766+
host = "127.0.0.1"
767+
os.symlink(f"/dev/tcp/{host}", socket_path)
768+
messages = self._test_tcp_raw(socket.AF_INET, f"qubes.SocketService+{host}+{port}",
769+
host, port, accept=False)
770+
self.assertListEqual(
771+
messages,
772+
[
773+
(qrexec.MSG_DATA_STDOUT, b""),
774+
(qrexec.MSG_DATA_STDERR, b""),
775+
(qrexec.MSG_DATA_EXIT_CODE, b"\177\0\0\0"),
776+
],
777+
)
778+
689779
def test_connect_socket(self):
690780
socket_path = os.path.join(
691781
self.tempdir, "rpc", "qubes.SocketService+arg"

qrexec/tests/socket/qrexec.py

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def socket_server(socket_path, socket_path_alt=None):
147147
except FileNotFoundError:
148148
pass
149149
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
150+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
150151
server.bind(socket_path)
151152
if socket_path_alt is not None:
152153
os.symlink(socket_path, socket_path_alt)

0 commit comments

Comments
 (0)