Repository: cockpit-project/cockpit-podman
Pull request #2024: Support connecting to other users
Author: login: martinpitt
Created: 2025-02-26T16:19:29Z | Merged: 2025-03-26T09:07:10Z

When the session has superuser capabilities, scan the system for all users
which have some podman containers running (via cgroupfs heuristics) and add
them to the owner selector.

Teach `rest.getAddress()` and the podman.socket startup to connect to the
podman service of an arbitrary UID by assuming that their runtime directory is
`/run/user/$UID`. This is a necessary, but safe enough assumption -- if the
system was modified to use a different one, then we won't support connecting to
that user.

However, we want to be careful and only connect to one extra user at a time: we
don't want connections to pile up (as the parallel events will eventually slow
down the page) or interfere with each other (as these users are isolated for a
reason). So only connect to the service once the user actually switches to that
owner, and disconnect again when switching away.

Fixes #692
https://issues.redhat.com/browse/COCKPIT-1086

----

I am testing this manually by starting a container as root, as user `admin` and as user `builder` (the system user on our bot VMs that runs package builds).

As root in the VM:

```sh
podman run -d --name sys1 --rm docker.io/busybox sleep infinity

loginctl enable-linger builder
su -c 'podman pull quay.io/cockpit/ws' - builder
su -c 'podman run -d --name build1 --rm docker.io/busybox sleep infinity' - builder
```

and the admin container:

```sh
ssh admin@c podman run -d --name adm1 --rm docker.io/busybox sleep infinity
```

Then you should see "builder" in the owner list after logging in, and can switch to it, and away from it.

Requirements:
 - [x] #1989
 - [x] #2022 
 - [x] #2020 
 - [x] #2039
 - [x] #2038
 - [x] #2042
 - [x] #2043

TODO:
 - [ ] Check with @garrett about this workflow -- timeout after a month of waiting. This doesn't really touch much UI
 - [x] Should we disconnect from other user when switching Owner to "all"? (right now it stays connected) -- see @jelly 's feedback below, let's do that.
 - [x] More integration tests: create new container, delete container
 - [x] test/fix changing users via URL change

Original PF5 screenshot:

![podman-multi-user-fs8](https://github.com/user-attachments/assets/7948f446-3da5-49a4-b647-247ca7480bdb)


## Podman: Connect to other users which run containers

Administrators often set up unprivileged containers run by system users, to isolate them from one another. These system users typically don't have a password and thus one cannot log into Cockpit as a system user. On startup, Cockpit Podman now scans the system for all users which have running containers, and offers you to connect to their podman service. Note that you can only connect to one system user at a time, to avoid breaking the isolation.

![image](https://github.com/user-attachments/assets/b8cd2a5f-2a89-4b56-a18f-ba3bb157a314)

Linked issue #692: Support multiple users
It would be nice if the main "admin" user (1000) would be able to view the containers, images etc. of other users. I (and I'm sure others) have rootless containers on a user by user basis for maximum security, especially when dealing with volumes, etc. Right now UID 1000 can only modify the root users and their own podman installations, but it would be nice if we could modify everyone's.
Linked issue comments: user: login: marusak
created_at: 2021-03-16T13:24:08Z
body: That seems like a very bad pattern that we would give user option to see anyone's containers and manage them.
The cleanest solution is to log in as given user to manage those user containers.
We offer rootless for users in wheel group because they are somewhat special, but we are not going to do this between any users.
author_association: MEMBER
user: login: nickavem
created_at: 2021-03-16T16:24:58Z
body: I figured, but worth a try. Thanks!
author_association: NONE
user: login: mailinglists35
created_at: 2024-01-09T12:16:37Z
body: @marusak  can you rethink this decision?

>That seems like a very bad pattern that we would give user option to see anyone's containers and manage them.

I am the sole human user of the machine but for extra isolation I am running each container in it's own linux username. I cannot even login using those usernames because they have the login disabled for security reasons. and second this would be a huge pain even if I would set password for each and allowed login, since I have hundreds of them
author_association: NONE
user: login: martinpitt
created_at: 2024-01-09T12:51:47Z
body: But this is tricky to do *precisely* because of the security isolation that you put up. These container users would each have to get a permanently running logind session ("lingering") to be able to auto-start containers and start the podman API, and yet it's difficult to log in as them. Something like `su - username` gets you the privileges, but not the `$XDG_RUNTIME_DIR` context, etc. All of this can be overcome, but you'd end up with a design which is very complex, immensely wasteful, brittle, and not even more secure as anything that can simultaneously talk to all of the containers is also the point of failure.

So I'm afraid this stands -- we can't support this, sorry.
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-09T13:14:15Z
body: > But this is tricky to do _precisely_ because of the security isolation that you put up. These container users would each have to get a permanently running logind session ("lingering") to be able to auto-start containers and start the podman API, and yet it's difficult to log in as them.

I have enabled lingering for these users, ofcourse.

>Something like `su - username` gets you the privileges, but not the `$XDG_RUNTIME_DIR` context, etc. 

there is already an XDG_RUNTIME_DIR-compliant method recommended by @rhatdan from podman: [machinectl](https://lists.podman.io/archives/list/podman@lists.podman.io/thread/HGVY3CCQGHR3KHEHHSGTP6ACMJ26KO5B/) 

see also [redhat's article](https://www.redhat.com/sysadmin/sudo-rootless-podman) endorsing `machinectl`

>All of this can be overcome, but you'd end up with a design which is very complex, immensely wasteful, brittle, and not even more secure as anything that can simultaneously talk to all of the containers is also the point of failure.
> 
> So I'm afraid this stands -- we can't support this, sorry.

I would happy even with just basic read-only-effect runs of podman, like listing containers/logs, images and volumes. seeing the status of containers in cockpit would be enormously helpful to my support staff instead of giving them shell access to a sudo account just to run `machinectl`.

what does it make so complicated? it's just this:

instead of 

`podman ps -a --noheading --no-trunc` as cockpit logged on user, 

it would be

````
$ getent passwd | grep bash | awk -F: '{print $1}' | grep -v ^root$ | sort | \
xargs -Iusername \
sudo machinectl -q shell username@ \
/bin/bash -c 'podman ps -a --noheading --no-trunc'
````
author_association: NONE
user: login: mailinglists35
created_at: 2024-01-09T13:16:26Z
body: >getent passwd | grep bash 

or whatever preffered method you have to list standard users
author_association: NONE
user: login: mailinglists35
created_at: 2024-01-09T13:26:30Z
body: >anything that can simultaneously talk to all of the containers is also the point of failure

ELI5? how is this a point of failure? you still have the ssh access to the machine. 
author_association: NONE
user: login: martinpitt
created_at: 2024-01-09T13:31:15Z
body: That's what I meant with "immensely wasteful/expensive" :grin: Your command might work for a quick local hack for *your specific setup*, but there's about a zillion things which are wrong with it. Plus, it essentially amounts to a DoS if you have more than a just a handful of users.

Also, c-podman uses the [podman API](https://docs.podman.io/en/latest/_static/api.html), not machinectl/`podman ps`. The `sudo machinectl` is also not something which cockpit can do.

This is so far apart from what cockpit does that it really doesn't fit, sorry.
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-09T14:04:48Z
body: can you not walk through `/run/user/<each readable uid>/podman/podman.sock` just to get at least some readonly info?

If I chmod/chown to read that file I can confirm I am able to talk to that users' podman api

````
$ http_proxy='' ALL_PROXY='' curl --unix-socket /run/user/2804/podman/podman.sock -v 'http://d/v4.0.0/libpod/images/json' | jq
*   Trying /run/user/2804/podman/podman.sock:0...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to d (/run/user/2804/podman/podman.sock) port 80 (#0)
> GET /v4.0.0/libpod/images/json HTTP/1.1
> Host: d
> User-Agent: curl/7.76.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Api-Version: 1.41
< Content-Type: application/json
< Libpod-Api-Version: 4.6.1
< Server: Libpod/4.6.1 (linux)
< X-Reference-Id: 0xc000516000
< Date: Tue, 09 Jan 2024 14:14:46 GMT
< Transfer-Encoding: chunked
<
{ [2675 bytes data]
100  2656    0  2656    0     0   648k      0 --:--:-- --:--:-- --:--:--  864k
* Connection #0 to host d left intact
[
  {
````
[...]


setting up permissions and starting the podman api service is on users responsibility, so all you have to do is walk through all readable `/run/user/*/podman/podman.sock` files and query them - what is so "very complex, immensely wasteful, brittle, and not even more secure" about this?

author_association: NONE
user: login: martinpitt
created_at: 2024-01-10T07:47:45Z
body: But that is precisely what you wanted to avoid -- you went through all the trouble of creating isolated users for the containers, only to nullify that by making them all accessible by a single human user?
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-10T10:29:42Z
body: > But that is precisely what you wanted to avoid -- you went through all the trouble of creating isolated users for the containers, only to nullify that by making them all accessible by a single human user?

no. I isolated the users so if one container escapes the namespace, it would limit the evil doing to it's user files instead of gaining access to all containers.

I fail to see how would giving the host human administrator access would nulify that; why would the administrator be limited in flexibility of administering the host? 
author_association: NONE
user: login: martinpitt
created_at: 2024-01-10T12:50:39Z
body: Ah, you want to protect the host *from* the containers, not protect the containers from the host, I see.
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-16T14:21:41Z
body: @martinpitt so what do you think, could some read-only info about containers be exposed in cockpit UI by talking to those podman socket files? is this feasible for current architecture of cockpit?
author_association: NONE
user: login: martinpitt
created_at: 2024-01-22T06:44:19Z
body: Not "read-only", the podman API doesn't work that way. Possibly it could manage containers for different users, but this would be a lot of work, it far exceeds our current capacity and the scope of an issue. It doesn't really fit the design.
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-25T11:44:19Z
body: > Not "read-only", the podman API doesn't work that way. Possibly it could manage containers for different users, but this would be a lot of work, it far exceeds our current capacity and the scope of an issue. It doesn't really fit the design.

i mean read only effect actions, such as listing containers, images, volumes.

come'on, it can't be _that_ complicated and out of scope!
author_association: NONE
user: login: martinpitt
created_at: 2024-01-25T11:50:19Z
body: > i mean read only effect actions

As I said, that's not how the podman API works.

You are welcome to give it a try :grin: 
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-26T13:16:15Z
body: > > i mean read only effect actions
> 
> As I said, that's not how the podman API works.
> 
> You are welcome to give it a try 😁

that's exactly how api works. see my previous [comment](https://github.com/cockpit-project/cockpit-podman/issues/692#issuecomment-1883108144) where I made a curl request to list containers. the output was obviously truncated but here it is again in full:

````
$ http_proxy='' ALL_PROXY='' curl --unix-socket /run/user/2804/podman/podman.sock -v 'http://d/v4.0.0/libpod/images/json' | jq
*   Trying /run/user/2804/podman/podman.sock:0...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to d (/run/user/2804/podman/podman.sock) port 80 (#0)
> GET /v4.0.0/libpod/images/json HTTP/1.1
> Host: d
> User-Agent: curl/7.76.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Api-Version: 1.41
< Content-Type: application/json
< Libpod-Api-Version: 4.6.1
< Server: Libpod/4.6.1 (linux)
< X-Reference-Id: 0xc0005e6008
< Date: Fri, 26 Jan 2024 13:13:57 GMT
< Transfer-Encoding: chunked
<
{ [2675 bytes data]
100  2656    0  2656    0     0   144k      0 --:--:-- --:--:-- --:--:--  144k
* Connection #0 to host d left intact
[
  {
    "Id": "a10f70804435e4db94a2d9ca9955a8cd17f7875dbac0fde4b7dddaf29be5e44f",
    "ParentId": "",
    "RepoTags": [
      "localhost/web:latest"
    ],
    "RepoDigests": [
      "localhost/web@sha256:95166b2b18d38e1e801267e612d5c493816cb32863563e30f31d452d1699cc18"
    ],
    "Created": 1705499820,
    "Size": 1423258674,
    "SharedSize": 0,
    "VirtualSize": 1423258674,
    "Labels": {
      "architecture": "x86_64",
      "build-date": "2023-12-07T06:30:54",
      "com.redhat.component": "php-81-container",
      "com.redhat.license_terms": "https://www.redhat.com/en/about/red-hat-end-user-license-agreements#UBI",
      "description": "PHP 8.1 available as container is a base platform for building and running various PHP 8.1 applications and frameworks. PHP is an HTML-embedded scripting language. PHP attempts to make it easy for developers to write dynamically generated web pages. PHP also offers built-in database integration for several commercial and non-commercial database management systems, so writing a database-enabled webpage with PHP is fairly simple. The most common use of PHP coding is probably as a replacement for CGI scripts.",
      "distribution-scope": "public",
      "help": "For more information visit https://github.com/sclorg/s2i-php-container",
      "io.buildah.version": "1.31.3",
      "io.k8s.description": "PHP 8.1 available as container is a base platform for building and running various PHP 8.1 applications and frameworks. PHP is an HTML-embedded scripting language. PHP attempts to make it easy for developers to write dynamically generated web pages. PHP also offers built-in database integration for several commercial and non-commercial database management systems, so writing a database-enabled webpage with PHP is fairly simple. The most common use of PHP coding is probably as a replacement for CGI scripts.",
      "io.k8s.display-name": "Apache 2.4 with PHP 8.1",
      "io.openshift.expose-services": "8080:http",
      "io.openshift.s2i.scripts-url": "image:///usr/libexec/s2i",
      "io.openshift.tags": "builder,php,php81,php-81",
      "io.s2i.scripts-url": "image:///usr/libexec/s2i",
      "maintainer": "SoftwareCollections.org <sclorg@redhat.com>",
      "name": "ubi9/php-81",
      "release": "43",
      "service": "web",
      "summary": "Platform for building and running PHP 8.1 applications",
      "url": "https://access.redhat.com/containers/#/registry.access.redhat.com/ubi9/php-81/images/1-43",
      "usage": "s2i build https://github.com/sclorg/s2i-php-container.git --context-dir=8.1/test/test-app ubi9/php-81 sample-server",
      "vcs-ref": "6811ef997d1c02aad25081909b79ffd6457d34f5",
      "vcs-type": "git",
      "vendor": "Red Hat, Inc.",
      "version": "1"
    },
    "Containers": 1,
    "Names": [
      "localhost/web:latest"
    ],
    "Digest": "sha256:95166b2b18d38e1e801267e612d5c493816cb32863563e30f31d452d1699cc18",
    "History": [
      "localhost/web:latest"
    ]
  }
]
````

why cockpit can NOT perform the same api request to list the running containers of some arbitrary podman socket file? and blindly stubbornly insists that it can only connect to ONE socket file, just because _traditionally_ the paradigm was only ONE file?


author_association: NONE
user: login: mailinglists35
created_at: 2024-01-26T13:26:43Z
body: the project makes the assumption there is only one file accessible.

https://github.com/search?q=repo%3Acockpit-project%2Fcockpit-podman%20sock&type=code

but as you could see in this conversation there are cases where multiple files are accessible, to which you can issue API calls that retrieve information (list running containers, list their configuration, list volumes/images and their metadata...)

I'm not asking to issue API calls that _modify_ configuration in those podmans. I only need to _view_ basic information about other containers via other socket files, which is done _exactly_ the same as you do now with the only socket file.
author_association: NONE
user: login: mailinglists35
created_at: 2024-01-26T13:30:30Z
body: this is not always required. maybe I can enable the socket by manually starting the podman api service. it should work even when that service is not started

https://github.com/cockpit-project/cockpit-podman/blob/1d07689589fb2c3335fcd77749cf50f3791bd00a/src/app.jsx#L568

instead here is the correct way to check for the socket.
https://github.com/cockpit-project/cockpit-podman/blob/1d07689589fb2c3335fcd77749cf50f3791bd00a/src/manifest.json#L3
author_association: NONE
user: login: mailinglists35
created_at: 2024-01-26T13:34:41Z
body: but then again, the projects' whole philosophy is centered about logged on user's podman socket file and podman system service, ignoring the possibility that there are also other potential socket files available to issue reading information api calls to them
author_association: NONE
user: login: martinpitt
created_at: 2024-01-26T13:52:04Z
body: > > > i mean read only effect actions
> > As I said, that's not how the podman API works.
> that's exactly how api works.

That's probably a misunderstanding. I meant you cannot only do *read-only* actions -- you can either talk to the socket and do everything (including starting/stopping containers etc.) or you can't and then you can't do anything at all.
author_association: MEMBER
user: login: mailinglists35
created_at: 2024-01-26T14:14:32Z
body: why could you not be able to filter the list of reading apis? they're documented. on system + current user socket file allow all apis, on discovered other socket files only issue apis that do not change the state. even a state changing action such as stop container is accepted by the other socket file
author_association: NONE
user: login: chrismaster
created_at: 2024-02-09T11:18:37Z
body: Same use case here.
- one pod with some containers per rootless passwordless user
- loginctl enable-linger
- administer with machinectl and systemctl --user
- AutoUpdate=registry
I hoped for some extra security with this setup, than a rootful setup.

To check the logs and maybe in the future see if there are outdated containers, I hoped to list all containers of every user in cockpit-podman.
Perhaps there is a way to archive this



author_association: NONE
user: login: thomasnaudin
created_at: 2024-03-28T09:00:59Z
body: I wanted to achieve the exact same workflow as @chrismaster
I totally understand that there are constraints, but having the possibility to get informations about multiple technical user accounts' containers via a single privileged human user account would imho really make sense, and would be a **great** enhancement.
author_association: NONE
user: login: YamiYukiSenpai
created_at: 2024-04-12T22:10:55Z
body: same here

I created my Jellyfin container on a dedicated jellyfin user account
author_association: NONE
user: login: martinpitt
created_at: 2025-01-21T17:44:31Z
body: Reopening. Our team discussed this on this week's sprint, and we found a design/solution that we can all agree to. Tracked in https://issues.redhat.com/browse/COCKPIT-1086
author_association: MEMBER
user: login: martinpitt
created_at: 2025-02-26T16:21:35Z
body: I posted a first draft to #2024. It still needs design and discussion, but you can already switch between users that have containers running and interact with these. Feedback appreciated! (The PR auto-builds COPRs for convenient testing on Fedora)
author_association: MEMBER

Diff:
diff --git a/src/Containers.jsx b/src/Containers.jsx
index 9dbde74a2..caa7afdbf 100644
--- a/src/Containers.jsx
+++ b/src/Containers.jsx
@@ -436,7 +436,7 @@ class Containers extends React.Component {
         const tty = !!container.Config?.Tty;
 
         const tabs = [];
-        if (container.State) {
+        if (container.State && user.con !== null) {
             tabs.push({
                 name: _("Details"),
                 renderer: ContainerDetails,
diff --git a/src/app.jsx b/src/app.jsx
index d1013429d..899f036e7 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -440,6 +440,7 @@ class Application extends React.Component {
     }
 
     cleanupAfterService(con) {
+        debug("cleanupAfterService", con.uid, "current owner filter:", this.state.ownerFilter);
         ["images", "containers", "pods"].forEach(t => {
             if (this.state[t])
                 this.setState(prevState => {
@@ -452,20 +453,30 @@ class Application extends React.Component {
                 });
         });
 
-        this.setState(prevState => ({ users: prevState.users.filter(u => u.uid !== con.uid) }));
+        // keep dummy (null) connections from other users, only remove valid ones
+        this.setState(prevState => ({ users: prevState.users.filter(u => u.con === null || u.uid !== con.uid) }));
 
-        // regardless of whose service went away (system/user), it makes owner filter disappear, so reset it
-        this.onOwnerChanged("all");
+        // reset owner filter if the current filter is the closed connection
+        if (con.uid == this.state.ownerFilter)
+            this.onOwnerChanged("all");
     }
 
     async init(uid, username) {
+        debug("init uid", uid, "name", username);
         const system = uid === 0;
+        const is_other_user = (uid !== 0 && uid !== null);
 
         let con = null;
 
         try {
-            await cockpit.spawn(["systemctl", ...(system ? [] : ["--user"]), "start", "podman.socket"],
-                                { superuser: system ? "require" : null, err: "message" });
+            const start_args = [
+                ...(is_other_user ? ["runuser", "-u", username, "--"] : []),
+                "systemctl",
+                ...(system ? [] : ["--user"]),
+                "start", "podman.socket"
+            ];
+            const environ = is_other_user ? ["XDG_RUNTIME_DIR=/run/user/" + uid] : [];
+            await cockpit.spawn(start_args, { superuser: uid === null ? null : "require", err: "message", environ });
             con = rest.connect(uid);
             const reply = await client.getInfo(con);
             this.setState(prevState => {
@@ -528,6 +539,45 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '(', '-name', 'libpod.*scope', '-o', '-name', 'podman-*.scope',
+                '-o', '-name', 'libpod-payload*', ')',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          // this find command doesn't need root, but user switching does;
+                          // hence skip the whole detection for unpriv sessions
+                          { superuser: "require", error: "message" })
+                    .then(output => {
+                        const other_users = [];
+                        const trimmed = output.trim();
+                        if (!trimmed)
+                            return;
+
+                        trimmed.split('\n').forEach(line => {
+                            const [uid_str, username] = line.split(' ');
+                            const uid = parseInt(uid_str);
+                            if (isNaN(uid)) {
+                                console.error(`User container detection: invalid uid '${uid_str}' in output '${output}'`); // not-covered: Should Not Happen™
+                                return; // not-covered: dito
+                            }
+                            // ignore standard users
+                            if (uid === 0 || uid === user.id)
+                                return;
+                            if (!other_users.find(u => u.uid === uid))
+                                other_users.push({ uid, name: username, con: null });
+                        });
+                        debug("other users who have containers running:", JSON.stringify(other_users));
+                        this.setState(prevState => ({ users: prevState.users.concat(other_users) }));
+                    })
+                    .catch(ex => {
+                        if (ex.problem == 'access-denied')
+                            debug("unprivileged session, skipping detection of other users");
+                        else
+                            console.warn("failed to detect other users:", ex);
+                    });
         });
 
         cockpit.spawn("selinuxenabled", { error: "ignore" })
@@ -557,18 +607,47 @@ class Application extends React.Component {
                 if (options.container) {
                     this.onContainerFilterChanged(options.container);
                 }
-                if (["user", "all"].includes(options.owner)) {
-                    this.setState({ ownerFilter: options.owner });
-                } else if (options.owner === undefined) {
-                    this.setState({ ownerFilter: "all" });
+                if (["all", undefined].includes(options.owner)) {
+                    // disconnect all non-standard users
+                    this.setState(prevState => ({
+                        users: prevState.users.map(u => {
+                            if (u.uid !== 0 && u.uid !== null && u.con) {
+                                debug("onNavigate All: closing unused connection to", u.name);
+                                u.con.close();
+                                return { uid: u.uid, name: u.name, con: null };
+                            } else
+                                return u;
+                        }),
+                        ownerFilter: "all",
+                    }));
                 } else {
-                    const uid = parseInt(options.owner);
-                    if (!isNaN(uid))
-                        this.setState({ ownerFilter: uid });
-                    else {
-                        console.log("Ignoring invalid URL owner value:", options.owner);
-                        // go back to previous valid filter
-                        this.onOwnerChanged(this.state.ownerFilter);
+                    const uid = options.owner === "user" ? null : parseInt(options.owner);
+                    const user = this.state.users.find(u => u.uid === uid);
+                    if (user) {
+                        // disconnect other non-standard users, to avoid piling up connections
+                        this.setState(prevState => ({
+                            users: prevState.users.map(u => {
+                                if (u.uid !== uid && u.uid !== 0 && u.uid !== null && u.con) {
+                                    debug("onNavigate", user.name, ": closing unused connection to", u.name);
+                                    u.con.close();
+                                    return { uid: u.uid, name: u.name, con: null };
+                                } else
+                                    return u;
+                            }),
+                            ownerFilter: uid === null ? "user" : uid,
+                        }), () => {
+                            if (user.con === null) {
+                                debug("onNavigate", user.name, ": initializing connection");
+                                this.init(user.uid, user.name);
+                            } else {
+                                debug("onNavigate", user.name, ": connection already initialized");
+                            }
+                        });
+                    } else {
+                        console.warn("Unknown user", options.owner, "in URL, ignoring");
+                        debug("known users:", JSON.stringify(this.state.users.map(u => [u.name, u.uid])));
+                        // reset URL to current value
+                        this.updateUrl({ ...this.state.location, owner: this.state.ownerFilter });
                     }
                 }
             }
@@ -627,9 +706,9 @@ class Application extends React.Component {
         } else
             imageContainerList = null;
 
-        const loadingImages = this.state.users.find(u => !u.imagesLoaded);
-        const loadingContainers = this.state.users.find(u => !u.containersLoaded);
-        const loadingPods = this.state.users.find(u => !u.podsLoaded);
+        const loadingImages = this.state.users.find(u => u.con && !u.imagesLoaded);
+        const loadingContainers = this.state.users.find(u => u.con && !u.containersLoaded);
+        const loadingPods = this.state.users.find(u => u.con && !u.podsLoaded);
 
         const imageList = (
             <Images
diff --git a/src/rest.js b/src/rest.js
index 8b9acdf48..95f4a3888 100644
--- a/src/rest.js
+++ b/src/rest.js
@@ -23,8 +23,8 @@ const CR = '\r'.charCodeAt(0); // always 13, but avoid magic constant
 
 const PODMAN_SYSTEM_ADDRESS = "/run/podman/podman.sock";
 
-/* uid: null for logged in session user; 0 for root; in the future we'll support other users */
-/* Return { path, superuser } */
+/* uid: null for logged in session user, otherwise standard Unix user ID
+ * Return { path, superuser } */
 function getAddress(uid) {
     if (uid === null) {
         // FIXME: make this async and call cockpit.user()
@@ -38,6 +38,9 @@ function getAddress(uid) {
     if (uid === 0)
         return { path: PODMAN_SYSTEM_ADDRESS, superuser: "require" };
 
+    if (Number.isInteger(uid))
+        return { path: `/run/user/${uid}/podman/podman.sock`, superuser: "require" };
+
     throw new Error(`getAddress: uid ${uid} not supported`);
 }
 
diff --git a/test/check-application b/test/check-application
index 713a4da1a..fc57f8ec0 100755
--- a/test/check-application
+++ b/test/check-application
@@ -3273,6 +3273,138 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
+        for user in ['guitar', 'piano']:
+            m.execute(f"useradd --create-home {user}; loginctl enable-linger {user}")
+            uids[user] = m.execute(f"id -u {user}").strip()
+            m.write(f"/home/{user}/.ssh/authorized_keys", authorized_keys, owner=f"{user}:{user}", perm="600")
+            self.addCleanup(m.execute, f"loginctl disable-linger {user}; rm -rf /home/{user}")
+
+            ssh[user] = ssh_connection.SSHConnection(user=user,
+                                                     address=m.ssh_address,
+                                                     ssh_port=m.ssh_port,
+                                                     identity_file=m.identity_file)
+            ssh[user].execute(f"""
+            podman load < /var/lib/test-images/localhosttest-busyboxlatest.tar
+            podman tag {IMG_BUSYBOX} example.com/{user}/myimg
+            podman rmi {IMG_BUSYBOX}
+            podman run -d --name {user}c1 --stop-timeout=0 example.com/{user}/myimg sleep infinity
+            """)
+            self.addCleanup(ssh[user].execute, """
+            systemctl --user stop podman.service podman.socket
+            podman system reset --force
+            """)
+
+        # add a system and admin user container into the mix
+        self.execute(True, f"podman run -d --name sysc1 --stop-timeout=0 {IMG_BUSYBOX} sleep infinity")
+        self.execute(False, f"podman run -d --name adminc1 --stop-timeout=0 {IMG_BUSYBOX} sleep infinity")
+
+        self.login_and_go("/podman")
+        b.wait_visible("#app")
+
+        # standard and both extra users get detected, with well-defined sorting
+        b.wait_text("#containers-containers-owner", "systemadminguitarpianoAll")
+        b.wait_val("#containers-containers-owner", "all")
+
+        # shows system + admin by default
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+        self.waitNumContainers(2, auth=True)
+        b.wait_text("#containers-containers tbody:nth-of-type(1) .container-name", "adminc1")
+        b.wait_text("#containers-containers tbody:nth-of-type(2) .container-name", "sysc1")
+        showImages(b)
+
+        # switching to other user only shows its images and containers
+        for user in ['guitar', 'piano']:
+            b.set_val("#containers-containers-owner", uids[user])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", f"{user}c1")
+            b.wait_text("#containers-containers [data-label='Owner']", f"user: {user}")
+
+            b.wait_text("#containers-images [data-label='Image']", f"example.com/{user}/myimg:latest")
+            b.wait_text("#containers-images [data-label='Owner']", f"user: {user}")
+
+            self.waitNumImages(1)
+
+        # switch to "all" disconnects other users
+        b.set_val("#containers-containers-owner", "all")
+        self.waitNumContainers(2, auth=True)
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+
+        # reconnect to guitar
+        b.set_val("#containers-containers-owner", uids['guitar'])
+        self.waitNumContainers(1, auth=False)
+
+        # switch session user, disconnects from last other user guitar
+        b.set_val("#containers-containers-owner", "user")
+        self.waitNumContainers(1, auth=False)
+        self.waitNumImages(self.user_images_count)
+
+        b.set_val("#containers-containers-owner", "all")
+        self.waitNumContainers(2, auth=True)
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+
+        # reconnect to guitar
+        b.set_val("#containers-containers-owner", uids["guitar"])
+        self.waitNumContainers(1, auth=False)
+        self.waitNumImages(1)
+
+        # container lifecycle actions
+        self.performContainerAction("example.com/guitar/myimg:latest", "Force stop")
+        b.wait(lambda: self.getContainerAttr("guitarc1", "State") in NOT_RUNNING)
+        self.performContainerAction("example.com/guitar/myimg:latest", "Start")
+        b.wait(lambda: self.getContainerAttr("guitarc1", "State") == "Running")
+
+        # container creation/removal
+        b.click('#containers-images tbody tr:contains("example.com/guitar/myimg") .ct-container-create')
+        b.set_input_text("#run-image-dialog-name", "new-guitar")
+        b.click("#create-image-create-btn")
+        b.wait_not_present("div.pf-v5-c-modal-box")
+        self.waitContainerRow("new-guitar")
+        self.performContainerAction("new-guitar", "Delete")
+        b.click(".pf-v5-c-modal-box button:contains(Delete)")
+        self.waitContainerRow("new-guitar", present=False)
+
+        # switch user via URL
+        b.go(f"#/?owner={uids['piano']}")
+        b.wait_js_cond(f'window.location.hash === "#/?owner={uids["piano"]}"')
+        b.wait_text("#containers-containers .container-name", "pianoc1")
+
+        # changing to an unknown user keeps the current filter
+        b.go("#/?owner=99999")
+        b.wait_js_cond(f'window.location.hash === "#/?owner={uids["piano"]}"')
+        b.wait_text("#containers-containers .container-name", "pianoc1")
+
+        b.logout()
+
+        # check quadlet detection
+        # old podman versions don't have quadlet support
+        if m.image not in ["rhel-8-10", "ubuntu-2204", "debian-stable"]:
+            ssh['guitar'].execute("podman rm -f --all")
+
+            quadlet = """[Container]
+Image=example.com/guitar/myimg
+Exec=sleep infinity
+ContainerName=guitarq1
+"""
+            ssh['guitar'].write("/home/guitar/.config/containers/systemd/guitarq1.container",
+                                quadlet, owner="guitar:guitar")
+            ssh['guitar'].execute("systemctl --user daemon-reload; systemctl --user start guitarq1")
+
+            self.login_and_go("/podman")
+            b.set_val("#containers-containers-owner", uids['guitar'])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", "guitarq1")
+            b.wait_text("#containers-containers [data-label='Owner']", "user: guitar")
+            b.wait_visible("#containers-containers .ct-badge-service")
+
 
 if __name__ == '__main__':
     testlib.test_main()


Reviews:
user: login: jelly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T16:39:26Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T17:26:44Z
user: login: jelly
body: Some minor questions but this mostly looks good!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T17:54:52Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T18:02:51Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T18:03:48Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T11:20:55Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T12:12:18Z
user: login: jelly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T13:23:29Z
user: login: jelly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T13:27:34Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T14:11:36Z
user: login: jelly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T14:21:34Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T14:33:30Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T14:56:22Z
user: login: martinpitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T15:11:25Z
user: login: cockpituous
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:08:30Z
user: login: jelly
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T08:51:34Z

Review comments:
diff_hunk: @@ -452,20 +453,30 @@ class Application extends React.Component {
                 });
         });
 
-        this.setState(prevState => ({ users: prevState.users.filter(u => u.uid !== con.uid) }));
+        // keep dummy (null) connections from other users, only remove valid ones
path: src/app.jsx
user: login: jelly
body: I assume we keep `null` connections as we make connections when required?
created_at: 2025-03-24T16:39:26Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,37 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '-name', 'libpod*s.cope', '-o', '-name', 'podman-*.scope',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          { superuser: "require", error: "message" })
path: src/app.jsx
user: login: jelly
body: Require seems unneeded, as I can list root's cgroup as normal user:

```
find /sys/fs/cgroup -iname  'libpod*s.cope' -o -name 'podman-*.scope' -exec stat --format="%u %U" {} \;
0 root
1000 jelle
```

Also this does not seem to work with quadlets, I guess since they are under a different cgroup scope? Not sure if we care about that? It seems we need to detect something like this then:

```
/sys/fs/cgroup/system.slice/jellyfin.service/libpod-payload-43d14c04471cef173a77ce5ffab61cfd793a31e833b916aeeb36a2bf549c26d5/
```
created_at: 2025-03-24T17:00:06Z
author_association: MEMBER
diff_hunk: @@ -452,20 +453,30 @@ class Application extends React.Component {
                 });
         });
 
-        this.setState(prevState => ({ users: prevState.users.filter(u => u.uid !== con.uid) }));
+        // keep dummy (null) connections from other users, only remove valid ones
path: src/app.jsx
user: login: martinpitt
body: Yes, otherwise if we'd drop them, the users would disappear from the owner selector. This is only meant to clean up users (i.e. connections) whose podman.service gets AWOL, not for our deliberate disconnects.
created_at: 2025-03-24T17:26:44Z
author_association: MEMBER
diff_hunk: @@ -23,8 +23,8 @@ const CR = '\r'.charCodeAt(0); // always 13, but avoid magic constant
 
 const PODMAN_SYSTEM_ADDRESS = "/run/podman/podman.sock";
 
-/* uid: null for logged in session user; 0 for root; in the future we'll support other users */
-/* Return { path, superuser } */
+/* uid: null for logged in session user, otherwise standard Unix user ID
+ * Return { path, superuser } */
path: src/rest.js
user: login: jelly
body: I am trying to remember why `null` for logged in user is special cased? Is this because of having to wait on `cockpit.user()`? Or the `getAddress` code below?

Wondering now if we can clean this up with `cockpit.info()` in the future. However already there and reviewed by me so ignore :)
created_at: 2025-03-24T17:27:12Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
path: test/check-application
user: login: jelly
body: Interesting, sounds like we can make the normal admin user session better as well:
```
        # Create admin session
        m.execute("""
            if [ ! -d /home/admin/.ssh ]; then
                mkdir /home/admin/.ssh
                cp /root/.ssh/* /home/admin/.ssh
                chown -R admin:admin /home/admin/.ssh
                chmod -R go-wx /home/admin/.ssh
            fi
            """)
```

I much prefer your approach with `m.write()`
created_at: 2025-03-24T17:43:31Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
+        for user in ['guitar', 'piano']:
+            m.execute(f"useradd --create-home {user}; loginctl enable-linger {user}")
+            uids[user] = m.execute(f"id -u {user}").strip()
+            m.write(f"/home/{user}/.ssh/authorized_keys", authorized_keys, owner=f"{user}:{user}", perm="600")
+            self.addCleanup(m.execute, f"loginctl disable-linger {user}; rm -rf /home/{user}")
+
+            ssh[user] = ssh_connection.SSHConnection(user=user,
+                                                     address=m.ssh_address,
+                                                     ssh_port=m.ssh_port,
+                                                     identity_file=m.identity_file)
+            ssh[user].execute(f"""
+            podman load < /var/lib/test-images/localhosttest-busyboxlatest.tar
+            podman tag {IMG_BUSYBOX} example.com/{user}/myimg
+            podman rmi {IMG_BUSYBOX}
+            podman run -d --name {user}c1 example.com/{user}/myimg sleep infinity
+            """)
+            self.addCleanup(ssh[user].execute, """
+            systemctl --user stop podman.service podman.socket
+            podman system reset --force
+            """)
+
+        # add a system and admin user container into the mix
+        self.execute(True, f"podman run -d --name sysc1 {IMG_BUSYBOX} sleep infinity")
+        self.execute(False, f"podman run -d --name adminc1 {IMG_BUSYBOX} sleep infinity")
+
+        self.login_and_go("/podman")
+        b.wait_visible("#app")
+
+        # standard and both extra users get detected, with well-defined sorting
+        b.wait_text("#containers-containers-owner", "systemadminguitarpianoAll")
+        b.wait_val("#containers-containers-owner", "all")
+
+        # shows system + admin by default
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+        self.waitNumContainers(2, auth=True)
+        b.wait_text("#containers-containers tbody:nth-of-type(1) .container-name", "adminc1")
+        b.wait_text("#containers-containers tbody:nth-of-type(2) .container-name", "sysc1")
+        showImages(b)
+
+        # switching to other user only shows its images and containers
+        for user in ['guitar', 'piano']:
+            b.set_val("#containers-containers-owner", uids[user])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", f"{user}c1")
+            b.wait_text("#containers-containers [data-label='Owner']", f"user: {user}")
+
+            b.wait_text("#containers-images [data-label='Image']", f"example.com/{user}/myimg:latest")
+            b.wait_text("#containers-images [data-label='Owner']", f"user: {user}")
+
+            self.waitNumImages(1)
+
+        # switch to "all", this keeps the piano connection still
path: test/check-application
user: login: jelly
body: Is this logical? It feels unlogical? If I would refresh the page it would only show admin/system right?
created_at: 2025-03-24T17:50:58Z
author_association: MEMBER
diff_hunk: @@ -23,8 +23,8 @@ const CR = '\r'.charCodeAt(0); // always 13, but avoid magic constant
 
 const PODMAN_SYSTEM_ADDRESS = "/run/podman/podman.sock";
 
-/* uid: null for logged in session user; 0 for root; in the future we'll support other users */
-/* Return { path, superuser } */
+/* uid: null for logged in session user, otherwise standard Unix user ID
+ * Return { path, superuser } */
path: src/rest.js
user: login: martinpitt
body: The little aspect is cockpit.user(), but we can sort this out easily. The big aspect is that it's special in two other regards: it doesn't need superuser for connecting (all other users do), and it looks at `$XDG_RUNTIME_DIR`. If we change `null` to `uid` we could compare `uid == cockpit.info.uid` here instead, that should work. But it's a bit unwieldy with the user promise. But after cockpit.info we can certainly revisit this!
created_at: 2025-03-24T18:02:51Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
+        for user in ['guitar', 'piano']:
+            m.execute(f"useradd --create-home {user}; loginctl enable-linger {user}")
+            uids[user] = m.execute(f"id -u {user}").strip()
+            m.write(f"/home/{user}/.ssh/authorized_keys", authorized_keys, owner=f"{user}:{user}", perm="600")
+            self.addCleanup(m.execute, f"loginctl disable-linger {user}; rm -rf /home/{user}")
+
+            ssh[user] = ssh_connection.SSHConnection(user=user,
+                                                     address=m.ssh_address,
+                                                     ssh_port=m.ssh_port,
+                                                     identity_file=m.identity_file)
+            ssh[user].execute(f"""
+            podman load < /var/lib/test-images/localhosttest-busyboxlatest.tar
+            podman tag {IMG_BUSYBOX} example.com/{user}/myimg
+            podman rmi {IMG_BUSYBOX}
+            podman run -d --name {user}c1 example.com/{user}/myimg sleep infinity
+            """)
+            self.addCleanup(ssh[user].execute, """
+            systemctl --user stop podman.service podman.socket
+            podman system reset --force
+            """)
+
+        # add a system and admin user container into the mix
+        self.execute(True, f"podman run -d --name sysc1 {IMG_BUSYBOX} sleep infinity")
+        self.execute(False, f"podman run -d --name adminc1 {IMG_BUSYBOX} sleep infinity")
+
+        self.login_and_go("/podman")
+        b.wait_visible("#app")
+
+        # standard and both extra users get detected, with well-defined sorting
+        b.wait_text("#containers-containers-owner", "systemadminguitarpianoAll")
+        b.wait_val("#containers-containers-owner", "all")
+
+        # shows system + admin by default
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+        self.waitNumContainers(2, auth=True)
+        b.wait_text("#containers-containers tbody:nth-of-type(1) .container-name", "adminc1")
+        b.wait_text("#containers-containers tbody:nth-of-type(2) .container-name", "sysc1")
+        showImages(b)
+
+        # switching to other user only shows its images and containers
+        for user in ['guitar', 'piano']:
+            b.set_val("#containers-containers-owner", uids[user])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", f"{user}c1")
+            b.wait_text("#containers-containers [data-label='Owner']", f"user: {user}")
+
+            b.wait_text("#containers-images [data-label='Image']", f"example.com/{user}/myimg:latest")
+            b.wait_text("#containers-images [data-label='Owner']", f"user: {user}")
+
+            self.waitNumImages(1)
+
+        # switch to "all", this keeps the piano connection still
path: test/check-application
user: login: martinpitt
body: Yes, that's the checkbox in the todo list. Thanks for your feedback -- I wasn't sure, but I'm happy to disconnect from all "third party" users when switching to "all".
created_at: 2025-03-24T18:03:47Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
path: test/check-application
user: login: martinpitt
body: Sure! See #2062 (not ready yet, but once it is I'll ask for your review)
created_at: 2025-03-25T11:20:55Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,37 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '-name', 'libpod*s.cope', '-o', '-name', 'podman-*.scope',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          { superuser: "require", error: "message" })
path: src/app.jsx
user: login: martinpitt
body: > Require seems unneeded

It's a simple/sneaky way to avoid the detection altogether if you don't have root, as *connecting* to other user's podman services definitively needs root. I'll add a comment.

As for quadlets, I'll add an integration test and fix it up, thanks!
created_at: 2025-03-25T12:12:18Z
author_association: MEMBER
diff_hunk: @@ -23,8 +23,8 @@ const CR = '\r'.charCodeAt(0); // always 13, but avoid magic constant
 
 const PODMAN_SYSTEM_ADDRESS = "/run/podman/podman.sock";
 
-/* uid: null for logged in session user; 0 for root; in the future we'll support other users */
-/* Return { path, superuser } */
+/* uid: null for logged in session user, otherwise standard Unix user ID
+ * Return { path, superuser } */
path: src/rest.js
user: login: jelly
body: Agreed, resolving.
created_at: 2025-03-25T13:23:29Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
+        for user in ['guitar', 'piano']:
+            m.execute(f"useradd --create-home {user}; loginctl enable-linger {user}")
+            uids[user] = m.execute(f"id -u {user}").strip()
+            m.write(f"/home/{user}/.ssh/authorized_keys", authorized_keys, owner=f"{user}:{user}", perm="600")
+            self.addCleanup(m.execute, f"loginctl disable-linger {user}; rm -rf /home/{user}")
+
+            ssh[user] = ssh_connection.SSHConnection(user=user,
+                                                     address=m.ssh_address,
+                                                     ssh_port=m.ssh_port,
+                                                     identity_file=m.identity_file)
+            ssh[user].execute(f"""
+            podman load < /var/lib/test-images/localhosttest-busyboxlatest.tar
+            podman tag {IMG_BUSYBOX} example.com/{user}/myimg
+            podman rmi {IMG_BUSYBOX}
+            podman run -d --name {user}c1 example.com/{user}/myimg sleep infinity
+            """)
+            self.addCleanup(ssh[user].execute, """
+            systemctl --user stop podman.service podman.socket
+            podman system reset --force
+            """)
+
+        # add a system and admin user container into the mix
+        self.execute(True, f"podman run -d --name sysc1 {IMG_BUSYBOX} sleep infinity")
+        self.execute(False, f"podman run -d --name adminc1 {IMG_BUSYBOX} sleep infinity")
+
+        self.login_and_go("/podman")
+        b.wait_visible("#app")
+
+        # standard and both extra users get detected, with well-defined sorting
+        b.wait_text("#containers-containers-owner", "systemadminguitarpianoAll")
+        b.wait_val("#containers-containers-owner", "all")
+
+        # shows system + admin by default
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+        self.waitNumContainers(2, auth=True)
+        b.wait_text("#containers-containers tbody:nth-of-type(1) .container-name", "adminc1")
+        b.wait_text("#containers-containers tbody:nth-of-type(2) .container-name", "sysc1")
+        showImages(b)
+
+        # switching to other user only shows its images and containers
+        for user in ['guitar', 'piano']:
+            b.set_val("#containers-containers-owner", uids[user])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", f"{user}c1")
+            b.wait_text("#containers-containers [data-label='Owner']", f"user: {user}")
+
+            b.wait_text("#containers-images [data-label='Image']", f"example.com/{user}/myimg:latest")
+            b.wait_text("#containers-images [data-label='Owner']", f"user: {user}")
+
+            self.waitNumImages(1)
+
+        # switch to "all", this keeps the piano connection still
path: test/check-application
user: login: jelly
body: My vote would be drop all other users when switching from `all`.
created_at: 2025-03-25T13:27:34Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
path: test/check-application
user: login: martinpitt
body: #2062 landed.
created_at: 2025-03-25T14:11:36Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,37 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '-name', 'libpod*s.cope', '-o', '-name', 'podman-*.scope',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          { superuser: "require", error: "message" })
path: src/app.jsx
user: login: jelly
body: Right, that makes sense to avoid it for non-superusers.
created_at: 2025-03-25T14:21:33Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,37 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '-name', 'libpod*s.cope', '-o', '-name', 'podman-*.scope',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          { superuser: "require", error: "message" })
path: src/app.jsx
user: login: martinpitt
body: Curiously quadlets work as there is a (hidden) podman-pause container, or at least cgroup. But let's not rely on this, but detect it from sysfs directly.
created_at: 2025-03-25T14:33:30Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,37 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '-name', 'libpod*s.cope', '-o', '-name', 'podman-*.scope',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          { superuser: "require", error: "message" })
path: src/app.jsx
user: login: martinpitt
body: Done!
created_at: 2025-03-25T14:56:22Z
author_association: MEMBER
diff_hunk: @@ -3277,6 +3278,111 @@ PodName={name}
     def testQuadletsUsers(self):
         self._testQuadlets(system=False)
 
+    def testOtherUsers(self):
+        m = self.machine
+        b = self.browser
+
+        # create two system users with running container
+        ssh = {}
+        uids = {}
+        with open(DEFAULT_IDENTITY_PUB_FILE) as f:
+            authorized_keys = f.read()
+        for user in ['guitar', 'piano']:
+            m.execute(f"useradd --create-home {user}; loginctl enable-linger {user}")
+            uids[user] = m.execute(f"id -u {user}").strip()
+            m.write(f"/home/{user}/.ssh/authorized_keys", authorized_keys, owner=f"{user}:{user}", perm="600")
+            self.addCleanup(m.execute, f"loginctl disable-linger {user}; rm -rf /home/{user}")
+
+            ssh[user] = ssh_connection.SSHConnection(user=user,
+                                                     address=m.ssh_address,
+                                                     ssh_port=m.ssh_port,
+                                                     identity_file=m.identity_file)
+            ssh[user].execute(f"""
+            podman load < /var/lib/test-images/localhosttest-busyboxlatest.tar
+            podman tag {IMG_BUSYBOX} example.com/{user}/myimg
+            podman rmi {IMG_BUSYBOX}
+            podman run -d --name {user}c1 example.com/{user}/myimg sleep infinity
+            """)
+            self.addCleanup(ssh[user].execute, """
+            systemctl --user stop podman.service podman.socket
+            podman system reset --force
+            """)
+
+        # add a system and admin user container into the mix
+        self.execute(True, f"podman run -d --name sysc1 {IMG_BUSYBOX} sleep infinity")
+        self.execute(False, f"podman run -d --name adminc1 {IMG_BUSYBOX} sleep infinity")
+
+        self.login_and_go("/podman")
+        b.wait_visible("#app")
+
+        # standard and both extra users get detected, with well-defined sorting
+        b.wait_text("#containers-containers-owner", "systemadminguitarpianoAll")
+        b.wait_val("#containers-containers-owner", "all")
+
+        # shows system + admin by default
+        self.waitNumImages(self.user_images_count + self.system_images_count)
+        self.waitNumContainers(2, auth=True)
+        b.wait_text("#containers-containers tbody:nth-of-type(1) .container-name", "adminc1")
+        b.wait_text("#containers-containers tbody:nth-of-type(2) .container-name", "sysc1")
+        showImages(b)
+
+        # switching to other user only shows its images and containers
+        for user in ['guitar', 'piano']:
+            b.set_val("#containers-containers-owner", uids[user])
+            self.waitNumContainers(1, auth=False)
+            b.wait_text("#containers-containers .container-name", f"{user}c1")
+            b.wait_text("#containers-containers [data-label='Owner']", f"user: {user}")
+
+            b.wait_text("#containers-images [data-label='Image']", f"example.com/{user}/myimg:latest")
+            b.wait_text("#containers-images [data-label='Owner']", f"user: {user}")
+
+            self.waitNumImages(1)
+
+        # switch to "all", this keeps the piano connection still
path: test/check-application
user: login: martinpitt
body: Done.
created_at: 2025-03-25T15:11:25Z
author_association: MEMBER
diff_hunk: @@ -528,6 +539,45 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '(', '-name', 'libpod.*scope', '-o', '-name', 'podman-*.scope',
+                '-o', '-name', 'libpod-payload*', ')',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          // this find command doesn't need root, but user switching does;
+                          // hence skip the whole detection for unpriv sessions
+                          { superuser: "require", error: "message" })
+                    .then(output => {
+                        const other_users = [];
+                        const trimmed = output.trim();
+                        if (!trimmed)
+                            return;
+
+                        trimmed.split('\n').forEach(line => {
+                            const [uid_str, username] = line.split(' ');
+                            const uid = parseInt(uid_str);
+                            if (isNaN(uid)) {
path: src/app.jsx
user: login: cockpituous
body: This added line is not executed by any test.
created_at: 2025-03-25T17:08:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -528,6 +539,45 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '(', '-name', 'libpod.*scope', '-o', '-name', 'podman-*.scope',
+                '-o', '-name', 'libpod-payload*', ')',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          // this find command doesn't need root, but user switching does;
+                          // hence skip the whole detection for unpriv sessions
+                          { superuser: "require", error: "message" })
+                    .then(output => {
+                        const other_users = [];
+                        const trimmed = output.trim();
+                        if (!trimmed)
+                            return;
+
+                        trimmed.split('\n').forEach(line => {
+                            const [uid_str, username] = line.split(' ');
+                            const uid = parseInt(uid_str);
+                            if (isNaN(uid)) {
+                                console.error(`User container detection: invalid uid '${uid_str}' in output '${output}'`); // not-covered: Should Not Happen™
+                                return; // not-covered: dito
+                            }
+                            // ignore standard users
+                            if (uid === 0 || uid === user.id)
+                                return;
+                            if (!other_users.find(u => u.uid === uid))
+                                other_users.push({ uid, name: username, con: null });
+                        });
+                        debug("other users who have containers running:", JSON.stringify(other_users));
+                        this.setState(prevState => ({ users: prevState.users.concat(other_users) }));
+                    })
+                    .catch(ex => {
+                        if (ex.problem == 'access-denied')
+                            debug("unprivileged session, skipping detection of other users");
path: src/app.jsx
user: login: cockpituous
body: This added line is not executed by any test.
created_at: 2025-03-25T17:08:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -528,6 +539,45 @@ class Application extends React.Component {
                     this.setState({ userLingeringEnabled: true });
                 }
             });
+
+            // detect which other users have containers running
+            cockpit.spawn([
+                'find', '/sys/fs/cgroup',
+                // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
+                '(', '-name', 'libpod.*scope', '-o', '-name', 'podman-*.scope',
+                '-o', '-name', 'libpod-payload*', ')',
+                '-exec', 'stat', '--format=%u %U', '{}', ';'],
+                          // this find command doesn't need root, but user switching does;
+                          // hence skip the whole detection for unpriv sessions
+                          { superuser: "require", error: "message" })
+                    .then(output => {
+                        const other_users = [];
+                        const trimmed = output.trim();
+                        if (!trimmed)
+                            return;
+
+                        trimmed.split('\n').forEach(line => {
+                            const [uid_str, username] = line.split(' ');
+                            const uid = parseInt(uid_str);
+                            if (isNaN(uid)) {
+                                console.error(`User container detection: invalid uid '${uid_str}' in output '${output}'`); // not-covered: Should Not Happen™
+                                return; // not-covered: dito
+                            }
+                            // ignore standard users
+                            if (uid === 0 || uid === user.id)
+                                return;
+                            if (!other_users.find(u => u.uid === uid))
+                                other_users.push({ uid, name: username, con: null });
+                        });
+                        debug("other users who have containers running:", JSON.stringify(other_users));
+                        this.setState(prevState => ({ users: prevState.users.concat(other_users) }));
+                    })
+                    .catch(ex => {
+                        if (ex.problem == 'access-denied')
+                            debug("unprivileged session, skipping detection of other users");
+                        else
+                            console.warn("failed to detect other users:", ex);
path: src/app.jsx
user: login: cockpituous
body: This added line is not executed by any test.
created_at: 2025-03-25T17:08:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: martinpitt
created_at: 2025-02-27T18:30:03Z
body: @garrett I'm on PTO next week until Wed. If you want to take a look already, this has an integration test and the description also has instructions for manual testing. I'm not sure if you already have opinions/ideas how we could expose multiple users -- for the tech demo I just took the most straightforward approach and use the existing owner selector. Maybe that's even good enough for a first version :thinking: 
author_association: MEMBER
user: login: martinpitt
created_at: 2025-03-07T06:41:18Z
body: Notes about the [debian-testing hang](https://cockpit-logs.us-east-1.linodeobjects.com/pull-2024-e259d680-20250306-072002-debian-testing/log.html) (which in similar form occurs everywhere):

 * This happens after switching back and forth between users a few times. After that it fails with "init uid 1003 getInfo failed: Error: timeout". Once c-podman is in that state, any further user switching fails the same way, even the system service (root) after a page reload.
 * `curl --unix-socket /run/user/1003/podman/podman.sock 'http://x/v1.12/libpod/containers/json?all=true'` in that state works fine.
 * Adding debugging to the bridge http channel shows that it gets up to "read body" [here](https://github.com/cockpit-project/cockpit/blob/9a98439cca6/src/cockpit/channels/http_channel.py#L117), but the `self.connect` or reading the response hangs -- it never gets [here](https://github.com/cockpit-project/cockpit/blob/9a98439cca6/src/cockpit/channels/http_channel.py#L132).
 * `strace -fvvs10000 -e 'trace=!epoll_pwait,futex,nanosleep,ppoll'` on the podman service process shows that it does not even receive the request, so the bridge http channel gets wedged.
 * Logging out and back in without restarting the podman.services works fine.
 * This still happens after disabling `updateContainerStats()`, the C bridge hack (second event stream), and serializing the get containers/image/pods and only running event streaming afterwards.
 * This is reproducible with just reloading the podman page a few times and doing some events in between (like stopping and starting a container).

So this is a bridge http bug. I wonder if the event monitoring (the http long call) somehow causes this? -- It does, replacing this with an unix stream channel works fine.

See #2038.
author_association: MEMBER
user: login: jelly
created_at: 2025-03-26T09:05:40Z
body: Merging but this needs a release-note :) 
author_association: MEMBER