diff --git a/doc/chronyd.adoc b/doc/chronyd.adoc index b4e382c..887be48 100644 --- a/doc/chronyd.adoc +++ b/doc/chronyd.adoc @@ -206,6 +206,17 @@ With this option *chronyd* will print version number to the terminal and exit. *-h*, *--help*:: With this option *chronyd* will print a help message to the terminal and exit. +== ENVIRONMENT VARIABLES + +*LISTEN_FDS*:: +On Linux systems, the systemd service manager may pass file descriptors for +pre-initialised sockets to *chronyd*. The service manager allocates and binds +the file descriptors, and passes a copy to each spawned instance of the +service. This allows for zero-downtime service restarts as the sockets buffer +client requests until the service is able to handle them. The service manager +sets the LISTEN_FDS environment variable to the number of passed file +descriptors. + == FILES _@SYSCONFDIR@/chrony.conf_ diff --git a/main.c b/main.c index 3233707..21d0fe7 100644 --- a/main.c +++ b/main.c @@ -368,9 +368,9 @@ go_daemon(void) } /* Don't keep stdin/out/err from before. But don't close - the parent pipe yet. */ + the parent pipe yet, or reusable file descriptors. */ for (fd=0; fd<1024; fd++) { - if (fd != pipefd[1]) + if (fd != pipefd[1] && !SCK_IsReusable(fd)) close(fd); } @@ -560,6 +560,9 @@ int main if (user_check && getuid() != 0) LOG_FATAL("Not superuser"); + /* Initialise reusable file descriptors before fork */ + SCK_PreInitialise(); + /* Turn into a daemon */ if (!nofork) { go_daemon(); diff --git a/socket.c b/socket.c index aa060a8..ff5c3fc 100644 --- a/socket.c +++ b/socket.c @@ -89,6 +89,9 @@ struct MessageHeader { static int initialised; +static int first_reusable_fd; +static int reusable_fds; + /* Flags indicating in which IP families sockets can be requested */ static int ip4_enabled; static int ip6_enabled; @@ -155,6 +158,59 @@ domain_to_string(int domain) /* ================================================== */ +static int +get_reusable_socket(int type, IPSockAddr *spec) +{ +#ifdef LINUX + union sockaddr_all sa; + IPSockAddr ip_sa; + int sock_fd, opt; + socklen_t l; + + /* Abort early if not an IPv4/IPv6 server socket */ + if (!spec || spec->ip_addr.family == IPADDR_UNSPEC || spec->port == 0) + return INVALID_SOCK_FD; + + /* Loop over available reusable sockets */ + for (sock_fd = first_reusable_fd; sock_fd < first_reusable_fd + reusable_fds; sock_fd++) { + + /* Check that types match */ + l = sizeof (opt); + if (getsockopt(sock_fd, SOL_SOCKET, SO_TYPE, &opt, &l) < 0 || + l != sizeof (opt) || opt != type) + continue; + + /* Get sockaddr for reusable socket */ + l = sizeof (sa); + if (getsockname(sock_fd, &sa.sa, &l) < 0 || l < sizeof (sa_family_t)) + continue; + SCK_SockaddrToIPSockAddr(&sa.sa, l, &ip_sa); + + /* Check that reusable socket matches specification */ + if (ip_sa.port != spec->port || UTI_CompareIPs(&ip_sa.ip_addr, &spec->ip_addr, NULL) != 0) + continue; + + /* Check that STREAM socket is listening */ + l = sizeof (opt); + if (type == SOCK_STREAM && (getsockopt(sock_fd, SOL_SOCKET, SO_ACCEPTCONN, &opt, &l) < 0 || + l != sizeof (opt) || opt == 0)) + continue; + +#if defined(FEAT_IPV6) && defined(IPV6_V6ONLY) + if (spec->ip_addr.family == IPADDR_INET6 && + (!SCK_GetIntOption(sock_fd, IPPROTO_IPV6, IPV6_V6ONLY, &opt) || opt != 1)) + LOG(LOGS_WARN, "Reusable IPv6 socket missing IPV6_V6ONLY option"); +#endif + + return sock_fd; + } +#endif + + return INVALID_SOCK_FD; +} + +/* ================================================== */ + #if defined(SOCK_CLOEXEC) || defined(SOCK_NONBLOCK) static int check_socket_flag(int sock_flag, int fd_flag, int fs_flag) @@ -214,7 +270,7 @@ set_socket_flags(int sock_fd, int flags) /* Close the socket automatically on exec */ if ( #ifdef SOCK_CLOEXEC - (supported_socket_flags & SOCK_CLOEXEC) == 0 && + (SCK_IsReusable(sock_fd) || (supported_socket_flags & SOCK_CLOEXEC) == 0) && #endif !UTI_FdSetCloexec(sock_fd)) return 0; @@ -222,7 +278,7 @@ set_socket_flags(int sock_fd, int flags) /* Enable non-blocking mode */ if ((flags & SCK_FLAG_BLOCK) == 0 && #ifdef SOCK_NONBLOCK - (supported_socket_flags & SOCK_NONBLOCK) == 0 && + (SCK_IsReusable(sock_fd) || (supported_socket_flags & SOCK_NONBLOCK) == 0) && #endif !set_socket_nonblock(sock_fd)) return 0; @@ -279,6 +335,32 @@ open_socket_pair(int domain, int type, int flags, int *other_fd) /* ================================================== */ +static int +get_ip_socket(int domain, int type, int flags, IPSockAddr *ip_sa) +{ + int sock_fd; + + /* Check if there is a matching reusable socket */ + sock_fd = get_reusable_socket(type, ip_sa); + + if (sock_fd < 0) { + sock_fd = open_socket(domain, type, flags); + + /* Unexpected, but make sure the new socket is not in the reusable range */ + if (SCK_IsReusable(sock_fd)) + LOG_FATAL("Could not open %s socket : file descriptor in reusable range", + domain_to_string(domain)); + } else { + /* Set socket flags on reusable socket */ + if (!set_socket_flags(sock_fd, flags)) + return INVALID_SOCK_FD; + } + + return sock_fd; +} + +/* ================================================== */ + static int set_socket_options(int sock_fd, int flags) { @@ -295,8 +377,10 @@ static int set_ip_options(int sock_fd, int family, int flags) { #if defined(FEAT_IPV6) && defined(IPV6_V6ONLY) - /* Receive only IPv6 packets on an IPv6 socket */ - if (family == IPADDR_INET6 && !SCK_SetIntOption(sock_fd, IPPROTO_IPV6, IPV6_V6ONLY, 1)) + /* Receive only IPv6 packets on an IPv6 socket, but do not attempt + to set this option on pre-initialised reuseable sockets */ + if (family == IPADDR_INET6 && !SCK_IsReusable(sock_fd) && + !SCK_SetIntOption(sock_fd, IPPROTO_IPV6, IPV6_V6ONLY, 1)) return 0; #endif @@ -385,6 +469,10 @@ bind_ip_address(int sock_fd, IPSockAddr *addr, int flags) ; #endif + /* Do not attempt to bind pre-initialised reusable socket */ + if (SCK_IsReusable(sock_fd)) + return 1; + saddr_len = SCK_IPSockAddrToSockaddr(addr, (struct sockaddr *)&saddr, sizeof (saddr)); if (saddr_len == 0) return 0; @@ -457,7 +545,7 @@ open_ip_socket(IPSockAddr *remote_addr, IPSockAddr *local_addr, const char *ifac return INVALID_SOCK_FD; } - sock_fd = open_socket(domain, type, flags); + sock_fd = get_ip_socket(domain, type, flags, local_addr); if (sock_fd < 0) return INVALID_SOCK_FD; @@ -482,7 +570,8 @@ open_ip_socket(IPSockAddr *remote_addr, IPSockAddr *local_addr, const char *ifac goto error; if (remote_addr || local_addr) - DEBUG_LOG("Opened %s%s socket fd=%d%s%s%s%s", + DEBUG_LOG("%s %s%s socket fd=%d%s%s%s%s", + SCK_IsReusable(sock_fd) ? "Reusing" : "Opened", type == SOCK_DGRAM ? "UDP" : type == SOCK_STREAM ? "TCP" : "?", family == IPADDR_INET4 ? "v4" : "v6", sock_fd, @@ -1170,6 +1259,39 @@ send_message(int sock_fd, SCK_Message *message, int flags) /* ================================================== */ +void +SCK_PreInitialise(void) +{ +#ifdef LINUX + char *s, *ptr; + + /* On Linux systems, the systemd service manager may pass file descriptors + for pre-initialised sockets to the chronyd daemon. The service manager + allocates and binds the file descriptors, and passes a copy to each + spawned instance of the service. This allows for zero-downtime service + restarts as the sockets buffer client requests until the service is able + to handle them. The service manager sets the LISTEN_FDS environment + variable to the number of passed file descriptors, and the integer file + descriptors start at 3 (see SD_LISTEN_FDS_START in + https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html). */ + first_reusable_fd = 3; + reusable_fds = 0; + + s = getenv("LISTEN_FDS"); + if (s) { + errno = 0; + reusable_fds = strtol(s, &ptr, 10); + if (errno != 0 || *ptr != '\0' || reusable_fds < 0) + reusable_fds = 0; + } +#else + first_reusable_fd = 0; + reusable_fds = 0; +#endif +} + +/* ================================================== */ + void SCK_Initialise(int family) { @@ -1209,10 +1331,17 @@ SCK_Initialise(int family) void SCK_Finalise(void) { + int fd; + ARR_DestroyInstance(recv_sck_messages); ARR_DestroyInstance(recv_headers); ARR_DestroyInstance(recv_messages); + for (fd = first_reusable_fd; fd < first_reusable_fd + reusable_fds; fd++) + close(fd); + reusable_fds = 0; + first_reusable_fd = 0; + initialised = 0; } @@ -1353,6 +1482,14 @@ SCK_OpenUnixSocketPair(int flags, int *other_fd) /* ================================================== */ +int +SCK_IsReusable(int fd) +{ + return fd >= first_reusable_fd && fd < first_reusable_fd + reusable_fds; +} + +/* ================================================== */ + int SCK_SetIntOption(int sock_fd, int level, int name, int value) { @@ -1410,7 +1547,7 @@ SCK_EnableKernelRxTimestamping(int sock_fd) int SCK_ListenOnSocket(int sock_fd, int backlog) { - if (listen(sock_fd, backlog) < 0) { + if (!SCK_IsReusable(sock_fd) && listen(sock_fd, backlog) < 0) { DEBUG_LOG("listen() failed : %s", strerror(errno)); return 0; } @@ -1573,6 +1710,10 @@ SCK_RemoveSocket(int sock_fd) void SCK_CloseSocket(int sock_fd) { + /* Reusable sockets are closed in finalisation */ + if (SCK_IsReusable(sock_fd)) + return; + close(sock_fd); } diff --git a/socket.h b/socket.h index cdbae2d..a2a1fd3 100644 --- a/socket.h +++ b/socket.h @@ -73,6 +73,9 @@ typedef struct { int descriptor; } SCK_Message; +/* Pre-initialisation function */ +extern void SCK_PreInitialise(void); + /* Initialisation function (the specified IP family is enabled, or all if IPADDR_UNSPEC) */ extern void SCK_Initialise(int family); @@ -106,6 +109,9 @@ extern int SCK_OpenUnixStreamSocket(const char *remote_addr, const char *local_a int flags); extern int SCK_OpenUnixSocketPair(int flags, int *other_fd); +/* Check if a file descriptor was passed from the service manager */ +extern int SCK_IsReusable(int sock_fd); + /* Set and get a socket option of int size */ extern int SCK_SetIntOption(int sock_fd, int level, int name, int value); extern int SCK_GetIntOption(int sock_fd, int level, int name, int *value); diff --git a/test/system/011-systemd b/test/system/011-systemd new file mode 100755 index 0000000..1049966 --- /dev/null +++ b/test/system/011-systemd @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +. ./test.common + +check_chronyd_features NTS || test_skip "NTS support disabled" +certtool --help &> /dev/null || test_skip "certtool missing" +check_chronyd_features DEBUG || test_skip "DEBUG support disabled" +systemd-socket-activate -h &> /dev/null || test_skip "systemd-socket-activate missing" +has_ipv6=$(check_chronyd_features IPV6 && ping6 -c 1 ::1 > /dev/null 2>&1 && echo 1 || echo 0) + +test_start "systemd socket activation" + +cat > $TEST_DIR/cert.cfg < $TEST_DIR/certtool.log +certtool --generate-self-signed --load-privkey $TEST_DIR/server.key \ + --template $TEST_DIR/cert.cfg --outfile $TEST_DIR/server.crt &>> $TEST_DIR/certtool.log +chown $user $TEST_DIR/server.* + +ntpport=$(get_free_port) +ntsport=$(get_free_port) + +server_options="port $ntpport nts ntsport $ntsport" +extra_chronyd_directives=" +port $ntpport +ntsport $ntsport +ntsserverkey $TEST_DIR/server.key +ntsservercert $TEST_DIR/server.crt +ntstrustedcerts $TEST_DIR/server.crt +ntsdumpdir $TEST_LIBDIR +ntsprocesses 3" + +if [ "$has_ipv6" = "1" ]; then + extra_chronyd_directives="$extra_chronyd_directives + bindaddress ::1 + server ::1 minpoll -6 maxpoll -6 $server_options" +fi + +# enable debug logging +extra_chronyd_options="-L -1" +# Hack to trigger systemd-socket-activate to activate the service. Normally, +# chronyd.service would be configured with the WantedBy= directive so it starts +# without waiting for socket activation. +# (https://0pointer.de/blog/projects/socket-activation.html). +for i in $(seq 10); do + sleep 1 + (echo "wake up" > /dev/udp/127.0.0.1/$ntpport) 2>/dev/null + (echo "wake up" > /dev/tcp/127.0.0.1/$ntsport) 2>/dev/null +done & + +# Test with UDP sockets (unfortunately systemd-socket-activate doesn't support +# both datagram and stream sockets in the same invocation: +# https://github.com/systemd/systemd/issues/9983). +CHRONYD_WRAPPER="systemd-socket-activate \ + --datagram \ + --listen 127.0.0.1:$ntpport \ + --listen 127.0.0.1:$ntsport" +if [ "$has_ipv6" = "1" ]; then + CHRONYD_WRAPPER="$CHRONYD_WRAPPER \ + --listen [::1]:$ntpport \ + --listen [::1]:$ntsport" +fi + +start_chronyd || test_fail +wait_for_sync || test_fail + +if [ "$has_ipv6" = "1" ]; then + run_chronyc "ntpdata ::1" || test_fail + check_chronyc_output "Total RX +: [1-9]" || test_fail +fi +run_chronyc "authdata" || test_fail +check_chronyc_output "^Name/IP address Mode KeyID Type KLen Last Atmp NAK Cook CLen +=========================================================================\ +$([ "$has_ipv6" = "1" ] && printf "\n%s\n" '::1 NTS 1 (30|15) (128|256) [0-9] 0 0 [78] ( 64|100)') +127\.0\.0\.1 NTS 1 (30|15) (128|256) [0-9] 0 0 [78] ( 64|100)$" || test_fail + +stop_chronyd || test_fail +# DGRAM ntpport socket should be used +check_chronyd_message_count "Reusing UDPv4 socket fd=3 local=127.0.0.1:$ntpport" 1 1 || test_fail +# DGRAM ntsport socket should be ignored +check_chronyd_message_count "Reusing TCPv4 socket fd=4 local=127.0.0.1:$ntsport" 0 0 || test_fail +if [ "$has_ipv6" = "1" ]; then + # DGRAM ntpport socket should be used + check_chronyd_message_count "Reusing UDPv6 socket fd=5 local=\[::1\]:$ntpport" 1 1 || test_fail + # DGRAM ntsport socket should be ignored + check_chronyd_message_count "Reusing TCPv6 socket fd=6 local=\[::1\]:$ntsport" 0 0 || test_fail +fi + +check_chronyd_messages || test_fail +check_chronyd_files || test_fail + +# Test with TCP sockets +CHRONYD_WRAPPER="systemd-socket-activate \ + --listen 127.0.0.1:$ntpport \ + --listen 127.0.0.1:$ntsport" +if [ "$has_ipv6" = "1" ]; then + CHRONYD_WRAPPER="$CHRONYD_WRAPPER \ + --listen [::1]:$ntpport \ + --listen [::1]:$ntsport" +fi + +start_chronyd || test_fail +wait_for_sync || test_fail + +if [ "$has_ipv6" = "1" ]; then + run_chronyc "ntpdata ::1" || test_fail + check_chronyc_output "Total RX +: [1-9]" || test_fail +fi +run_chronyc "authdata" || test_fail +check_chronyc_output "^Name/IP address Mode KeyID Type KLen Last Atmp NAK Cook CLen +=========================================================================\ +$([ "$has_ipv6" = "1" ] && printf "\n%s\n" '::1 NTS 1 (30|15) (128|256) [0-9] 0 0 [78] ( 64|100)') +127\.0\.0\.1 NTS 1 (30|15) (128|256) [0-9] 0 0 [78] ( 64|100)$" || test_fail + +stop_chronyd || test_fail +# STREAM ntpport should be ignored +check_chronyd_message_count "Reusing TCPv4 socket fd=3 local=127.0.0.1:$ntpport" 0 0 || test_fail +# STREAM ntsport should be used +check_chronyd_message_count "Reusing TCPv4 socket fd=4 local=127.0.0.1:$ntsport" 1 1 || test_fail +if [ "$has_ipv6" = "1" ]; then + # STREAM ntpport should be ignored + check_chronyd_message_count "Reusing TCPv6 socket fd=5 local=\[::1\]:$ntpport" 0 0 || test_fail + # STREAM ntsport should be used + check_chronyd_message_count "Reusing TCPv6 socket fd=6 local=\[::1\]:$ntsport" 1 1 || test_fail +fi +check_chronyd_messages || test_fail +check_chronyd_files || test_fail + +test_pass diff --git a/test/system/test.common b/test/system/test.common index aa48ac6..c389b48 100644 --- a/test/system/test.common +++ b/test/system/test.common @@ -324,7 +324,7 @@ check_chronyd_messages() { ([ "$clock_control" -ne 0 ] || grep -q 'Disabled control of system clock' "$logfile") && \ ([ "$minimal_config" -ne 0 ] || grep -q 'Frequency .* read from' "$logfile") && \ grep -q 'chronyd exiting' "$logfile" && \ - ! grep -q 'Could not' "$logfile" && \ + ! (grep -v '^.\{19\}Z D:' "$logfile" | grep -q 'Could not') && \ ! grep -q 'Disabled command socket' "$logfile" && \ test_ok || test_bad } diff --git a/test/unit/socket.c b/test/unit/socket.c new file mode 100644 index 0000000..c4edea0 --- /dev/null +++ b/test/unit/socket.c @@ -0,0 +1,61 @@ +/* + ********************************************************************** + * Copyright (C) Luke Valenta 2023 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of version 2 of the GNU General Public License as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + ********************************************************************** + */ + +#include +#include "test.h" + +static void +test_preinitialise(void) +{ +#ifdef LINUX + /* Test LISTEN_FDS environment variable parsing */ + + /* normal */ + putenv("LISTEN_FDS=2"); + SCK_PreInitialise(); + TEST_CHECK(reusable_fds == 2); + + /* negative */ + putenv("LISTEN_FDS=-2"); + SCK_PreInitialise(); + TEST_CHECK(reusable_fds == 0); + + /* trailing characters */ + putenv("LISTEN_FDS=2a"); + SCK_PreInitialise(); + TEST_CHECK(reusable_fds == 0); + + /* non-integer */ + putenv("LISTEN_FDS=a2"); + SCK_PreInitialise(); + TEST_CHECK(reusable_fds == 0); + + /* not set */ + unsetenv("LISTEN_FDS"); + SCK_PreInitialise(); + TEST_CHECK(reusable_fds == 0); +#endif +} + +void +test_unit(void) +{ + test_preinitialise(); +}