Skip to content

Commit 147de69

Browse files
authored
Add tsm-shim example: run configfs-tsm binaries via a sidecar
Sidecar that serves inblob/outblob by forwarding to the guest-agent GetQuote, so unmodified configfs-tsm attestation binaries run on a stock dstack CVM. Image published to GHCR by CI.
1 parent 9744eab commit 147de69

7 files changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: build tsm-shim
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'tsm-shim/**'
8+
- '.github/workflows/build-tsm-shim.yml'
9+
pull_request:
10+
paths:
11+
- 'tsm-shim/**'
12+
- '.github/workflows/build-tsm-shim.yml'
13+
workflow_dispatch:
14+
15+
permissions:
16+
contents: read
17+
packages: write
18+
19+
jobs:
20+
build:
21+
runs-on: ubuntu-latest
22+
env:
23+
IMAGE: ghcr.io/dstack-tee/dstack-tsm-shim
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- uses: docker/setup-buildx-action@v3
28+
29+
# On PRs (incl. forks) GITHUB_TOKEN lacks packages:write, so only build.
30+
- name: Log in to GHCR
31+
if: github.event_name != 'pull_request'
32+
uses: docker/login-action@v3
33+
with:
34+
registry: ghcr.io
35+
username: ${{ github.actor }}
36+
password: ${{ secrets.GITHUB_TOKEN }}
37+
38+
- name: Docker metadata
39+
id: meta
40+
uses: docker/metadata-action@v5
41+
with:
42+
images: ${{ env.IMAGE }}
43+
tags: |
44+
type=raw,value=latest,enable={{is_default_branch}}
45+
type=sha,format=short
46+
47+
- name: Build and push
48+
uses: docker/build-push-action@v6
49+
with:
50+
context: tsm-shim
51+
file: tsm-shim/Dockerfile
52+
platforms: linux/amd64
53+
push: ${{ github.event_name != 'pull_request' }}
54+
tags: ${{ steps.meta.outputs.tags }}
55+
labels: ${{ steps.meta.outputs.labels }}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ volumes:
128128
|---------|-------------|--------|
129129
| [timelock-nts](./timelock-nts) | Raw socket usage (what the SDK wraps) | Available |
130130
| [attestation/configid-based](./attestation/configid-based) | ConfigID-based verification | Available |
131+
| [tsm-shim](./tsm-shim) | Run unmodified `configfs-tsm` binaries (inblob/outblob) via a sidecar | Available |
131132

132133
### Gateway & Domains
133134

tsm-shim/Dockerfile

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Pure stdlib — no pip, no build deps. Built & pushed to GHCR by
2+
# .github/workflows/build-tsm-shim.yml.
3+
FROM python:3.12-slim@sha256:6c4dd321d176d61ea848dc8c73a4f7dbae8f70e0ee48bb411ea2f045b599fa8e
4+
5+
LABEL org.opencontainers.image.title="dstack-tsm-shim"
6+
LABEL org.opencontainers.image.description="configfs-tsm compatibility shim: re-exposes dstack guest-agent GetQuote as inblob/outblob FIFOs"
7+
LABEL org.opencontainers.image.source="https://github.com/Dstack-TEE/dstack-examples"
8+
9+
COPY tsm_shim.py /usr/local/bin/tsm_shim.py
10+
COPY demo-app.py /usr/local/bin/demo-app.py
11+
12+
ENV TSM_REPORT_DIR=/run/tsm/report \
13+
DSTACK_SOCKET=/var/run/dstack.sock
14+
15+
# Report healthy only once both FIFOs exist, so an app can gate on
16+
# `depends_on: { tsm-shim: { condition: service_healthy } }`.
17+
HEALTHCHECK --interval=2s --timeout=2s --retries=30 --start-period=1s \
18+
CMD test -p "$TSM_REPORT_DIR/inblob" && test -p "$TSM_REPORT_DIR/outblob" || exit 1
19+
20+
# Default role is the shim daemon (reads TSM_REPORT_DIR / DSTACK_SOCKET from env).
21+
ENTRYPOINT ["python3", "/usr/local/bin/tsm_shim.py"]

tsm-shim/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# configfs-tsm shim
2+
3+
Some attestation binaries get their TDX quote through the kernel's `configfs-tsm`
4+
files (`/sys/kernel/config/tsm/report/*` — write `inblob`, read `outblob`) instead
5+
of the dstack SDK. dstack doesn't expose those files to containers, so they fail.
6+
7+
This sidecar bridges them: it serves `inblob`/`outblob` from a shared volume and
8+
forwards each request to the guest-agent's `GetQuote`. The quote is the real
9+
hardware quote (`report_data` passed through unchanged), so an unmodified binary
10+
works with only docker-compose changes — no OS change, no FUSE, no privileged
11+
container. CI publishes the image to `ghcr.io/dstack-tee/dstack-tsm-shim`.
12+
13+
## Use it
14+
15+
Add the sidecar, then point your app at it with the `(+)` lines:
16+
17+
```yaml
18+
services:
19+
tsm-shim:
20+
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest
21+
restart: unless-stopped
22+
volumes:
23+
- /var/run/dstack.sock:/var/run/dstack.sock
24+
- tsm-report:/run/tsm/report
25+
26+
my-app:
27+
image: your-app:latest
28+
depends_on:
29+
tsm-shim:
30+
condition: service_healthy # (+) wait until the shim is ready
31+
environment:
32+
- TSM_REPORT_PATH=/run/tsm/report # (+) point your app at the shim dir
33+
volumes:
34+
- tsm-report:/run/tsm/report # (+) see the shim's inblob/outblob
35+
- tsm-devstub:/dev/tdx-guest # (+) satisfy /dev/tdx-guest checks
36+
37+
volumes:
38+
tsm-report: {}
39+
tsm-devstub: {}
40+
```
41+
42+
If your binary hard-codes `/sys/kernel/config/tsm/report`, mount the volume there
43+
instead of setting `TSM_REPORT_PATH`. For production, pin the image by digest.
44+
45+
## Try the demo
46+
47+
```bash
48+
phala deploy -n tsm-shim-example -c docker-compose.yaml
49+
phala cvms logs <app_id> -c app # expect PASS and a ~5 KB quote
50+
```
51+
52+
## Good to know
53+
54+
- Covers the configfs-tsm `inblob`/`outblob` path (go-configfs-tsm, recent
55+
libtdx-attest). It does **not** handle the `/dev/tdx-guest` ioctl, which needs a
56+
raw TDREPORT that dstack doesn't expose.
57+
- One request at a time, one shim per app — a shared `inblob`/`outblob` can't tell
58+
concurrent callers apart. An empty `outblob` read means the quote failed.

tsm-shim/demo-app.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env python3
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.
5+
"""
6+
import hashlib
7+
import os
8+
import sys
9+
import time
10+
11+
12+
def detect_tdx() -> bool:
13+
return os.path.exists("/dev/tdx-guest") or os.path.exists("/dev/tdx_guest")
14+
15+
16+
def main() -> None:
17+
report_dir = os.environ.get("TSM_REPORT_PATH", "/sys/kernel/config/tsm/report/dstack")
18+
19+
if not detect_tdx():
20+
print("FAIL: no TDX guest device (/dev/tdx-guest)")
21+
sys.exit(1)
22+
23+
# `depends_on: condition: service_healthy` already gates startup on the shim
24+
# FIFOs existing; this short retry just mirrors what real attestation libs do.
25+
for _ in range(100):
26+
if os.path.exists(f"{report_dir}/inblob"):
27+
break
28+
time.sleep(0.1)
29+
30+
report_data = hashlib.sha256(b"dstack-tsm-shim-demo").digest() # 32 bytes
31+
with open(f"{report_dir}/inblob", "wb") as f:
32+
f.write(report_data[:64].ljust(64, b"\0"))
33+
with open(f"{report_dir}/outblob", "rb") as f:
34+
quote = f.read()
35+
36+
print(f"report_dir : {report_dir}")
37+
print(f"report_data : {report_data.hex()}")
38+
print(f"quote length : {len(quote)} bytes")
39+
print(f"quote header : {quote[:2].hex()} (a TDX v4 quote starts with 0400)")
40+
bound = report_data[:32] in quote
41+
print(f"report_data bound in quote: {bound}")
42+
print(
43+
"PASS - unmodified configfs-tsm app got a real TDX quote via the shim"
44+
if (quote[:2].hex() == "0400" and bound)
45+
else "FAIL - unexpected quote (header or report_data binding off)"
46+
)
47+
48+
sys.stdout.flush()
49+
while True:
50+
time.sleep(3600)
51+
52+
53+
if __name__ == "__main__":
54+
main()

tsm-shim/docker-compose.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: tsm-shim-example
2+
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.
8+
9+
services:
10+
# ---------- the shim sidecar ----------
11+
tsm-shim:
12+
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest
13+
restart: unless-stopped
14+
volumes:
15+
- /var/run/dstack.sock:/var/run/dstack.sock # source of real quotes
16+
- tsm-report:/run/tsm/report # FIFOs shared with the app
17+
18+
# ---------- your app (here: a bundled demo client) ----------
19+
app:
20+
image: ghcr.io/dstack-tee/dstack-tsm-shim:latest # <- replace with your image
21+
entrypoint: ["python3", "/usr/local/bin/demo-app.py"] # <- your app's entrypoint
22+
depends_on:
23+
tsm-shim:
24+
condition: service_healthy # (+) wait until FIFOs exist
25+
environment:
26+
- TSM_REPORT_PATH=/run/tsm/report # (+) point the app at the shim
27+
volumes:
28+
- tsm-report:/run/tsm/report # (+) see the shim's FIFOs
29+
- tsm-devstub:/dev/tdx-guest # (+) make /dev/tdx-guest "exist"
30+
31+
volumes:
32+
tsm-report: {}
33+
tsm-devstub: {}

tsm-shim/tsm_shim.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
"""dstack -> configfs-tsm shim.
3+
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.
9+
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.
13+
14+
Env: TSM_REPORT_DIR (default /run/tsm/report), DSTACK_SOCKET (default
15+
/var/run/dstack.sock).
16+
"""
17+
import errno
18+
import fcntl
19+
import http.client
20+
import json
21+
import os
22+
import socket
23+
import sys
24+
import time
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"))
31+
32+
33+
def log(msg):
34+
sys.stderr.write(f"[tsm-shim] {msg}\n")
35+
sys.stderr.flush()
36+
37+
38+
class _UDS(http.client.HTTPConnection):
39+
"""HTTPConnection over an AF_UNIX socket."""
40+
41+
def __init__(self, path, timeout):
42+
super().__init__("localhost", timeout=timeout)
43+
self._path = path
44+
45+
def connect(self):
46+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
47+
self.sock.settimeout(self.timeout)
48+
self.sock.connect(self._path)
49+
50+
51+
def get_quote(report_data, timeout=30):
52+
conn = _UDS(SOCKET, timeout)
53+
try:
54+
conn.request("POST", "/GetQuote",
55+
body=json.dumps({"report_data": report_data.hex()}),
56+
headers={"Host": "localhost", "Content-Type": "application/json"})
57+
resp = conn.getresponse()
58+
data = resp.read()
59+
if resp.status != 200:
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)
65+
finally:
66+
conn.close()
67+
68+
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):
89+
if os.path.lexists(path):
90+
os.remove(path)
91+
os.mkfifo(path, 0o600)
92+
93+
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}")
101+
102+
while True:
103+
try:
104+
with open(inblob, "rb") as f: # blocks until the app writes
105+
report_data = f.read()
106+
if not report_data:
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:
125+
f.write(quote)
126+
except Exception as exc:
127+
log(f"serve loop error: {exc}")
128+
time.sleep(0.5)
129+
130+
131+
if __name__ == "__main__":
132+
main()

0 commit comments

Comments
 (0)