From 75e35bfa6cfc7fb60e61bb0d789c9b5fded39523 Mon Sep 17 00:00:00 2001
From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com>
Date: Sat, 15 Feb 2025 13:41:24 +0100
Subject: [PATCH] [PR #9659/06df717b backport][stable-10] lxd_connection: Allow
 non-root users to connect to an instance (#9751)

lxd_connection: Allow non-root users to connect to an instance (#9659)

* fix: add support for non-root user

* fix: show correct info for connection

* fix: use build_exec_command to execute as nonroot

* unset default user

* feat: add options for setting remote user and become method

* fix: add root as default remote_user

* fix: remove ansible_ssh_user from remote_user vars

* fix: use single quotes inside f-string

* fix: ensure lxc exec comes first

* fix: line length

* fix: use -c flag with su

* Update plugins/connection/lxd.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/connection/lxd.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/connection/lxd.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* doc: add changelog fragment

* fix: use underscore for module name in fragment

* Update 9659-lxd_connection-nonroot-user.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: add put command

* feat: add get_remote_uid_gid placeholder function

* feat: complete placeholder _get_remote_uid_gid function

* fix: better logging

* fix: ensure default values are of type str

* fix: use ints for uid and gid

* fix: print put command

* fix: format

* fix: display msg for PUT

* fix: add comment about defaults

* fix: format

* fix: use os module to get uid and gid

* Revert "fix: use os module to get uid and gid"

This reverts commit bb2ba14b8fde1639f1053fc0e0a52349b22deee3.

* Update plugins/connection/lxd.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* fix: omit uid, gid args in lxd file push if root

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
(cherry picked from commit 06df717bc62fe3371acb2462b7694828b8e8196e)

Co-authored-by: Peter Siegel <33677897+yeetypete@users.noreply.github.com>
---
 .../9659-lxd_connection-nonroot-user.yml      |   2 +
 plugins/connection/lxd.py                     | 116 +++++++++++++++---
 2 files changed, 98 insertions(+), 20 deletions(-)
 create mode 100644 changelogs/fragments/9659-lxd_connection-nonroot-user.yml

diff --git a/changelogs/fragments/9659-lxd_connection-nonroot-user.yml b/changelogs/fragments/9659-lxd_connection-nonroot-user.yml
new file mode 100644
index 0000000000..760921f054
--- /dev/null
+++ b/changelogs/fragments/9659-lxd_connection-nonroot-user.yml
@@ -0,0 +1,2 @@
+minor_changes:
+  - lxd connection plugin - adds ``remote_user`` and ``lxd_become_method`` parameters for allowing a non-root user to connect to an LXD instance (https://github.com/ansible-collections/community.general/pull/9659).
diff --git a/plugins/connection/lxd.py b/plugins/connection/lxd.py
index fc8b4ae474..2670ed1b5f 100644
--- a/plugins/connection/lxd.py
+++ b/plugins/connection/lxd.py
@@ -32,6 +32,15 @@ options:
     vars:
       - name: ansible_executable
       - name: ansible_lxd_executable
+  lxd_become_method:
+    description:
+      - Become command used to switch to a non-root user.
+      - Is only used when O(remote_user) is not V(root).
+    type: str
+    default: /bin/su
+    vars:
+      - name: lxd_become_method
+    version_added: 10.4.0
   remote:
     description:
       - Name of the LXD remote to use.
@@ -40,6 +49,22 @@ options:
     vars:
       - name: ansible_lxd_remote
     version_added: 2.0.0
+  remote_user:
+    description:
+      - User to login/authenticate as.
+      - Can be set from the CLI via the C(--user) or C(-u) options.
+    type: string
+    default: root
+    vars:
+      - name: ansible_user
+    env:
+      - name: ANSIBLE_REMOTE_USER
+    ini:
+      - section: defaults
+        key: remote_user
+    keyword:
+      - name: remote_user
+    version_added: 10.4.0
   project:
     description:
       - Name of the LXD project to use.
@@ -63,7 +88,6 @@ class Connection(ConnectionBase):
 
     transport = 'community.general.lxd'
     has_pipelining = True
-    default_user = 'root'
 
     def __init__(self, play_context, new_stdin, *args, **kwargs):
         super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs)
@@ -73,9 +97,6 @@ class Connection(ConnectionBase):
         except ValueError:
             raise AnsibleError("lxc command not found in PATH")
 
-        if self._play_context.remote_user is not None and self._play_context.remote_user != 'root':
-            self._display.warning('lxd does not support remote_user, using default: root')
-
     def _host(self):
         """ translate remote_addr to lxd (short) hostname """
         return self.get_option("remote_addr").split(".", 1)[0]
@@ -85,25 +106,40 @@ class Connection(ConnectionBase):
         super(Connection, self)._connect()
 
         if not self._connected:
-            self._display.vvv("ESTABLISH LXD CONNECTION FOR USER: root", host=self._host())
+            self._display.vvv(f"ESTABLISH LXD CONNECTION FOR USER: {self.get_option('remote_user')}", host=self._host())
             self._connected = True
 
+    def _build_command(self, cmd) -> str:
+        """build the command to execute on the lxd host"""
+
+        exec_cmd = [self._lxc_cmd]
+
+        if self.get_option("project"):
+            exec_cmd.extend(["--project", self.get_option("project")])
+
+        exec_cmd.extend(["exec", f"{self.get_option('remote')}:{self._host()}", "--"])
+
+        if self.get_option("remote_user") != "root":
+            self._display.vvv(
+                f"INFO: Running as non-root user: {self.get_option('remote_user')}, \
+                trying to run 'lxc exec' with become method: {self.get_option('lxd_become_method')}",
+                host=self._host(),
+            )
+            exec_cmd.extend(
+                [self.get_option("lxd_become_method"), self.get_option("remote_user"), "-c"]
+            )
+
+        exec_cmd.extend([self.get_option("executable"), "-c", cmd])
+
+        return exec_cmd
+
     def exec_command(self, cmd, in_data=None, sudoable=True):
         """ execute a command on the lxd host """
         super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)
 
         self._display.vvv(f"EXEC {cmd}", host=self._host())
 
-        local_cmd = [self._lxc_cmd]
-        if self.get_option("project"):
-            local_cmd.extend(["--project", self.get_option("project")])
-        local_cmd.extend([
-            "exec",
-            f"{self.get_option('remote')}:{self._host()}",
-            "--",
-            self.get_option("executable"), "-c", cmd
-        ])
-
+        local_cmd = self._build_command(cmd)
         self._display.vvvvv(f"EXEC {local_cmd}", host=self._host())
 
         local_cmd = [to_bytes(i, errors='surrogate_or_strict') for i in local_cmd]
@@ -125,6 +161,25 @@ class Connection(ConnectionBase):
 
         return process.returncode, stdout, stderr
 
+    def _get_remote_uid_gid(self) -> tuple[int, int]:
+        """Get the user and group ID of 'remote_user' from the instance."""
+
+        rc, uid_out, err = self.exec_command("/bin/id -u")
+        if rc != 0:
+            raise AnsibleError(
+                f"Failed to get remote uid for user {self.get_option('remote_user')}: {err}"
+            )
+        uid = uid_out.strip()
+
+        rc, gid_out, err = self.exec_command("/bin/id -g")
+        if rc != 0:
+            raise AnsibleError(
+                f"Failed to get remote gid for user {self.get_option('remote_user')}: {err}"
+            )
+        gid = gid_out.strip()
+
+        return int(uid), int(gid)
+
     def put_file(self, in_path, out_path):
         """ put a file from local to lxd """
         super(Connection, self).put_file(in_path, out_path)
@@ -137,11 +192,32 @@ class Connection(ConnectionBase):
         local_cmd = [self._lxc_cmd]
         if self.get_option("project"):
             local_cmd.extend(["--project", self.get_option("project")])
-        local_cmd.extend([
-            "file", "push",
-            in_path,
-            f"{self.get_option('remote')}:{self._host()}/{out_path}"
-        ])
+
+        if self.get_option("remote_user") != "root":
+            uid, gid = self._get_remote_uid_gid()
+            local_cmd.extend(
+                [
+                    "file",
+                    "push",
+                    "--uid",
+                    str(uid),
+                    "--gid",
+                    str(gid),
+                    in_path,
+                    f"{self.get_option('remote')}:{self._host()}/{out_path}",
+                ]
+            )
+        else:
+            local_cmd.extend(
+                [
+                    "file",
+                    "push",
+                    in_path,
+                    f"{self.get_option('remote')}:{self._host()}/{out_path}",
+                ]
+            )
+
+        self._display.vvvvv(f"PUT {local_cmd}", host=self._host())
 
         local_cmd = [to_bytes(i, errors='surrogate_or_strict') for i in local_cmd]