Skip to content

Implement socket=True in Container.exec_run via connection hijacking - #648

Merged
inknos merged 1 commit into
containers:mainfrom
oligiochi:exec-socket-streaming
Jul 20, 2026
Merged

inknos merged 1 commit into
containers:mainfrom
oligiochi:exec-socket-streaming

Conversation

@oligiochi

Copy link
Copy Markdown
Contributor

The libpod REST API supports upgrading the exec start request to a raw bidirectional stream (101 Switching Protocols). Send the Upgrade headers, keep the response unconsumed (stream=True) and hand the caller the underlying UDS socket, anchoring the response to keep the hijacked connection out of the urllib3 pool.

Fixes #421

Signed-off-by: Giovanni Oliverio oligiovi2@gmail.com

Fixes #421

Problem

Container.exec_run() accepts a socket parameter (documented as "Return
the connection socket to allow custom read/write operations") but the
parameter is silently ignored: the call always blocks until the command
exits and returns the buffered output. This makes interactive exec
sessions (a shell with live stdin/stdout, terminal emulators, REPLs)
impossible with the native bindings, while the same use case works with
docker-py against Podman's compatibility socket.

The limitation is client-side only: the libpod REST API fully supports
hijacking the exec start request into a raw bidirectional stream.

Solution

When socket=True, send the POST /exec/{id}/start request with
Connection: Upgrade / Upgrade: tcp headers and stream=True (so
requests does not consume the body). The server replies
101 Switching Protocols and the underlying connection stops being HTTP:
it becomes a raw bidirectional byte channel attached to the exec
session's stdin/stdout/stderr (raw stream with tty=True, multiplexed
frames otherwise). The socket is extracted from the response via
response.raw._connection.sock_connection here is podman-py's own
UDSConnection (podman/api/uds.py), which keeps a reference to its
socket, so the only private attribute traversed is urllib3's
HTTPResponse._connection.

The response object is anchored on the returned socket
(sock._hijacked_response) so the hijacked connection cannot be released
back to the urllib3 pool while the caller is using it; a later request on
the same client would otherwise write HTTP into the exec session.

Detach is forced to False in this branch: a socket to a detached
session is meaningless (consistent with docker-py behaviour).

Return contract matches docker-py: (None, socket); the caller is
responsible for reading, writing and closing the socket.

Testing

  • Unit test: verifies that socket=True issues the start request with
    the Upgrade headers, stream=True and Detach: false, and returns the
    extracted socket (extraction helper mocked, since the hijack cannot
    happen against requests_mock).
  • Integration test: opens an interactive /bin/sh via
    exec_run(socket=True), writes a command computing a marker and reads
    it back from the live socket.
  • Manually validated against Podman 4.9.3 (rootless, crun, UDS) with
    podman-py 5.8.0: upgrade returns 101, the channel is bidirectional
    while the command is still running, terminal resize via
    /exec/{id}/resize is honoured, exit codes are reported by
    /exec/{id}/json, and concurrent requests on the same client open a
    separate pool connection without corrupting the hijacked stream.

@oligiochi
oligiochi marked this pull request as draft July 13, 2026 14:57
@oligiochi
oligiochi force-pushed the exec-socket-streaming branch from d7bae46 to 28248df Compare July 13, 2026 15:18

@inknos inknos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the PR. I saw you converted to draft so I'll stop reviewing, but here are a couple of comments.

I am also thinking if it will break if the socket is passed via ssh. The reason is, when you hijack the socket over http you can get a clean file descriptor for read/write operations. But when the connection is handled via ssh the python code overrides the read/write methods and you pipe to different ssh subprocess. This will give you different file descriptors and it will break.

So the SSH case needs to be handled with care. A quick solution would be to raise NotImplementedError for ssh connections to keep it simple.

Comment thread podman/domain/containers.py Outdated
)
start_resp.raise_for_status()

sock = start_resp.raw._connection.sock

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you need raw.connection instead of _connection

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, I believe we need a None guard here in case the response is consumed or the socket is None for some reason

conn = start_resp.raw.connection
if conn is None or conn.sock is None:
    raise RuntimeError("unable to extract socket from hijacked connection")
sock = conn.sock

not sure about the message, but I would avoid throwing a python error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! I've addressed all three points:

  1. Fixed raw._connection → raw.connection (public property instead of the private urllib3 attribute).
  2. Added a None guard before extracting the socket:
conn = start_resp.raw.connection
if conn is None or conn.sock is None:
    raise APIError(
        f"/exec/{exec_id}/start",
        explanation="Unable to extract socket from hijacked connection",
        response=start_resp,
    )
sock = conn.sock

Used APIError instead of a bare Python exception, consistent with the rest of the codebase.

  1. Added an explicit guard for SSH connections, raising NotImplementedError:
if self.client.api.base_url.scheme == "http+ssh":
    raise NotImplementedError("exec_run(socket=True) is not supported over SSH")

You're right that hijacking wouldn't work cleanly there since the socket ends up piped through an SSH subprocess rather than being a direct fd. Happy to look into proper SSH support as a follow-up if there's interest, but wanted to keep this PR focused on the UDS case.
Ready for another look whenever you have time.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used APIError

Makes sense

if self.client.api.base_url.scheme == "http+ssh":
raise NotImplementedError("exec_run(socket=True) is not supported over SSH")

Should be if self.client.base_url.scheme

Happy to look into proper SSH support as a follow-up if there's interest, but wanted to keep this PR focused on the UDS case

definitely, it might be/not be so trivial, but let's make it another PR

I'll give it another round of review tomorrow

@oligiochi oligiochi Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be if self.client.base_url.scheme

Yes, I saw that and fixed it in the next commit, but thanks anyway.

@oligiochi
oligiochi marked this pull request as ready for review July 13, 2026 16:36
@oligiochi
oligiochi force-pushed the exec-socket-streaming branch 2 times, most recently from 9fd320e to 5ee8317 Compare July 13, 2026 16:46
@oligiochi
oligiochi force-pushed the exec-socket-streaming branch from 5ee8317 to 199974c Compare July 13, 2026 20:33
@oligiochi

Copy link
Copy Markdown
Contributor Author

/packit test

@oligiochi
oligiochi force-pushed the exec-socket-streaming branch 2 times, most recently from fed0694 to 4567fdd Compare July 14, 2026 11:31
@oligiochi

Copy link
Copy Markdown
Contributor Author

The testing-farm:fedora-rawhide-x86_64:distro-fedora-all job fails deterministically (retriggered via /packit test, same result), but I'm unable to reproduce it locally:

Full unit test suite passes on my machine (274 passed), with both pytest and unittest discover, including with the latest urllib3/requests/requests-mock
It also passes inside a fedora:rawhide container (Python 3.15), again with both runners

All other testing-farm targets (fedora 43/44, centos-stream 14/15, epel, unittest-coverage) pass. The truncated failure log only shows urllib3 exhausting retries (raise reraise(...) in urllib3/util/retry.py).
Is this a known issue with the rawhide plan, or is there something environment-specific I should look at? Happy to dig further if you can point me at the relevant log.

Implement connection hijacking over the exec start endpoint so
exec_run(socket=True) returns the raw socket, matching docker-py's
return contract (None, socket). The response is anchored on the
socket object to prevent urllib3 from reclaiming the hijacked
connection.

Fixes containers#421

Signed-off-by: Giovanni <oligiovi2@gmail.com>
@oligiochi
oligiochi force-pushed the exec-socket-streaming branch from 4567fdd to 9aa49c1 Compare July 15, 2026 12:50
@oligiochi

Copy link
Copy Markdown
Contributor Author

/packit test

@oligiochi

Copy link
Copy Markdown
Contributor Author

/packit retest-failed

@oligiochi
oligiochi marked this pull request as draft July 16, 2026 15:18
@oligiochi

Copy link
Copy Markdown
Contributor Author

CI failure analysis: test_ssh_ping — unrelated to this PR

TL;DR

The only failing test in the CI run is
podman/tests/integration/test_adapters.py::AdapterIntegrationTest::test_ssh_ping,
failing identically in all six tox environments (coverage, py39py313).
The failure originates in the SSH port-forwarding layer of the CI runner
(sshd cannot connect to the podman service's UNIX socket), not in Python code,
and it occurs before any code touched by this PR is ever executed. All
331 other tests pass, including the two new tests added by this PR. Below is
the full evidence.

What the failing run looks like

Environment: Podman 6.0.1 on the runner, tox -e coverage,py39,py310,py311,py312,py313.

Every environment reports the same single failure:

=========================== short test summary info ============================
FAILED podman/tests/integration/test_adapters.py::AdapterIntegrationTest::test_ssh_ping
=== 1 failed, 327 passed, 4 skipped, 88 subtests passed in 67.08s (0:01:07) ====

For comparison, an earlier run of the same tox matrix on a runner with Podman
6.0.0 (July 1) passed everything, including test_ssh_ping:

======== 362 passed, 4 skipped, 74 subtests passed in 74.57s =========
  coverage: OK ... py313: OK
  congratulations :)

The only relevant difference between the two runs, besides the source tree,
is the runner image and its Podman version (6.0.0 → 6.0.1).

Anatomy of the failure

test_ssh_ping starts a podman system service on a fresh UNIX socket under
/tmp, then reaches it through an SSH tunnel:

def test_ssh_ping(self):
    with PodmanClient(
        base_url=f"http+ssh://{getpass.getuser()}@localhost:22{self.socket_file}"
    ) as client:
        self.assertTrue(client.ping())

Passing run (Podman 6.0.0):

Launching(6.0.0) podman ... system service --time=0 unix:///tmp/tmp35fi.../...
Starting new HTTP connection (1): localhost:22
Waiting on /run/user/0/podman/podman-forward-....sock   (x2)
http://localhost:22 "HEAD /v5.8.0/libpod/_ping HTTP/1.1" 200 0
PASSED

Failing run (Podman 6.0.1) — identical up to the request, then:

Launching(6.0.1) podman ... system service --time=0 unix:///tmp/tmp03r8.../...
Starting new HTTP connection (1): localhost:22
Waiting on /run/user/0/podman/podman-forward-....sock   (x2)
send: HEAD /v5.8.0/libpod/_ping ...
[ssh stderr]  channel 1: open failed: connect failed: open failed
FAILED

Resulting exception chain:

ConnectionResetError(104, 'Connection reset by peer')
  → requests.exceptions.ConnectionError ('Connection aborted.')
    → podman.errors.exceptions.APIError:
      http://root@localhost:22/v5.8.0/libpod/_ping (HEAD operation failed)

The key line is emitted by the ssh client, not by Python:

channel 1: open failed: connect failed: open failed

The client-side forward socket
(/run/user/0/podman/podman-forward-*.sock) is created and accepts the
connection exactly as in the passing run. The failure happens on the remote
end of the tunnel: when the HEAD request traverses the channel, sshd fails
to connect to the target UNIX socket
(the podman service socket under
/tmp), closes the channel, and the client observes ECONNRESET. Note that
the podman system service process itself starts and exits cleanly
(Command return Code: 0), so the service side is healthy — it is the
sshd → UNIX-socket hop that breaks.

Plausible causes, in order of likelihood:

  1. A behavior change between Podman 6.0.0 and 6.0.1 in system service
    socket creation (timing, path, or permissions) as seen by sshd.
  2. A change in the runner image's sshd configuration (e.g. filesystem
    sandboxing such as PrivateTmp, which would make a socket under /tmp
    invisible to sshd-spawned channels).
  3. A startup race — least likely, since the launch-to-request interval
    (~1 s) is the same in both runs.

Why this PR cannot be the cause

  1. Execution order. test_adapters.py sorts first, so test_ssh_ping is
    the very first test executed in each pytest process — it fails at [ 0%],
    before any test added by this PR runs and before exec_run() is called
    even once in that process. There is no runtime state originating from this
    PR at the moment of failure.

  2. The changed code is unreachable from this test. The production diff is
    confined to Container.exec_run(), and within it the new code runs only
    when socket=True. test_ssh_ping calls client.ping()
    HEAD /libpod/_ping; exec_run() is never invoked. The PR adds no new
    imports and no module-level code, so there are no import-time side effects
    either.

  3. The error originates outside Python. The channel 1: open failed
    message comes from sshd's failure to open a UNIX socket; no podman-py code
    (patched or otherwise) executes in that process.

  4. Deterministic across six fresh virtualenvs. The failure is identical
    in coverage and py39py313, each a freshly built environment, each
    failing at its first test — including the first environment of the run,
    i.e. a completely virgin process on the runner.

  5. No cross-process channel. Each test launches its own
    podman system service on a fresh socket path; a hypothetical leaked
    resource from another test could only reference a different, already-dead
    socket and cannot affect sshd connecting to a new one. The new tests also
    clean up after themselves (sock.close() in a finally block,
    remove(force=True) in tearDown).

  6. Not a stale-branch artifact. The SSH test and its infrastructure have
    not changed upstream since well before this branch's merge base
    (test_adapters.py last touched Nov 2024, podman/api/ssh.py Sep 2024,
    tests/integration/{utils,base}.py Mar 2026), so the branch is not
    missing any upstream fix relevant to this test.

Meanwhile, the tests that do exercise the PR pass everywhere:

  • podman/tests/unit/test_container.py::ContainersTestCase::test_exec_run_socket → PASSED (all envs)
  • podman/tests/integration/test_container_exec.py::ContainersExecIntegrationTests::test_container_exec_run_socket → PASSED (all envs), with the hijack visible in the logs:
POST /v5.8.0/libpod/containers/.../exec HTTP/1.1" 201
POST /v5.8.0/libpod/exec/.../start HTTP/1.1" 101 0    ← 101 Switching Protocols

Suggested verification

Running pytest podman/tests/integration/test_adapters.py from a clean
checkout of main on the same runner (Podman 6.0.1) should reproduce the
same test_ssh_ping failure, confirming it is a runner/environment
regression independent of this branch. Happy to help bisect between Podman
6.0.0 and 6.0.1 or against the runner image if useful.

@inknos

inknos commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

your pr looks good, thanks!

also, great investigation on the failures, I will go and take a look at podman 6.0.1 changes.

@oligiochi
oligiochi marked this pull request as ready for review July 20, 2026 11:58
@inknos

inknos commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

I will go and take a look at podman 6.0.1 changes.

on second thought, I believe it could be even infrastructure related. I couldn't reproduce locally on my system. I'll try a rawhide box to check if it's rawhide related

@oligiochi

oligiochi commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

on second thought, I believe it could be even infrastructure related. I couldn't reproduce locally on my system. I'll try a rawhide box to check if it's rawhide related

If it turns out to be infrastructure-related, I'd be happy to help investigate further.

@inknos

inknos commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

If it turns out to be infrastructure-related, I'd be happy to help investigate further.

it is and it's related to SELinux policies in ssh. I was checking, and I am almost at the bottom of it, I'll share what I found.

I reproduced on a rawhide box, with SELinux enabled. If you set it to permissive you'll get the test passing with this line in the logs (or similar)

type=AVC msg=audit(1784552999.028:754): avc:  denied  { connectto } for  pid=3768 comm="sshd-session" path="/tmp/tmpu4f66z2l/72a732c0fd6244f1bbdfe32c3a4eed03" scontext=system_u:system_r:sshd_session_t:s0-s0:c0.c1023 tcontext=unconfined_u:unconfined_r:container_runtime_t:s0-s0:c0.c1023 tclass=unix_stream_socket permissive=0

I am not that fluent in SELinux, but I believe ssh forwardings run in the sshd-session binary, which Fedora's SELinux policy assigns to the new sshd_session_t domain. The containers/container-selinux policies should be updated to grant sshd_session_t the connectto permission on container_runtime_t sockets.

@oligiochi

Copy link
Copy Markdown
Contributor Author

approved these changes

The test_ssh_ping CI failure has been root-caused as an SELinux issue unrelated to this PR. The OpenSSH 9.8+ split of sshd into sshd/sshd-session means SSH forwarding now runs as sshd_session_t, which lacked connectto on container_runtime_t sockets. Tracked and fixed in containers/container-selinux#478 and containers/container-selinux#479.

Since the failure is confirmed environmental and independent of these changes, would you prefer to merge this PR now, or wait for the policy fix to land on the Testing Farm runners?

@inknos

inknos commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

ok, since we are gating podman-py again before the next release it can be merged

@inknos
inknos merged commit 8d5ea3a into containers:main Jul 20, 2026
18 of 20 checks passed
@oligiochi

Copy link
Copy Markdown
Contributor Author

ok, since we are gating podman-py again before the next release it can be merged

thank you so much

@inknos

inknos commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

thank you so much

sure and thanks a lot for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

exec_run(cmd, socket=True) isn't implemented: blocks and only returns resulting stdout/stderr bytes

2 participants