Skip to content

Commit 059809a

Browse files
h4x3rotabclaude
andcommitted
tsm-shim: fix FIFO wedge + concurrent-writer desync, trim verbosity
Addresses the review of the configfs-tsm shim: - outblob write now opens non-blocking with a deadline (TSM_OUTBLOB_DEADLINE), so a caller that writes inblob then dies no longer wedges the daemon forever. - reject inblob writes >64 bytes (a sign of racing writers) instead of returning a quote bound to ambiguous data -- fail closed, not silently wrong. - document the single in-flight requester constraint; drop the false "no race" claims; document that an empty outblob read means the quote failed. - pin the base image by digest; trim docstrings, README, and compose comments. Verified: happy path, >64B fail-closed guard, and no-wedge recovery covered by a local test; image e2e green; dev.sh validate + yamllint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6400d5f commit 059809a

5 files changed

Lines changed: 123 additions & 166 deletions

File tree

tsm-shim/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Pure stdlib — no pip, no build deps. Built & pushed to GHCR by
22
# .github/workflows/build-tsm-shim.yml.
3-
FROM python:3.12-slim
3+
FROM python:3.12-slim@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e
44

55
LABEL org.opencontainers.image.title="dstack-tsm-shim"
66
LABEL org.opencontainers.image.description="configfs-tsm compatibility shim: re-exposes dstack guest-agent GetQuote as inblob/outblob FIFOs"
@@ -13,7 +13,7 @@ ENV TSM_REPORT_DIR=/run/tsm/report \
1313
DSTACK_SOCKET=/var/run/dstack.sock
1414

1515
# Report healthy only once both FIFOs exist, so an app can gate on
16-
# `depends_on: { tsm-shim: { condition: service_healthy } }` with no race.
16+
# `depends_on: { tsm-shim: { condition: service_healthy } }`.
1717
HEALTHCHECK --interval=2s --timeout=2s --retries=30 --start-period=1s \
1818
CMD test -p "$TSM_REPORT_DIR/inblob" && test -p "$TSM_REPORT_DIR/outblob" || exit 1
1919

tsm-shim/README.md

Lines changed: 29 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,18 @@
22

33
Some programs request a TDX quote through the **standard Linux interfaces**
44
`configfs-tsm` (`/sys/kernel/config/tsm/report/*`, with `inblob`/`outblob`) and a
5-
`/dev/tdx-guest` device — rather than through the dstack SDK / guest-agent
6-
socket. On a stock dstack CVM those kernel interfaces aren't exposed to app
7-
containers, so such binaries fail out of the box.
5+
`/dev/tdx-guest` device — rather than through the dstack SDK / guest-agent socket.
6+
A stock dstack CVM doesn't expose those kernel interfaces to app containers, so
7+
such binaries fail out of the box.
88

9-
This example ships a small **sidecar** that bridges the gap. It re-exposes the
10-
guest-agent's `GetQuote` RPC under the configfs-tsm file ABI: your app writes
9+
This example ships a small **sidecar** that bridges the gap: it re-exposes the
10+
guest-agent's `GetQuote` RPC under the configfs-tsm file ABI. Your app writes
1111
`report_data` to `inblob` and reads the raw Intel DCAP TDX quote from `outblob`,
12-
exactly as it would against the kernel.
12+
exactly as against the kernel. No OS change, no FUSE, no privileged container; the
13+
quote is the genuine hardware quote and `report_data` is forwarded byte-for-byte.
1314

14-
- **No OS change** — pure userspace, runs in a normal container.
15-
- **No FUSE, no `CAP_SYS_ADMIN`, no privileged mode, no device passthrough.**
16-
- **No weaker attestation** — the quote is the genuine hardware quote and
17-
`report_data` is forwarded byte-for-byte.
18-
19-
The shim image is built and published to GHCR by
20-
[`.github/workflows/build-tsm-shim.yml`](../.github/workflows/build-tsm-shim.yml):
15+
The image is built and published to GHCR by
16+
[`build-tsm-shim.yml`](../.github/workflows/build-tsm-shim.yml):
2117
`ghcr.io/dstack-tee/dstack-tsm-shim`.
2218

2319
## Try it
@@ -69,46 +65,32 @@ volumes:
6965
7066
If your binary **hard-codes** `/sys/kernel/config/tsm/report`, mount the shared
7167
volume there instead of using `TSM_REPORT_PATH`:
72-
73-
```yaml
74-
volumes:
75-
- tsm-report:/sys/kernel/config/tsm/report
76-
```
77-
78-
For production, pin the image by digest (e.g.
79-
`ghcr.io/dstack-tee/dstack-tsm-shim:latest@sha256:...`).
68+
`- tsm-report:/sys/kernel/config/tsm/report`. For production, pin the image by
69+
digest (`ghcr.io/dstack-tee/dstack-tsm-shim@sha256:...`).
8070

8171
## How it works
8272

83-
`tsm-shim` exposes `inblob` and `outblob` as **named pipes (FIFOs)** in a shared
84-
volume. A read of `outblob` blocks until the quote for the most recent `inblob`
85-
write is ready — which matches configfs-tsm's write-then-read contract with no
86-
race and no privileges. When `inblob` is written, the shim calls
87-
`POST /GetQuote` on `/var/run/dstack.sock` and writes the returned quote to
88-
`outblob`. The image reports healthy only once both FIFOs exist, so the app can
89-
gate on `depends_on: { condition: service_healthy }`.
90-
91-
The `/dev/tdx-guest` device can't be created inside another container from a
92-
sidecar, so an empty volume is mounted at that path — enough for the common
93-
"does the device exist?" check. (dstack permits mounting under `/dev`.)
94-
95-
## Files
73+
`inblob`/`outblob` are **named pipes (FIFOs)** in a shared volume; a read of
74+
`outblob` blocks until the quote is ready (configfs-tsm's write-then-read
75+
contract). On an `inblob` write the shim `POST`s `/GetQuote` to
76+
`/var/run/dstack.sock` and writes the quote to `outblob`. The image reports
77+
healthy only once both FIFOs exist, so the app gates on `service_healthy`. An
78+
**empty `outblob` read means the quote failed** (the shim logs why).
9679

97-
| File | Purpose |
98-
|------|---------|
99-
| `tsm_shim.py` | the sidecar daemon (pure Python stdlib, no dependencies) |
100-
| `demo-app.py` | a stand-in unmodified configfs-tsm consumer (bundled self-test) |
101-
| `Dockerfile` | builds the published image |
102-
| `docker-compose.yaml` | the shim + demo app wired together |
80+
`/dev/tdx-guest` can't be created in another container from a sidecar, so an
81+
empty volume is mounted there to satisfy the common "does the device exist?"
82+
check (dstack permits mounting under `/dev`).
10383

10484
## Limitations
10585

10686
- Covers the **configfs-tsm `inblob`/`outblob`** path (used by `go-configfs-tsm`,
10787
recent `libtdx-attest`, etc.).
108-
- Does **not** emulate the `/dev/tdx-guest` `TDX_CMD_GET_REPORT0` ioctl. That
109-
returns a raw, locally-MAC'd TDREPORT, which dstack does not expose and which
110-
can't be derived from a quote — so it isn't recoverable in userspace by any
111-
shim. Binaries that drive the device by ioctl rather than configfs are out of
112-
scope.
113-
- One quote at a time per shim instance (matches the kernel's single
114-
configfs-tsm entry). Run one shim per app.
88+
- Does **not** emulate the `/dev/tdx-guest` `TDX_CMD_GET_REPORT0` ioctl: it
89+
returns a raw, locally-MAC'd TDREPORT, which dstack doesn't expose and which
90+
can't be derived from a quote, so it isn't recoverable in userspace by any
91+
shim. Binaries that drive the device by ioctl are out of scope.
92+
- **Single in-flight requester** per shim — the shared `inblob`/`outblob` pair
93+
can't correlate concurrent callers (the kernel gives each opener its own
94+
`report/<entry>/`; this doesn't). Run one shim per app; the shim rejects an
95+
`inblob` write larger than 64 bytes (a sign of racing writers) rather than
96+
return an ambiguous quote.

tsm-shim/demo-app.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
#!/usr/bin/env python3
2-
"""Demo "unmodified" attestation app — a standard configfs-tsm consumer.
3-
4-
Does exactly what a real binary built against the Linux TSM interface does:
5-
1. check that the TDX guest device exists,
6-
2. write up to 64 bytes of report_data to <dir>/inblob,
7-
3. read the raw Intel DCAP TDX quote from <dir>/outblob.
8-
9-
The only deployment-specific knob is TSM_REPORT_PATH. On a stock dstack CVM that
10-
directory is served by the tsm-shim sidecar instead of the kernel.
2+
"""Demo configfs-tsm consumer: check the device, write report_data to inblob,
3+
read the quote from outblob. TSM_REPORT_PATH points at the shim (vs the kernel's
4+
/sys/kernel/config/tsm/report). An empty outblob read means the quote failed.
115
"""
126
import hashlib
137
import os

tsm-shim/docker-compose.yaml

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
name: tsm-shim-example
22

3-
# Run an UNMODIFIED configfs-tsm attestation binary on a stock dstack CVM.
4-
#
5-
# `tsm-shim` is a prebuilt sidecar (built & published to GHCR by
6-
# .github/workflows/build-tsm-shim.yml) that re-exposes the dstack guest-agent's
7-
# GetQuote RPC under the standard configfs-tsm file ABI (inblob/outblob). Your
8-
# app talks to the kernel-style interface it already expects; the shim forwards
9-
# report_data to real TDX hardware and returns the genuine quote. No OS change,
10-
# no FUSE, no privileged container, no extra capabilities.
11-
#
12-
# The `app` service below is a self-test (it reuses the shim image only to run a
13-
# bundled demo client). In your own deployment, replace `app` with your service
14-
# and add the four lines marked (+).
3+
# Run an unmodified configfs-tsm attestation binary on a stock dstack CVM: the
4+
# tsm-shim sidecar re-exposes the guest-agent GetQuote RPC as inblob/outblob.
5+
# `app` is a self-test (reuses the shim image to run a bundled demo client); in
6+
# your deployment, replace it with your service and add the four (+) lines.
7+
# See README.md for details.
158

169
services:
1710
# ---------- the shim sidecar ----------

tsm-shim/tsm_shim.py

Lines changed: 84 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,132 @@
11
#!/usr/bin/env python3
2-
"""dstack -> configfs-tsm compatibility shim (FIFO mode, zero privileges).
2+
"""dstack -> configfs-tsm shim.
33
4-
Re-exposes the dstack guest-agent `GetQuote` RPC under the standard
5-
configfs-tsm file ABI that unmodified attestation binaries expect:
4+
Serves <dir>/inblob (write report_data, <=64 bytes) and <dir>/outblob (read the
5+
raw Intel DCAP TDX quote) by forwarding to the dstack guest-agent GetQuote RPC.
6+
inblob/outblob are FIFOs, so a read of outblob blocks until the quote is ready.
7+
report_data is forwarded byte-for-byte, so the quote is the genuine hardware
8+
quote.
69
7-
<report-dir>/inblob write <=64 bytes of report_data
8-
<report-dir>/outblob read -> raw Intel DCAP TDX quote
10+
Serves ONE request at a time and supports a SINGLE in-flight requester -- like a
11+
single configfs-tsm report entry, it cannot correlate concurrent callers. Run one
12+
shim per app. An empty outblob read means the quote failed.
913
10-
`inblob` and `outblob` are implemented as named pipes (FIFOs), so a read of
11-
`outblob` naturally blocks until the quote for the most recent `inblob` write
12-
is ready. That matches the canonical configfs-tsm usage (write inblob, then
13-
read outblob) with no race, and -- crucially -- needs NO FUSE, NO
14-
CAP_SYS_ADMIN, and NO remount of /sys. It runs as an ordinary process in a
15-
sidecar or a pre-launch wrapper.
16-
17-
The quote itself still comes from real TDX hardware via the guest-agent over
18-
its unix socket, so attestation is not weakened: report_data is forwarded
19-
byte-for-byte and the returned quote is the genuine hardware quote.
20-
21-
Usage:
22-
tsm_shim.py --report-dir /run/tsm/report --socket /var/run/dstack.sock
14+
Env: TSM_REPORT_DIR (default /run/tsm/report), DSTACK_SOCKET (default
15+
/var/run/dstack.sock).
2316
"""
24-
import argparse
17+
import errno
18+
import fcntl
2519
import http.client
2620
import json
2721
import os
2822
import socket
2923
import sys
3024
import time
31-
import traceback
25+
26+
REPORT_DIR = os.environ.get("TSM_REPORT_DIR", "/run/tsm/report")
27+
SOCKET = os.environ.get("DSTACK_SOCKET", "/var/run/dstack.sock")
28+
# How long to wait for the app to open outblob for reading before giving up, so a
29+
# caller that writes inblob then dies can't wedge the daemon.
30+
OUTBLOB_DEADLINE = float(os.environ.get("TSM_OUTBLOB_DEADLINE", "30"))
3231

3332

3433
def log(msg):
3534
sys.stderr.write(f"[tsm-shim] {msg}\n")
3635
sys.stderr.flush()
3736

3837

39-
class _UDSConnection(http.client.HTTPConnection):
40-
"""HTTPConnection that dials an AF_UNIX socket instead of TCP."""
38+
class _UDS(http.client.HTTPConnection):
39+
"""HTTPConnection over an AF_UNIX socket."""
4140

42-
def __init__(self, uds_path, timeout):
41+
def __init__(self, path, timeout):
4342
super().__init__("localhost", timeout=timeout)
44-
self._uds_path = uds_path
43+
self._path = path
4544

4645
def connect(self):
47-
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
48-
s.settimeout(self.timeout)
49-
s.connect(self._uds_path)
50-
self.sock = s
51-
46+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
47+
self.sock.settimeout(self.timeout)
48+
self.sock.connect(self._path)
5249

53-
def get_quote(sock_path, report_data, timeout=30):
54-
"""Call dstack guest-agent DstackGuest.GetQuote, return raw quote bytes.
5550

56-
Wire format mirrors the official dstack SDK: POST /GetQuote over the unix
57-
socket with JSON body {"report_data": "<hex>"}; response is JSON with a
58-
hex-encoded "quote". http.client transparently handles chunked /
59-
content-length response framing, so no extra deps are required.
60-
"""
61-
body = json.dumps({"report_data": report_data.hex()})
62-
conn = _UDSConnection(sock_path, timeout)
51+
def get_quote(report_data, timeout=30):
52+
conn = _UDS(SOCKET, timeout)
6353
try:
64-
conn.request(
65-
"POST",
66-
"/GetQuote",
67-
body=body,
68-
headers={"Host": "localhost", "Content-Type": "application/json"},
69-
)
54+
conn.request("POST", "/GetQuote",
55+
body=json.dumps({"report_data": report_data.hex()}),
56+
headers={"Host": "localhost", "Content-Type": "application/json"})
7057
resp = conn.getresponse()
7158
data = resp.read()
7259
if resp.status != 200:
73-
raise RuntimeError(
74-
f"guest-agent GetQuote returned HTTP {resp.status}: {data[:200]!r}"
75-
)
76-
obj = json.loads(data)
77-
if "quote" not in obj:
78-
raise RuntimeError(f"GetQuote response missing 'quote': {obj}")
79-
return bytes.fromhex(obj["quote"])
60+
raise RuntimeError(f"guest-agent returned http {resp.status}: {data[:200]!r}")
61+
quote = json.loads(data).get("quote")
62+
if not quote:
63+
raise RuntimeError(f"no quote in response: {data[:200]!r}")
64+
return bytes.fromhex(quote)
8065
finally:
8166
conn.close()
8267

8368

84-
def _make_fifo(path):
69+
def open_write_deadline(path, deadline=30.0):
70+
"""open a FIFO for writing, waiting up to `deadline`s for a reader.
71+
72+
Returns a blocking fd, or None if no reader showed up -- so a caller that
73+
writes inblob then dies can't wedge the daemon forever.
74+
"""
75+
end = time.monotonic() + deadline
76+
while True:
77+
try:
78+
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
79+
except OSError as exc:
80+
if exc.errno == errno.ENXIO and time.monotonic() < end:
81+
time.sleep(0.05)
82+
continue
83+
return None
84+
fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) & ~os.O_NONBLOCK)
85+
return fd
86+
87+
88+
def make_fifo(path):
8589
if os.path.lexists(path):
8690
os.remove(path)
8791
os.mkfifo(path, 0o600)
8892

8993

90-
def serve(report_dir, sock_path):
91-
os.makedirs(report_dir, exist_ok=True)
92-
inblob = os.path.join(report_dir, "inblob")
93-
outblob = os.path.join(report_dir, "outblob")
94-
_make_fifo(inblob)
95-
_make_fifo(outblob)
96-
# Best-effort: expose a `provider` attribute for apps that sanity-check it.
97-
try:
98-
with open(os.path.join(report_dir, "provider"), "w") as f:
99-
f.write("tdx_guest\n")
100-
except OSError:
101-
pass
94+
def main():
95+
os.makedirs(REPORT_DIR, exist_ok=True)
96+
inblob = os.path.join(REPORT_DIR, "inblob")
97+
outblob = os.path.join(REPORT_DIR, "outblob")
98+
make_fifo(inblob)
99+
make_fifo(outblob)
100+
log(f"ready: {REPORT_DIR} -> {SOCKET}")
102101

103-
log(f"ready: {inblob} (write report_data), {outblob} (read quote) -> {sock_path}")
104102
while True:
105103
try:
106-
# 1. Block until the app writes report_data to inblob.
107-
with open(inblob, "rb") as f:
104+
with open(inblob, "rb") as f: # blocks until the app writes
108105
report_data = f.read()
109106
if not report_data:
110-
continue # opened+closed with no payload; ignore.
111-
rd64 = report_data[:64].ljust(64, b"\0")
112-
log(f"inblob: {len(report_data)} bytes; requesting hardware quote...")
113-
try:
114-
quote = get_quote(sock_path, rd64)
115-
log(f"quote: {len(quote)} bytes, header={quote[:2].hex()}")
116-
except Exception as exc: # noqa: BLE001 - surface to logs, keep serving
117-
log(f"GetQuote failed: {exc}")
118-
quote = b"" # deliver empty so the reader doesn't hang forever.
119-
# 2. Block until the app opens outblob for reading, then deliver.
120-
with open(outblob, "wb") as f:
107+
continue
108+
if len(report_data) > 64:
109+
# >64 bytes means more than one writer raced on inblob -- fail
110+
# closed rather than hand back a quote bound to ambiguous data.
111+
log(f"rejecting inblob: {len(report_data)} bytes (>64); concurrent writers?")
112+
quote = b""
113+
else:
114+
try:
115+
quote = get_quote(report_data.ljust(64, b"\0"))
116+
log(f"quote {len(quote)} bytes, header={quote[:2].hex()}")
117+
except Exception as exc:
118+
log(f"getquote failed: {exc}") # deliver empty == failure signal
119+
quote = b""
120+
fd = open_write_deadline(outblob, OUTBLOB_DEADLINE)
121+
if fd is None:
122+
log("no reader for outblob within deadline; dropping")
123+
continue
124+
with os.fdopen(fd, "wb") as f:
121125
f.write(quote)
122-
except Exception: # noqa: BLE001 - never let the daemon die.
123-
log("serve loop error:\n" + traceback.format_exc())
126+
except Exception as exc:
127+
log(f"serve loop error: {exc}")
124128
time.sleep(0.5)
125129

126130

127-
def main():
128-
ap = argparse.ArgumentParser(description=__doc__)
129-
ap.add_argument(
130-
"--report-dir",
131-
default=os.environ.get("TSM_REPORT_DIR", "/run/tsm/report"),
132-
help="directory in which to expose inblob/outblob FIFOs",
133-
)
134-
ap.add_argument(
135-
"--socket",
136-
default=os.environ.get("DSTACK_SOCKET", "/var/run/dstack.sock"),
137-
help="path to the dstack guest-agent unix socket",
138-
)
139-
args = ap.parse_args()
140-
serve(args.report_dir, args.socket)
141-
142-
143131
if __name__ == "__main__":
144132
main()

0 commit comments

Comments
 (0)