]> git.ipfire.org Git - thirdparty/chrony.git/commitdiff
socket: add support for systemd sockets
authorLuke Valenta <lvalenta@cloudflare.com>
Thu, 26 Oct 2023 16:48:56 +0000 (12:48 -0400)
committerMiroslav Lichvar <mlichvar@redhat.com>
Mon, 13 Nov 2023 16:05:26 +0000 (17:05 +0100)
Before opening new IPv4/IPv6 server sockets, chronyd will check for
matching reusable sockets passed from the service manager (for example,
passed via systemd socket activation:
https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html)
and use those instead.

Aside from IPV6_V6ONLY (which cannot be set on already-bound sockets),
the daemon sets the same socket options on reusable sockets as it would
on sockets it opens itself.

Unit tests test the correct parsing of the LISTEN_FDS environment
variable.

Add 011-systemd system test to test socket activation for DGRAM and
STREAM sockets (both IPv4 and IPv6).  The tests use the
systemd-socket-activate test tool, which has some limitations requiring
workarounds discussed in inline comments.

doc/chronyd.adoc
main.c
socket.c
socket.h
test/system/011-systemd [new file with mode: 0755]
test/system/test.common
test/unit/socket.c [new file with mode: 0644]

index b4e382c6b9b1ad57f8256f907543a04f123b30a1..887be485a6399c5cf3ef16ee336a6051da1798fb 100644 (file)
@@ -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 3233707844aabfa62234769a83c5b9eb496b6b2e..21d0fe7f0fc273e98b1f42731cc01cbf800951d1 100644 (file)
--- 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();
index aa060a8e3ef6d4796156003f902054fcf9545eb8..ff5c3fc38922db3f75891be178b53f5b16a7d515 100644 (file)
--- 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);
 }
 
index cdbae2de45b9a889759be867c3c7d0fe04278cfa..a2a1fd334fd728e8715e2ba544fbbaa78f4e4d95 100644 (file)
--- 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 (executable)
index 0000000..1049966
--- /dev/null
@@ -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 <<EOF
+cn = "chrony-nts-test"
+dns_name = "chrony-nts-test"
+ip_address = "$server"
+$([ "$has_ipv6" = "1" ] && echo 'ip_address = "::1"')
+serial = 001
+activation_date = "$[$(date '+%Y') - 1]-01-01 00:00:00 UTC"
+expiration_date = "$[$(date '+%Y') + 2]-01-01 00:00:00 UTC"
+signing_key
+encryption_key
+EOF
+
+certtool --generate-privkey --key-type=ed25519 --outfile $TEST_DIR/server.key \
+       &> $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
index aa48ac67a6d350b5f9b93fd26378642062bc1e22..c389b484373d6c6ab5ff3c2b10410018bc9e56e8 100644 (file)
@@ -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 (file)
index 0000000..c4edea0
--- /dev/null
@@ -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 <socket.c>
+#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();
+}