[Openvpn-devel,v5] platform: Retain CAP_NET_ADMIN when dropping privileges

Message ID 20220514103717.235-1-timo@rothenpieler.org
State Accepted
Delegated to: Antonio Quartulli
Headers show
Series [Openvpn-devel,v5] platform: Retain CAP_NET_ADMIN when dropping privileges | expand

Commit Message

Timo Rothenpieler May 14, 2022, 10:37 a.m. UTC
On Linux, when dropping privileges, interaction with
the network configuration, such as tearing down routes
or ovpn-dco interfaces will fail when --user/--group are
used.

This patch sets the CAP_NET_ADMIN capability, which grants
the needed privileges during the lifetime of the OpenVPN
process when dropping root privileges.

Signed-off-by: Timo Rothenpieler <timo@rothenpieler.org>
Reviewed-By: David Sommerseth <davids@openvpn.net>
---
 configure.ac                              |  19 +++
 distro/systemd/openvpn-client@.service.in |   2 +-
 distro/systemd/openvpn-server@.service.in |   2 +-
 src/openvpn/init.c                        |   5 +-
 src/openvpn/platform.c                    | 146 +++++++++++++++++++++-
 src/openvpn/platform.h                    |  10 +-
 6 files changed, 175 insertions(+), 9 deletions(-)

Comments

Timo Rothenpieler Aug. 10, 2022, 1:57 p.m. UTC | #1
On 14/05/2022 12:37, Timo Rothenpieler wrote:
> On Linux, when dropping privileges, interaction with
> the network configuration, such as tearing down routes
> or ovpn-dco interfaces will fail when --user/--group are
> used.
> 
> This patch sets the CAP_NET_ADMIN capability, which grants
> the needed privileges during the lifetime of the OpenVPN
> process when dropping root privileges.
> 
> Signed-off-by: Timo Rothenpieler <timo@rothenpieler.org>
> Reviewed-By: David Sommerseth <davids@openvpn.net>

With Linux DCO now fully merged, this patch is ready to land.
It still applies cleanly for all I can tell.
Frank Lichtenheld Aug. 11, 2022, 9:30 a.m. UTC | #2
On Sat, May 14, 2022 at 12:37:17PM +0200, Timo Rothenpieler wrote:
> On Linux, when dropping privileges, interaction with
> the network configuration, such as tearing down routes
> or ovpn-dco interfaces will fail when --user/--group are
> used.
> 
> This patch sets the CAP_NET_ADMIN capability, which grants
> the needed privileges during the lifetime of the OpenVPN
> process when dropping root privileges.
> 
> Signed-off-by: Timo Rothenpieler <timo@rothenpieler.org>
> Reviewed-By: David Sommerseth <davids@openvpn.net>
> ---
>  configure.ac                              |  19 +++
>  distro/systemd/openvpn-client@.service.in |   2 +-
>  distro/systemd/openvpn-server@.service.in |   2 +-
>  src/openvpn/init.c                        |   5 +-
>  src/openvpn/platform.c                    | 146 +++++++++++++++++++++-
>  src/openvpn/platform.h                    |  10 +-
>  6 files changed, 175 insertions(+), 9 deletions(-)


I ran several t_client test runs with --user nobody on a DCO-enabled system.

Without the patch:
 - errors on teardown in all tests (sitnl)
 - test 11 fails (which actually uses DCO, since no comp)

With the patch:
 - errors on teardown gone
 - test 11 passes

With the patch and --disable-dco --enable-iproute2:
 - no cap retained
 - errors on teardown (ip)

Looks to me like it does what it is supposed to do.

Acked-By: Frank Lichtenheld <frank@lichtenheld.com>

That said, maybe we should add some hint about this
behavior to the actual documentation? Maybe to
--user documentation? Or at least Changes?

Regards,
Gert Doering Aug. 11, 2022, 10:03 a.m. UTC | #3
I have not tested this myself, but if I had, the test setup would have
been very similar to what Frank did (so, big thanks) - run a DCO 
environment with "owner nobody", and see if things still work.

I will add this to my DCO server test environment - run one of the
iroute-using instances with "nobody", so it is continuously tested.

I did have a stare-at-code a few weeks ago, and we did discuss this
a few months ago, and the approach chosen seems to make sense.

Uncrustify complained about two lines with tabs -> fixed.

Your patch has been applied to the master branch.

commit 2e359a088226ab1e5ee41fbab27d38d8a8d192ac
Author: Timo Rothenpieler
Date:   Sat May 14 12:37:17 2022 +0200

     platform: Retain CAP_NET_ADMIN when dropping privileges

     Signed-off-by: Timo Rothenpieler <timo@rothenpieler.org>
     Acked-by: Frank Lichtenheld <frank@lichtenheld.com>
     Message-Id: <20220514103717.235-1-timo@rothenpieler.org>
     URL: https://www.mail-archive.com/openvpn-devel@lists.sourceforge.net/msg24360.html
     Signed-off-by: Gert Doering <gert@greenie.muc.de>


--
kind regards,

Gert Doering
Gert Doering Aug. 11, 2022, 11:29 a.m. UTC | #4
Hi,

On Thu, Aug 11, 2022 at 12:03:45PM +0200, Gert Doering wrote:
> Uncrustify complained about two lines with tabs -> fixed.

Turns out that it's actually 4 lines, and while I did "git apply $patch",
I forgot the "git commit --amend platform.c", so the whitespace errors
landed in the commit, and the *fix* was still sitting in my tree.

Since this is just whitespace, I'll merge the patch as follows, 
into 

commit 649874df9edb52a9d85bf9db690b6150fdb6dcc9 (HEAD -> master)
Author: Gert Doering <gert@greenie.muc.de>
Date:   Thu Aug 11 13:26:58 2022 +0200

    Apply uncrustify changes that forgotten in the last patch.

gert
Gert Doering Aug. 15, 2022, 9:54 a.m. UTC | #5
HI,

On Thu, Aug 11, 2022 at 12:03:45PM +0200, Gert Doering wrote:
> I have not tested this myself, but if I had, the test setup would have
> been very similar to what Frank did (so, big thanks) - run a DCO 
> environment with "owner nobody", and see if things still work.
> 
> I will add this to my DCO server test environment - run one of the
> iroute-using instances with "nobody", so it is continuously tested.
[..]
> commit 2e359a088226ab1e5ee41fbab27d38d8a8d192ac
> Author: Timo Rothenpieler
> Date:   Sat May 14 12:37:17 2022 +0200
> 
>      platform: Retain CAP_NET_ADMIN when dropping privileges

Unfortunately, it seems that our approach to "if SITNL is used, we hard
require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
ways that people use openvpn.

Namely, network-manager...

  https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1017379

... runs openvpn with --ifconfig-noexec / --route-noexec, and insists
on doing all that itself.  I do not like NM's way of trying to control
everything (up to the point that it defaults to redirecting a default
route to OpenVPN even if config and server do not want that), but this
is what Linux people seem to be stuck with, so we need to handle it.


So I think we need to amend this patch twofold

 - if --ifconfig-noexec && --route-noexec are set, do not mandate
   success on CAP_NET_ADMIN (users might want it for using it in --up
   scripts, but if it fails, *openvpn* is not missing functionality)

   --> this should take care of the NM case

 - also, we might want to think long and hard about mandating it if
   --client (that is, --pull) is in use.  We postpone dropping of privileges
   until after the initial ifconfig/route setup has been done, and on
   program end, closing tun/dco interface will (I hope) make the interface
   go away without needing privileges, and the system will remove the 
   routes together with it.

   --redirect-gateway will break, --redirect-gateway def1 will not.

   Overlapping vpn routes with vpn gateway (= install a host route) will
   also be unable to clean up at program end.  But this is no worse than
   2.5.x with --user nobody.

gert
Timo Rothenpieler Aug. 15, 2022, 10:14 a.m. UTC | #6
On 15/08/2022 11:54, Gert Doering wrote:
> HI,
> 
> On Thu, Aug 11, 2022 at 12:03:45PM +0200, Gert Doering wrote:
>> I have not tested this myself, but if I had, the test setup would have
>> been very similar to what Frank did (so, big thanks) - run a DCO
>> environment with "owner nobody", and see if things still work.
>>
>> I will add this to my DCO server test environment - run one of the
>> iroute-using instances with "nobody", so it is continuously tested.
> [..]
>> commit 2e359a088226ab1e5ee41fbab27d38d8a8d192ac
>> Author: Timo Rothenpieler
>> Date:   Sat May 14 12:37:17 2022 +0200
>>
>>       platform: Retain CAP_NET_ADMIN when dropping privileges
> 
> Unfortunately, it seems that our approach to "if SITNL is used, we hard
> require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
> ways that people use openvpn.

That's not how the patch operates.
It only hard-requires the capability retention is dco_enabled() returns 
true.
In all other cases, it will try to retain capabilities, but continue 
with a warning if it fails.

Making the dco_enabled() case a "try but continue" would be a matter of 
changing a 1 to a -1. But given that DCO can't really work then, I'm not 
sure if that's desirable.
Gert Doering Aug. 15, 2022, 10:29 a.m. UTC | #7
Hi,

On Mon, Aug 15, 2022 at 12:14:23PM +0200, Timo Rothenpieler wrote:
> > Unfortunately, it seems that our approach to "if SITNL is used, we hard
> > require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
> > ways that people use openvpn.
> 
> That's not how the patch operates.
> It only hard-requires the capability retention is dco_enabled() returns 
> true.
> In all other cases, it will try to retain capabilities, but continue 
> with a warning if it fails.

Yes, but we do have DCO here, setting CAP_NET_ADMIN fails, and we abort,
instead of having a working client connect.

> Making the dco_enabled() case a "try but continue" would be a matter of 
> changing a 1 to a -1. But given that DCO can't really work then, I'm not 
> sure if that's desirable.

*DCO* can work fine, from the looks of that bug report (because all
the DCO open happens before capability drop on a --client config).

"Calling ifconfig and installing routes" cannot, but this is exactly what
we are not doing if --ifconfig-noexec + --route-noexec are set.

Please look closely at the log in the bug report, and keep in mind
that NM will do all the "SITNL" stuff for the user in that scenaro,
not OpenVPN itself.

gert
Timo Rothenpieler Aug. 15, 2022, 10:40 a.m. UTC | #8
On 15/08/2022 12:29, Gert Doering wrote:
> Hi,
> 
> On Mon, Aug 15, 2022 at 12:14:23PM +0200, Timo Rothenpieler wrote:
>>> Unfortunately, it seems that our approach to "if SITNL is used, we hard
>>> require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
>>> ways that people use openvpn.
>>
>> That's not how the patch operates.
>> It only hard-requires the capability retention is dco_enabled() returns
>> true.
>> In all other cases, it will try to retain capabilities, but continue
>> with a warning if it fails.
> 
> Yes, but we do have DCO here, setting CAP_NET_ADMIN fails, and we abort,
> instead of having a working client connect.
> 
>> Making the dco_enabled() case a "try but continue" would be a matter of
>> changing a 1 to a -1. But given that DCO can't really work then, I'm not
>> sure if that's desirable.
> 
> *DCO* can work fine, from the looks of that bug report (because all
> the DCO open happens before capability drop on a --client config).
> 
> "Calling ifconfig and installing routes" cannot, but this is exactly what
> we are not doing if --ifconfig-noexec + --route-noexec are set.
> 
> Please look closely at the log in the bug report, and keep in mind
> that NM will do all the "SITNL" stuff for the user in that scenaro,
> not OpenVPN itself.
> 
> gert

There's basically two options then:

Remove the dco_enabled() check entirely, and purely check for SITNL, but 
always only warn if it fails and carry on, hoping it works fine.

Add checks for ifconfig-noexec + route-noexec being set, and either only 
warn in that case, or don't even try to retain capabilities, since 
they're not needed either way. I'd prefer the later, since fewer 
capabilities is generally better.

The later of the two options seems nicer, will have a look at 
implementing them. Shouldn't be that hard, given the config is already 
there.
I don't have a NM setup to test that on though.
Gert Doering Aug. 15, 2022, 10:48 a.m. UTC | #9
Hi,

On Mon, Aug 15, 2022 at 12:40:55PM +0200, Timo Rothenpieler wrote:
> Add checks for ifconfig-noexec + route-noexec being set, and either only 
> warn in that case, 

... this is what I suggested two mails upthread :-)

> or don't even try to retain capabilities, since 
> they're not needed either way. I'd prefer the later, since fewer 
> capabilities is generally better.

I could see arguments for "we want to do the ifconfig/route setup in
an --up script" - for example to do VRF/NetNS stuff that OpenVPN can not
do itself.  So for these scenarios having the capability around would
be useful (= thus, try-and-warn)...

Different scenario, same options.  We don't always know what users want.

> implementing them. Shouldn't be that hard, given the config is already 
> there.
> I don't have a NM setup to test that on though.

Sending to the debian bug report referenced upthread and asking them
to test might be an option here.  Or ask David.

I do not use NM either.

gert
Steffan Karger Aug. 16, 2022, 9:16 a.m. UTC | #10
Hi,

On Mon, 15 Aug 2022 at 12:50, Gert Doering <gert@greenie.muc.de> wrote:
> On Mon, Aug 15, 2022 at 12:40:55PM +0200, Timo Rothenpieler wrote:
> > or don't even try to retain capabilities, since
> > they're not needed either way. I'd prefer the later, since fewer
> > capabilities is generally better.
>
> I could see arguments for "we want to do the ifconfig/route setup in
> an --up script" - for example to do VRF/NetNS stuff that OpenVPN can not
> do itself.  So for these scenarios having the capability around would
> be useful (= thus, try-and-warn)...
>
> Different scenario, same options.  We don't always know what users want.

Let me just second the "fewer capabilities is generally better"
argument. CAP_NET_ADMIN is a broad set of effective capabilties and
has been a popular path for privilege escalation vulnerabilities in
the past. See for example these two recent CVEs:

CVE-2022-2586

    A use-after-free in the Netfilter subsystem may result in local
    privilege escalation for a user with the CAP_NET_ADMIN capability in
    any user or network namespace.

CVE-2022-2588

    Zhenpeng Lin discovered a use-after-free flaw in the cls_route
    filter implementation which may result in local privilege escalation
    for a user with the CAP_NET_ADMIN capability in any user or network
    namespace.

So I'm really not in favour of retaining CAP_NET_ADMIN "just in case".
I would even like to be able to not retain it at all.

-Steffan
Gert Doering Aug. 16, 2022, 9:29 a.m. UTC | #11
Hi,

On Mon, Aug 15, 2022 at 11:54:21AM +0200, Gert Doering wrote:
> [..]
> > commit 2e359a088226ab1e5ee41fbab27d38d8a8d192ac
> > Author: Timo Rothenpieler
> > Date:   Sat May 14 12:37:17 2022 +0200
> > 
> >      platform: Retain CAP_NET_ADMIN when dropping privileges
> 
> Unfortunately, it seems that our approach to "if SITNL is used, we hard
> require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
> ways that people use openvpn.
> 
> Namely, network-manager...
> 
>   https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1017379
> 
> ... runs openvpn with --ifconfig-noexec / --route-noexec, and insists
> on doing all that itself.  I do not like NM's way of trying to control
> everything (up to the point that it defaults to redirecting a default
> route to OpenVPN even if config and server do not want that), but this
> is what Linux people seem to be stuck with, so we need to handle it.

We've discussed this quite a bit in #openvpn-devel, and it's more complicated
than "just ifconfig and route".

Namely, there is more operations OpenVPN needs to do if running with
the DCO kernel module, which all needs CAP_NET_ADMIN

  - renegotiate on TLS session expiry (control channel communication is
    done through kernel module, not directly on socket)
  - install / swap keys
  - reconfigure the kernel peer on reconnect (peer-id / remote IP)

So, running in an environment that prevents use of CAP_NET_ADMIN (how
does it do that, in the first place?) *and* forces a non-root user
means "DCO will break".  Not immediately, but openvpn will abort on
each of these envents - NM might hide that by just restarting it, but
it's still broken.


So the discussion seems to run towards two options

  - disable DCO if CAP_NET_ADMIN can not be retained
  - fix this in NM

Preferably, fix this in NM...

gert
Gert Doering Aug. 16, 2022, 9:33 a.m. UTC | #12
Hi,

On Tue, Aug 16, 2022 at 11:16:50AM +0200, Steffan Karger wrote:
> So I'm really not in favour of retaining CAP_NET_ADMIN "just in case".
> I would even like to be able to not retain it at all.

I hear what you say, and I support that line of thought.

Alas, with current Linux-DCO, we need this for fiddling all the DCO bits
(keys / key renewal! / peer-id / install/update peers), as this is done
via netlink, and netlink requires CAP_NET_ADMIN.

To get around that we'd need to find a communication channel to 
(Linux-)DCO that does not require privileges after initial setup 
("hey, DCO, give me a socket that does not need privileges") and 
rewrite quite a bit...  I'm sure that Antonio will be thrilled by
that new challenge... :-)

gert
Gert Doering Aug. 17, 2022, 3:31 p.m. UTC | #13
Hi,

On Mon, Aug 15, 2022 at 11:54:21AM +0200, Gert Doering wrote:
> [..]
> > commit 2e359a088226ab1e5ee41fbab27d38d8a8d192ac
> > Author: Timo Rothenpieler
> > Date:   Sat May 14 12:37:17 2022 +0200
> > 
> >      platform: Retain CAP_NET_ADMIN when dropping privileges
> 
> Unfortunately, it seems that our approach to "if SITNL is used, we hard
> require that setting CAP_NET_ADMIN succeeds" is too strong for the twisted
> ways that people use openvpn.
> 
> Namely, network-manager...
> 
>   https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1017379

For the sake of the list archives: *this* particular problem has been
solved by 

commit da31c1654c8534658157cfe9c9de5750ee752608
Author: Timo Rothenpieler <timo@rothenpieler.org>
Date:   Wed Aug 17 15:18:17 2022 +0200

    dco: disable DCO if --user specified but unable to retain capabilities


so if we detect "the caller wants us to go to --user $notroot but we do
not have the necessary capabilities to retain CAP_NET_ADMIN, disable DCO".

This is basically the only thing we can do - if we have no CAP_NET_ADMIN,
DCO will be unable to function today.


Next steps are

 - talk to the NM maintainers to get them to call OpenVPN with something
   like "CAP_NET_ADMIN and uid != 0" (and no --user config) - so we can
   just do our thing, without root privs.  David :-)

 - figure out if we can do Linux DCO without CAP_NET_ADMIN, at least 
   "after startup" (open with privs, get a ticket, continue without privs,
   something mumble mumble).  Antonio :-)

Thanks for all the enlightenment that happened here.

gert

Patch

diff --git a/configure.ac b/configure.ac
index 85921ddb..d2eb3426 100644
--- a/configure.ac
+++ b/configure.ac
@@ -794,6 +794,25 @@  dnl
 	esac
 fi
 
+dnl
+dnl Depend on libcap-ng on Linux
+dnl
+case "$host" in
+	*-*-linux*)
+		PKG_CHECK_MODULES([LIBCAPNG],
+				  [libcap-ng],
+				  [],
+				  [AC_MSG_ERROR([libcap-ng package not found. Is the development package and pkg-config installed?])]
+		)
+		AC_CHECK_HEADER([sys/prctl.h],,[AC_MSG_ERROR([sys/prctl.h not found!])])
+
+		CFLAGS="${CFLAGS} ${LIBCAPNG_CFALGS}"
+		LIBS="${LIBS} ${LIBCAPNG_LIBS}"
+		AC_DEFINE(HAVE_LIBCAPNG, 1, [Enable libcap-ng support])
+	;;
+esac
+
+
 if test "${with_crypto_library}" = "openssl"; then
 	AC_ARG_VAR([OPENSSL_CFLAGS], [C compiler flags for OpenSSL])
 	AC_ARG_VAR([OPENSSL_LIBS], [linker flags for OpenSSL])
diff --git a/distro/systemd/openvpn-client@.service.in b/distro/systemd/openvpn-client@.service.in
index cbcef653..159fb4dc 100644
--- a/distro/systemd/openvpn-client@.service.in
+++ b/distro/systemd/openvpn-client@.service.in
@@ -11,7 +11,7 @@  Type=notify
 PrivateTmp=true
 WorkingDirectory=/etc/openvpn/client
 ExecStart=@sbindir@/openvpn --suppress-timestamps --nobind --config %i.conf
-CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE
+CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_SYS_CHROOT CAP_DAC_OVERRIDE
 LimitNPROC=10
 DeviceAllow=/dev/null rw
 DeviceAllow=/dev/net/tun rw
diff --git a/distro/systemd/openvpn-server@.service.in b/distro/systemd/openvpn-server@.service.in
index d1cc72cb..6e8e7d94 100644
--- a/distro/systemd/openvpn-server@.service.in
+++ b/distro/systemd/openvpn-server@.service.in
@@ -11,7 +11,7 @@  Type=notify
 PrivateTmp=true
 WorkingDirectory=/etc/openvpn/server
 ExecStart=@sbindir@/openvpn --status %t/openvpn-server/status-%i.log --status-version 2 --suppress-timestamps --config %i.conf
-CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_AUDIT_WRITE
+CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_AUDIT_WRITE
 LimitNPROC=10
 DeviceAllow=/dev/null rw
 DeviceAllow=/dev/net/tun rw
diff --git a/src/openvpn/init.c b/src/openvpn/init.c
index a6c93038..39b8f77a 100644
--- a/src/openvpn/init.c
+++ b/src/openvpn/init.c
@@ -1163,8 +1163,9 @@  do_uid_gid_chroot(struct context *c, bool no_delay)
         {
             if (no_delay)
             {
-                platform_group_set(&c0->platform_state_group);
-                platform_user_set(&c0->platform_state_user);
+                platform_user_group_set(&c0->platform_state_user,
+                                        &c0->platform_state_group,
+                                        c);
             }
             else if (c->first_time)
             {
diff --git a/src/openvpn/platform.c b/src/openvpn/platform.c
index 61afee83..56dca6e6 100644
--- a/src/openvpn/platform.c
+++ b/src/openvpn/platform.c
@@ -29,6 +29,9 @@ 
 
 #include "syshead.h"
 
+#include "openvpn.h"
+#include "options.h"
+
 #include "buffer.h"
 #include "crypto.h"
 #include "error.h"
@@ -43,6 +46,11 @@ 
 #include <direct.h>
 #endif
 
+#ifdef HAVE_LIBCAPNG
+#include <cap-ng.h>
+#include <sys/prctl.h>
+#endif
+
 /* Redefine the top level directory of the filesystem
  * to restrict access to files for security */
 void
@@ -91,7 +99,7 @@  platform_user_get(const char *username, struct platform_state_user *state)
     return ret;
 }
 
-void
+static void
 platform_user_set(const struct platform_state_user *state)
 {
 #if defined(HAVE_GETPWNAM) && defined(HAVE_SETUID)
@@ -130,7 +138,7 @@  platform_group_get(const char *groupname, struct platform_state_group *state)
     return ret;
 }
 
-void
+static void
 platform_group_set(const struct platform_state_group *state)
 {
 #if defined(HAVE_GETGRNAM) && defined(HAVE_SETGID)
@@ -155,6 +163,140 @@  platform_group_set(const struct platform_state_group *state)
 #endif
 }
 
+/*
+ * Determine if we need to retain process capabilities. DCO and SITNL need it.
+ * Enforce it for DCO, but only try and soft-fail for SITNL to keep backwards compat.
+ *
+ * Returns the tri-state expected by platform_user_group_set.
+ * -1: try to keep caps, but continue if impossible
+ *  0: don't keep caps
+ *  1: keep caps, fail hard if impossible
+ */
+static int
+need_keep_caps(struct context *c)
+{
+    if (!c)
+    {
+        return -1;
+    }
+
+    if (dco_enabled(&c->options))
+    {
+#ifdef TARGET_LINUX
+        /* DCO on Linux does not work at all without CAP_NET_ADMIN */
+        return 1;
+#else
+        /* Windows/BSD/... has no equivalent capability mechanism */
+        return -1;
+#endif
+    }
+
+#ifdef ENABLE_SITNL
+    return -1;
+#else
+    return 0;
+#endif
+}
+
+/* Set user and group, retaining neccesary capabilities required by the platform.
+ *
+ * The keep_caps argument has 3 possible states:
+ *  >0: Retain capabilities, and fail hard on failure to do so.
+ * ==0: Don't attempt to retain any capabilities, just sitch user/group.
+ *  <0: Try to retain capabilities, but continue on failure.
+ */
+void platform_user_group_set(const struct platform_state_user *user_state,
+                             const struct platform_state_group *group_state,
+                             struct context *c)
+{
+    int keep_caps = need_keep_caps(c);
+    unsigned int err_flags = (keep_caps > 0) ? M_FATAL : M_NONFATAL;
+#ifdef HAVE_LIBCAPNG
+    int new_gid = -1, new_uid = -1;
+    int res;
+
+    if (keep_caps == 0)
+    {
+        goto fallback;
+    }
+
+    /*
+     * new_uid/new_gid defaults to -1, which will not make
+     * libcap-ng change the UID/GID unless configured
+     */
+    if (group_state->groupname && group_state->gr)
+    {
+        new_gid = group_state->gr->gr_gid;
+    }
+    if (user_state->username && user_state->pw)
+    {
+        new_uid = user_state->pw->pw_uid;
+    }
+
+    /* Prepare capabilities before dropping UID/GID */
+    capng_clear(CAPNG_SELECT_BOTH);
+    res = capng_update(CAPNG_ADD, CAPNG_EFFECTIVE | CAPNG_PERMITTED, CAP_NET_ADMIN);
+    if (res < 0)
+    {
+        msg(err_flags, "capng_update(CAP_NET_ADMIN) failed: %d", res);
+        goto fallback;
+    }
+
+    /* Change to new UID/GID.
+     * capng_change_id() internally calls capng_apply() to apply prepared capabilities.
+     */
+    res = capng_change_id(new_uid, new_gid, CAPNG_DROP_SUPP_GRP | CAPNG_CLEAR_BOUNDING);
+    if (res == -4 || res == -6)
+    {
+        /* -4 and -6 mean failure of setuid/gid respectively.
+           There is no point for us to continue if those failed. */
+        msg(M_ERR, "capng_change_id('%s','%s') failed: %d",
+            user_state->username, group_state->groupname, res);
+    }
+    else if (res == -3)
+    {
+        msg(M_NONFATAL | M_ERRNO, "capng_change_id() failed applying capabilities");
+        msg(err_flags, "NOTE: previous error likely due to missing capability CAP_SETPCAP.");
+        goto fallback;
+    }
+    else if (res < 0)
+    {
+        msg(err_flags | M_ERRNO, "capng_change_id('%s','%s') failed retaining capabilities: %d",
+            user_state->username, group_state->groupname, res);
+        goto fallback;
+    }
+
+    if (new_uid >= 0)
+    {
+         msg(M_INFO, "UID set to %s", user_state->username);
+    }
+    if (new_gid >= 0)
+    {
+         msg(M_INFO, "GID set to %s", group_state->groupname);
+    }
+
+    msg(M_INFO, "Capabilities retained: CAP_NET_ADMIN");
+    return;
+
+fallback:
+    /* capng_change_id() can leave this flag clobbered on failure
+     * This is working around a bug in libcap-ng, which can leave the flag set
+     * on failure: https://github.com/stevegrubb/libcap-ng/issues/33 */
+    if (prctl(PR_GET_KEEPCAPS) && prctl(PR_SET_KEEPCAPS, 0) < 0)
+    {
+        msg(M_ERR, "Clearing KEEPCAPS flag failed");
+    }
+#endif  /* HAVE_LIBCAPNG */
+
+    if (keep_caps)
+    {
+        msg(err_flags, "Unable to retain capabilities");
+    }
+
+    platform_group_set(group_state);
+    platform_user_set(user_state);
+}
+
 /* Change process priority */
 void
 platform_nice(int niceval)
diff --git a/src/openvpn/platform.h b/src/openvpn/platform.h
index a3eec298..1ffd81e3 100644
--- a/src/openvpn/platform.h
+++ b/src/openvpn/platform.h
@@ -55,6 +55,9 @@ 
 #include "basic.h"
 #include "buffer.h"
 
+/* forward declared to avoid large amounts of extra includes */
+struct context;
+
 /* Get/Set UID of process */
 
 struct platform_state_user {
@@ -79,11 +82,12 @@  struct platform_state_group {
 
 bool platform_user_get(const char *username, struct platform_state_user *state);
 
-void platform_user_set(const struct platform_state_user *state);
-
 bool platform_group_get(const char *groupname, struct platform_state_group *state);
 
-void platform_group_set(const struct platform_state_group *state);
+void platform_user_group_set(const struct platform_state_user *user_state,
+                             const struct platform_state_group *group_state,
+                             struct context *c);
+
 
 /*
  * Extract UID or GID