Compare commits
10
Commits
4baf0208e1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c198c36ea4 | ||
|
|
0315d5816e | ||
|
|
9b921eeae5 | ||
|
|
ab9bb3eae9 | ||
|
|
e61c004431 | ||
|
|
43ed5f9183 | ||
|
|
dbce497f95 | ||
|
|
ab6fc403e4 | ||
|
|
51573544a4 | ||
|
|
705f4a52a6 |
@@ -13,7 +13,7 @@ The goal is that `docker compose up` gives you a working setup where Claude Desk
|
||||
|
||||
## Status
|
||||
|
||||
Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop and Ghidra, started by `run.sh`; no compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs.
|
||||
Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop, Ghidra (plus GhidraMCP and an ESP32 loader), binwalk, unblob, esptool and REA, started by `run.sh` (or the app-menu launcher `install.sh` creates: `~/.local/share/applications/claude-re-setup.desktop`, icons taken from the image, `Exec` pointing at this checkout's `run.sh`; `run.sh` shows errors in a zenity dialog when it has no terminal) as two containers (Claude Desktop + Ghidra, and the REA MCP server); `README.md` covers usage; Claude Desktop's MCP config is `claude-desktop/claude_desktop_config.json`; Ghidra config files are in `ghidra/`. No compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs.
|
||||
|
||||
## Intended layout
|
||||
|
||||
@@ -37,26 +37,31 @@ Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with C
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Claude Desktop and Ghidra share one container** (the root `Dockerfile`, started by `run.sh`), at the user's request. Ghidra is the official GitHub release (version, build date and SHA-256 are build args), runs on Ubuntu's `openjdk-25-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. Further services such as the RE MCP server may still get their own containers.
|
||||
- **Claude Desktop and Ghidra share one container** (the root `Dockerfile`, started by `run.sh`), at the user's request. Ghidra is the official GitHub release (version, build date and SHA-256 are global build args, downloaded once in the `ghidra-dist` stage and copied into the final image and into build stages that compile extensions against it), is pinned to **12.1.4** because that's what the GhidraMCP release is built for (Ghidra only loads extensions built for its exact version), runs on Ubuntu's `openjdk-21-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. The **Developer** plugin package is enabled in both the project window and the CodeBrowser. A bare `<PACKAGE NAME="Developer" />` only adds the package's RELEASED plugins, so the STABLE ones are listed with `<INCLUDE CLASS=...>`. CodeBrowser: the build inserts `ghidra/codebrowser-developer.xml` into `defaultTools/CodeBrowser.tool` inside `Public_Release.jar`. Project window: it has no default to patch, so `ghidra/FrontEndTool.xml` is copied into the user settings, along with `ghidra/preferences` (`USER_AGREEMENT=ACCEPT`, because a pre-created settings dir otherwise makes Ghidra ask for its user agreement). To check a config really loaded, close Ghidra cleanly and see whether the plugins survive in the file it rewrites; Ghidra drops what it didn't load.
|
||||
- **Model provider window.** `run.sh` asks on every start (zenity form; terminal prompts without zenity) for a base URL, token, auth header and optional model list. Empty URL: normal claude.ai sign-in. Otherwise it writes Claude Desktop's third-party managed config (`inferenceProvider: "gateway"`, `inferenceGatewayBaseUrl`, `inferenceGatewayApiKey`, `inferenceGatewayAuthScheme`, `inferenceModels`, `disableDeploymentModeChooser`; docs: https://claude.com/docs/third-party/claude-desktop) and Chat, Code and Cowork use that Anthropic-Messages-compatible endpoint, with no claude.ai account. Verified against Claude Desktop 4.4212.0:
|
||||
- On Linux the file is `/etc/claude-desktop/managed-settings.json`, and Claude Desktop **ignores it unless root owns it and it isn't group/world-writable**. A bind mount from the host shows the user's UID, so `run.sh` creates the container, copies the file in with `docker cp` from a tar stream (owner 0, mode 0644), then starts it; the host copy (runtime dir, 0700) is deleted right away and on failure. The image provides `/etc/claude-desktop`.
|
||||
- Claude Desktop rejects `http://` base URLs except loopback. For `http://host[:port]` the container gets `CLAUDE_RE_GATEWAY_FORWARD=host:port`, `launch-apps.sh` runs `socat` on `127.0.0.1:18080` to it, and the config points at `http://127.0.0.1:18080` (the token then travels unencrypted, as with any plain-HTTP server). `https://` URLs are used as given.
|
||||
- `localhost`/`127.0.0.1` in the URL is rewritten to `host.docker.internal` (`--add-host host.docker.internal:host-gateway`); a trailing `/` or `/v1` is stripped because Claude Desktop appends `/v1/messages`. An empty token becomes `none` (the key is required in gateway mode).
|
||||
- Check what Claude Desktop made of it in `~/.config/Claude/logs/main.log` (`[managedConfig]`, `[custom-3p]`).
|
||||
- **Shared workspace volume.** Every container mounts the workspace at `/workspace`, so a file path means the same thing to Claude, Ghidra and the RE MCP. `run.sh` asks for the host folder on each start in the desktop's folder picker (`zenity`, or `kdialog` on KDE; terminal prompt if neither exists), opening at the last folder used, else `./workspace`. Skip it with `--workspace DIR` or `WORKSPACE=DIR`. It refuses `/` and `$HOME`. The image symlinks `~/workspace` to `/workspace` for Claude Desktop's file picker.
|
||||
- **GUI forwarding.** Claude Desktop and Ghidra are GUI apps. Default to X11 forwarding (`/tmp/.X11-unix` + `DISPLAY`). Support Wayland through XWayland. Keep a noVNC/VNC fallback in mind for headless hosts. Never solve display access with a blanket `xhost +`. Scope it (`xhost +local:docker` or `+SI:localuser:...`).
|
||||
- **GhidraMCP wiring.** GhidraMCP has two parts:
|
||||
- a Ghidra plugin that runs an HTTP server inside Ghidra (default port `8080`), and
|
||||
- a Python MCP bridge (`bridge_mcp_ghidra.py`) that speaks MCP to Claude and forwards calls to that HTTP server.
|
||||
- **GhidraMCP wiring.** LaurieWired's GhidraMCP (https://github.com/LaurieWired/GhidraMCP), release 2.0, installed from the official release zip (SHA-256 checked). It has two parts:
|
||||
- a Ghidra extension, unpacked into `/opt/ghidra/Ghidra/Extensions/GhidraMCP`. Its `GhidraMCPPlugin` is a RELEASED plugin in the Developer package, so the CodeBrowser config loads it. While a CodeBrowser is open it serves an HTTP backend on `127.0.0.1:8080` (port: *Edit → Tool Options → GhidraMCP HTTP Server*).
|
||||
- the `ghidra-mcp` Python MCP server (2.0 replaced `bridge_mcp_ghidra.py`), installed from the release wheel into the venv `/opt/ghidra-mcp`. Its dependencies are pinned to GhidraMCP's `uv.lock` at the release commit via `scripts/uvlock-to-constraints.py`. CLI: `ghidra-mcp --ghidra-server http://127.0.0.1:8080/` (stdio by default; also `--transport streamable-http`, `--headless`).
|
||||
|
||||
The bridge is configured with the Ghidra server URL (e.g. `http://ghidra:8080/`). The plugin must be enabled in the CodeBrowser and a program must be open, or the bridge has nothing to talk to.
|
||||
- **Container lifetime follows the apps.** The entrypoint `launch-apps.sh` starts Claude Desktop and Ghidra and stops both, ending the container, when either is closed. Ghidra counts as closed when its process exits (closing the project window quits it; CodeBrowser or Help windows don't). Claude Desktop's window only hides the app on close (GNOME shows no tray icon), so its X windows are watched: none open or minimized for ~3 s counts as closed. `run.sh` uses `--init` so signals and zombie processes are handled.
|
||||
- **MCP transport between containers.** Claude Desktop starts local MCP servers as stdio subprocesses. Because the servers live in other containers, pick one approach and apply it consistently:
|
||||
- *Preferred:* run the MCP servers with an HTTP/SSE transport in their own containers. Bridge them into Claude Desktop's stdio config with a small proxy (e.g. `mcp-remote` or `mcp-proxy`) installed in the Claude Desktop image.
|
||||
- *Alternative:* install the bridges inside the Claude Desktop image and point them at the other containers over the network. This works well for the GhidraMCP bridge, which is just Python + HTTP.
|
||||
- Avoid mounting the Docker socket into the Claude Desktop container just to `docker exec` MCP servers.
|
||||
A program must be open in the CodeBrowser for the tools to return data. Claude Desktop only starts MCP servers after sign-in.
|
||||
- **ESP32 Flash Loader** (https://github.com/dynacylabs/ghidra-esp32-flash-loader): a Ghidra loader ("ESP32 Flash Image" in *File → Import File*) for ESP32 app images and flash dumps. It reads the partition table at `0x8000`, maps segments to their addresses, adds peripherals from Espressif's SVD files and loads the chip's ROM ELF for symbols. A flash dump is only recognized when it **starts with the bootloader** (ESP32-S/C layouts). Original-ESP32 dumps (bootloader at `0x1000`) are rejected, and trimming the first `0x1000` bytes doesn't help: the loader then finds no partition table at `0x8000`, loads only the bootloader and throws a NullPointerException while still reporting success. Workaround: cut the app partition out of the dump (offsets from the partition table) and import it as an app image. Its tags ship no binaries and the older forks (tslater2006, Ebiroll, saibotk v1.1.0) don't compile against Ghidra 12, so the `esp32-loader-build` stage builds dynacylabs' master at a pinned commit, with its `data/svd` and `ghidra_scripts` submodules fetched over HTTPS at pinned commits and Gradle checked against gradle.org's SHA-256. It is unpacked into `/opt/ghidra/Ghidra/Extensions/esp32`; loaders need no tool config.
|
||||
- **REA ("Reverse Engineer Anything")**: https://github.com/morluto/rea (MIT, TypeScript), the `rea-agents` npm package, pinned to **6.8.0** with the tarball checked against the registry's integrity hash; its dependencies are locked by the `npm-shrinkwrap.json` it ships. It needs Node.js ≥24.22.1 (official tarball, SHA-256 checked, in `/opt/node`) and procps' `ps`. It uses Ghidra 12.1.x headless through `GHIDRA_INSTALL_DIR` (its own temporary projects, separate from the GUI's) binwalk 3.1.x through `REA_BINWALK_COMMAND` (`inspect_firmware_regions`) and unblob 26.6.x through `REA_UNBLOB_COMMAND` (`extract_firmware`); Hopper, IDA, Kuna, JADX, tshark, pwntools and a browser are not installed, and frida (optional, runtime instrumentation) stays unbuilt because npm blocks its install script. Check with `rea doctor --provider ghidra --json`. REA's bundled skill (`reverse-engineer-anything`) is copied from the package into `~/.claude/skills` in the image, for Claude Code sessions in Claude Desktop's Code tab (they load user skills from there). Claude Desktop's chat doesn't read that folder: its skills come from the account (Settings → Capabilities → Skills) and are cached by the app.
|
||||
Some REA tools run the target (process capture, runtime/Electron observation), so it does **not** run in the Claude Desktop container. `run.sh` starts a second container from the same image (`claude-re-mcp`): `rea mcp --http --host 0.0.0.0 --port 8765`, only the workspace mounted, no display, `--cap-drop ALL`, `no-new-privileges`, and only on the `--internal` network `claude-re-setup-internal` (no route out) under the alias `rea`. The Claude Desktop container is created on the default network plus that internal one, so it can sign in and reach `http://rea:8765/mcp`. REA's HTTP endpoint has no authentication and serves one client session at a time; the internal network is what limits who can reach it. A detached `docker wait` started by `run.sh` stops REA when Claude Desktop's container ends.
|
||||
- **binwalk, unblob and esptool** are CLI tools in the same image (no MCP server of their own; REA wraps binwalk and unblob). The `Dockerfile` has a `binwalk-build` stage adapted from binwalk's own Dockerfile (https://github.com/ReFirmLabs/binwalk) to Ubuntu 24.04, with every source pinned: binwalk at a master commit (3.1.1; the last tag, v3.1.0, is from 2024), Rust toolchain, `dumpifs`/`lzfse`/`dmg2img` commits, and the Python extractors (`jefferson`, `ubi-reader`, `uefi_firmware`, `vmlinux-to-elf`, ...) in `/opt/binwalk-py`. esptool is in `/opt/esptool`. unblob (https://github.com/onekey-sec/unblob) 26.6.4 is installed from its PyPI wheel (SHA-256 checked) into `/opt/unblob`, with dependencies pinned to its `uv.lock` at the release commit via `scripts/uvlock-to-constraints.py`; `/usr/local/bin/unblob` is a wrapper that puts that venv first on `PATH`, so unblob uses its own locked `jefferson`/`ubireader` rather than binwalk's. Its extractors follow upstream's `install-deps.sh`: Ubuntu packages, sasquatch v4.5.1-6 (shared with binwalk) and onekey-sec's e2fsprogs fork (v1.47.0-3.ok2, replacing Ubuntu's 1.47.0), whose `debugfs rdump` options unblob's ext2/3/4 handler needs. The build fails unless `unblob --show-external-dependencies` finds every extractor. unblob's Landlock sandbox works in the locked-down REA container (`status=FullyEnforced`). The venvs' entry points are symlinked into `/usr/local/bin` (not their `python`/`pip`). The final stage adds upstream's runtime extractors from apt, sasquatch from onekey-sec's `.deb`, Ubuntu's `squashfs-tools` (plain `unsquashfs`/`mksquashfs`, the commands Claude tries before `sasquatch`). `unsquashfs`, `sasquatch` and `sasquatch-v4be` in `/usr/local/bin` are wrappers (`scripts/squashfs-no-xattrs.sh`) that add `-no-xattrs` unless an xattr option is given: as non-root they can't restore `security.*` xattrs and exit 2 although every file was extracted, which looked like a failed extraction, and Kaleido for `binwalk -E -p` (PNG entropy graphs), found via `KALEIDO_PATH`. Upstream publishes no checksums for sasquatch, onekey's e2fsprogs or Kaleido; their SHA-256s were recorded when pinned, amd64 only. `BINWALK_RM_EXTRACTION_SYMLINK=1` avoids dangling symlinks to container paths in extraction output. esptool needs the serial device passed into the container (`--device`) to talk to hardware; that isn't set up.
|
||||
- **MCP transport.** Claude Desktop starts local MCP servers as stdio subprocesses. GhidraMCP runs in the same container, so `ghidra-mcp` is a plain stdio entry in `claude_desktop_config.json` that reaches Ghidra on `127.0.0.1`. Servers in other containers (so far REA) serve streamable HTTP and are bridged into the stdio config with `mcp-remote` (`--allow-http --transport http-only`), installed in `/opt/mcp-bridge` from the committed `claude-desktop/mcp-bridge/package-lock.json` and started with `/opt/node/bin/node` by absolute path. Use the same pattern for future servers. Avoid mounting the Docker socket into the Claude Desktop container just to `docker exec` MCP servers.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Pin versions.** Ghidra version, GhidraMCP release, JDK version, Claude Desktop build and RE MCP commit go in `.env` / build args, not `latest`. The GhidraMCP release must match the Ghidra version it was built for.
|
||||
- **Run as non-root** inside containers. Map the host UID/GID (via `.env`) so files in `workspace/` stay owned by the user.
|
||||
- **Persist state in named volumes or `workspace/`**: Ghidra user settings and projects. Rebuilding an image must not lose analysis.
|
||||
- **Claude Desktop sign-in is not persisted.** No keyring is installed and `~/.config/Claude` is not mounted, so the user logs in on every start. Don't add a keyring or config volume back without being asked.
|
||||
- **Claude Desktop sign-in is not persisted.** No keyring is installed and `~/.config/Claude` is not mounted, so the user logs in on every start. Don't add a keyring or config volume back without being asked. The same goes for the model-provider token (below): it's asked for on every start and never stored on the host.
|
||||
- **No secrets in the repo.** Credentials, session data and `workspace/` contents are git-ignored. Ship `.env.example`, never `.env`.
|
||||
- **Untrusted binaries.** Samples under analysis may be malicious. Never execute them on the host or in the Claude Desktop container. Containers that might run samples (dynamic analysis) should have no host mounts beyond `workspace/` and should allow network isolation (`network_mode: none` or an internal-only network).
|
||||
- **Linux only.** Target recent Docker Engine + Compose v2 (`docker compose`, not `docker-compose`). Make the setup work under both Docker and rootless Podman where practical. On SELinux hosts such as Fedora, bind mounts need `:z`/`:Z`.
|
||||
@@ -77,8 +82,8 @@ docker compose down
|
||||
## Things to verify, not assume
|
||||
|
||||
- **Claude Desktop on Linux.** Check whether an official Linux build is available at the time of work. If not, use a maintained community repackaging and document which one and why. Confirm the config path, usually `~/.config/Claude/claude_desktop_config.json`.
|
||||
- **Reverse Engineer Anything MCP.** Confirm the upstream repository, install method, runtime (Python/Node), required external tools and supported transports before writing its Dockerfile. Record the source URL in the README.
|
||||
- **GhidraMCP.** Confirm the current release, its supported Ghidra version, the bridge's CLI flags and its default ports from the upstream README.
|
||||
- **REA.** Get it only from morluto/rea, rea.tools or the `rea-agents` npm package (copycat repos exist). Before bumping it, check its accepted Ghidra/binwalk/Node versions (`docs/installation.md`, `docs/firmware-analysis.md` at the release tag) and update `REA_INTEGRITY` from `https://registry.npmjs.org/rea-agents/<version>` (`dist.integrity`).
|
||||
- **GhidraMCP.** Before bumping GhidraMCP or Ghidra, confirm the release's supported Ghidra version (`version=` in the extension's `extension.properties`; the build checks it), the `ghidra-mcp` CLI flags and its default ports from the upstream README. The Developer plugin lists in `ghidra/` were checked against 12.1.4 and 12.2; re-check them for other versions.
|
||||
|
||||
## When making changes
|
||||
|
||||
|
||||
+381
-16
@@ -1,3 +1,150 @@
|
||||
# Ghidra from the official GitHub release, checked against the SHA-256 in the
|
||||
# release notes: https://github.com/NationalSecurityAgency/ghidra/releases
|
||||
# 12.1.4 because that is the version the GhidraMCP release below is built
|
||||
# for; Ghidra only loads extensions built for its exact version.
|
||||
ARG GHIDRA_VERSION=12.1.4
|
||||
ARG GHIDRA_BUILD_DATE=20260921
|
||||
ARG GHIDRA_SHA256=ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db
|
||||
|
||||
|
||||
# --- Stage: Ghidra distribution ----------------------------------------------
|
||||
# Downloaded once, used by the final image and to build extensions against.
|
||||
FROM ubuntu:24.04 AS ghidra-dist
|
||||
ARG GHIDRA_VERSION
|
||||
ARG GHIDRA_BUILD_DATE
|
||||
ARG GHIDRA_SHA256
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSLo /tmp/ghidra.zip \
|
||||
"https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${GHIDRA_VERSION}_build/ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_BUILD_DATE}.zip" \
|
||||
&& echo "${GHIDRA_SHA256} /tmp/ghidra.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/ghidra.zip -d /opt \
|
||||
&& rm /tmp/ghidra.zip
|
||||
|
||||
|
||||
# --- Build stage: ESP32 Flash Loader for Ghidra --------------------------------
|
||||
# https://github.com/dynacylabs/ghidra-esp32-flash-loader: loads ESP32 flash
|
||||
# dumps / app images (partition table, segments at their addresses, chip
|
||||
# ROM, peripherals from Espressif's SVD files). Its tags ship no binaries,
|
||||
# so it's built against this exact Ghidra. Pinned to a master commit (Ghidra
|
||||
# 12 API, newer than the v12.0 tag) and its submodules' commits. Gradle is
|
||||
# downloaded directly, checked against the SHA-256 gradle.org publishes,
|
||||
# because Ghidra's own wrapper doesn't verify its download.
|
||||
FROM ubuntu:24.04 AS esp32-loader-build
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG GHIDRA_VERSION
|
||||
ARG ESP32_LOADER_COMMIT=59d88034d07010fb3a3601053554baf02e7be2e4
|
||||
ARG ESP32_SVD_COMMIT=be20aa12560889d6125d144cdb48cf615ac17628
|
||||
ARG GHIDRA_SVD_LOADER_COMMIT=8fb5601c3444111159cf1e056cbe9d8457985237
|
||||
ARG GRADLE_VERSION=9.7.1
|
||||
ARG GRADLE_SHA256=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git unzip openjdk-21-jdk-headless \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghidra-dist /opt/ghidra_${GHIDRA_VERSION}_PUBLIC /opt/ghidra
|
||||
RUN set -e \
|
||||
&& fetch() { git init -q "$2"; git -C "$2" fetch -q --depth 1 "$1" "$3"; git -C "$2" checkout -q FETCH_HEAD; } \
|
||||
&& fetch https://github.com/dynacylabs/ghidra-esp32-flash-loader.git /src/esp32 "${ESP32_LOADER_COMMIT}" \
|
||||
&& rmdir /src/esp32/data/svd /src/esp32/ghidra_scripts \
|
||||
&& fetch https://github.com/espressif/svd.git /src/esp32/data/svd "${ESP32_SVD_COMMIT}" \
|
||||
&& fetch https://github.com/dynacylabs/ghidra-svd-loader.git /src/esp32/ghidra_scripts "${GHIDRA_SVD_LOADER_COMMIT}" \
|
||||
&& curl -fsSLo /tmp/gradle.zip "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||
&& echo "${GRADLE_SHA256} /tmp/gradle.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/gradle.zip -d /opt \
|
||||
&& cd /src/esp32 \
|
||||
&& GHIDRA_INSTALL_DIR=/opt/ghidra "/opt/gradle-${GRADLE_VERSION}/bin/gradle" --no-daemon -q buildExtension \
|
||||
&& unzip -q dist/ghidra_${GHIDRA_VERSION}_PUBLIC_*_esp32.zip -d /out \
|
||||
&& grep -qx "version=${GHIDRA_VERSION}" /out/esp32/extension.properties
|
||||
|
||||
|
||||
# --- Build stage: binwalk and its helpers -------------------------------------
|
||||
# Based on binwalk's own Dockerfile (https://github.com/ReFirmLabs/binwalk,
|
||||
# Dockerfile at the pinned commit), adapted to Ubuntu 24.04 and with every
|
||||
# source pinned. Only the results are copied into the final image, so
|
||||
# compilers and the Rust toolchain stay out of it.
|
||||
FROM ubuntu:24.04 AS binwalk-build
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
# binwalk: last tag (v3.1.0) is from 2024; master is 3.1.1 with later fixes.
|
||||
ARG BINWALK_COMMIT=26713972e3f9f52dc37d4a421c4554b0bd9e82ef
|
||||
ARG RUST_VERSION=1.99.1
|
||||
ARG DUMPIFS_COMMIT=b7bac90e8312eca2796f2003a52791899eb8dcd9
|
||||
ARG LZFSE_COMMIT=e634ca58b4821d9f3d560cdc6df5dec02ffc93fd
|
||||
ARG DMG2IMG_COMMIT=a3e413489ccdd05431401357bf21690536425012
|
||||
ARG VMLINUX_TO_ELF_COMMIT=36da74ded9cd486f294c8852e0dcab9ef6a48c6b
|
||||
ARG ESPTOOL_VERSION=5.6.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git build-essential clang pkg-config \
|
||||
zlib1g-dev liblzma-dev liblzo2-dev libucl-dev liblz4-dev libbz2-dev \
|
||||
libssl-dev libsrecord-dev libfontconfig1-dev \
|
||||
python3 python3-venv python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Shallow-fetch one exact commit of a git repository.
|
||||
RUN printf '%s\n' '#!/bin/sh' 'set -e' \
|
||||
'git init -q "$2"' \
|
||||
'git -C "$2" fetch -q --depth 1 "$1" "$3"' \
|
||||
'git -C "$2" checkout -q FETCH_HEAD' > /usr/local/bin/fetch-commit \
|
||||
&& chmod +x /usr/local/bin/fetch-commit
|
||||
|
||||
# binwalk itself, with a pinned Rust toolchain and the locked crate versions.
|
||||
RUN fetch-commit https://github.com/ReFirmLabs/binwalk.git /src/binwalk "${BINWALK_COMMIT}" \
|
||||
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_VERSION}" \
|
||||
&& . /root/.cargo/env \
|
||||
&& cargo build --release --locked --manifest-path /src/binwalk/Cargo.toml \
|
||||
&& install -D -m 755 /src/binwalk/target/release/binwalk /out/bin/binwalk
|
||||
|
||||
# Extractors binwalk calls that Ubuntu doesn't package (upstream src.sh).
|
||||
# dumpifs ships a prebuilt binary in its repo; it's removed and rebuilt.
|
||||
RUN fetch-commit https://github.com/askac/dumpifs.git /src/dumpifs "${DUMPIFS_COMMIT}" \
|
||||
&& rm /src/dumpifs/dumpifs \
|
||||
&& make -C /src/dumpifs dumpifs \
|
||||
&& fetch-commit https://github.com/lzfse/lzfse.git /src/lzfse "${LZFSE_COMMIT}" \
|
||||
&& make -C /src/lzfse install \
|
||||
&& fetch-commit https://github.com/Lekensteyn/dmg2img.git /src/dmg2img "${DMG2IMG_COMMIT}" \
|
||||
&& make -C /src/dmg2img dmg2img vfdecrypt HAVE_LZFSE=1 \
|
||||
&& install -m 755 /src/dumpifs/dumpifs /usr/local/bin/lzfse \
|
||||
/src/dmg2img/dmg2img /src/dmg2img/vfdecrypt /out/bin/
|
||||
|
||||
# Python extractors binwalk calls (upstream requirements.txt, versions
|
||||
# pinned), and esptool. Each gets its own venv at its final path; the final
|
||||
# image uses the same base and Python, so the venvs can be copied as-is.
|
||||
RUN python3 -m venv /opt/binwalk-py \
|
||||
&& CC=clang /opt/binwalk-py/bin/pip install --no-cache-dir --disable-pip-version-check \
|
||||
uefi_firmware==1.16 jefferson==0.4.8 ubi-reader==0.8.16 \
|
||||
lz4==4.4.5 zstandard==0.25.0 \
|
||||
"vmlinux-to-elf @ git+https://github.com/marin-m/vmlinux-to-elf@${VMLINUX_TO_ELF_COMMIT}" \
|
||||
&& python3 -m venv /opt/esptool \
|
||||
&& /opt/esptool/bin/pip install --no-cache-dir --disable-pip-version-check \
|
||||
"esptool==${ESPTOOL_VERSION}"
|
||||
|
||||
# unblob (https://github.com/onekey-sec/unblob), the firmware extractor REA
|
||||
# uses for extract_firmware; REA is verified with 26.6.4. Installed from the
|
||||
# PyPI wheel (SHA-256 from PyPI) into its own venv, with every dependency
|
||||
# pinned to unblob's uv.lock at the release tag's commit, like ghidra-mcp.
|
||||
ARG UNBLOB_VERSION=26.6.4
|
||||
ARG UNBLOB_COMMIT=1fcc7a0a584a70a96c31f5a276c20944d199a089
|
||||
ARG UNBLOB_WHEEL_SHA256=0fc16e9c7dc92fcf86b8b61475700c9dd16e1dddacf34467574ea965a5d3584e
|
||||
ARG UNBLOB_UVLOCK_SHA256=0a88ed397d60e0624bdef34c7a0b7329a6d278dfdedb86f940c6ee25401c0317
|
||||
COPY scripts/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py
|
||||
RUN set -e \
|
||||
&& whl="unblob-${UNBLOB_VERSION}-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" \
|
||||
&& curl -fsSLo "/tmp/${whl}" "https://files.pythonhosted.org/packages/cp39/u/unblob/${whl}" \
|
||||
&& echo "${UNBLOB_WHEEL_SHA256} /tmp/${whl}" | sha256sum -c - \
|
||||
&& curl -fsSLo /tmp/uv.lock "https://raw.githubusercontent.com/onekey-sec/unblob/${UNBLOB_COMMIT}/uv.lock" \
|
||||
&& echo "${UNBLOB_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \
|
||||
&& python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \
|
||||
&& python3 -m venv /opt/unblob \
|
||||
&& /opt/unblob/bin/pip install --no-cache-dir --disable-pip-version-check \
|
||||
--constraint /tmp/constraints.txt "/tmp/${whl}"
|
||||
# (unblob needs libmagic to start, which this stage lacks; the final stage
|
||||
# runs it.)
|
||||
|
||||
|
||||
# --- Final image -------------------------------------------------------------
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -30,34 +177,252 @@ RUN apt-get update \
|
||||
claude-desktop \
|
||||
x11-utils \
|
||||
xauth \
|
||||
socat \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Ghidra from the official GitHub release, checked against the SHA-256 in the
|
||||
# release notes: https://github.com/NationalSecurityAgency/ghidra/releases
|
||||
# Ghidra 12.2 needs JDK 25 (application.java.min=25). The full JDK, not the
|
||||
# headless one, because Ghidra's GUI needs the AWT/X11 libraries.
|
||||
ARG GHIDRA_VERSION=12.2
|
||||
ARG GHIDRA_BUILD_DATE=20261016
|
||||
ARG GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67
|
||||
# Ghidra (downloaded and verified in the ghidra-dist stage). Ghidra 12.1.4
|
||||
# needs JDK 21 (application.java.min=21). The full JDK, not the headless one,
|
||||
# because Ghidra's GUI needs the AWT/X11 libraries.
|
||||
ARG GHIDRA_VERSION
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openjdk-25-jdk unzip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSLo /tmp/ghidra.zip \
|
||||
"https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${GHIDRA_VERSION}_build/ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_BUILD_DATE}.zip" \
|
||||
&& echo "${GHIDRA_SHA256} /tmp/ghidra.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/ghidra.zip -d /opt \
|
||||
&& rm /tmp/ghidra.zip \
|
||||
&& ln -s "/opt/ghidra_${GHIDRA_VERSION}_PUBLIC" /opt/ghidra \
|
||||
&& apt-get install -y --no-install-recommends openjdk-21-jdk unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghidra-dist /opt/ghidra_${GHIDRA_VERSION}_PUBLIC /opt/ghidra_${GHIDRA_VERSION}_PUBLIC
|
||||
RUN ln -s "/opt/ghidra_${GHIDRA_VERSION}_PUBLIC" /opt/ghidra \
|
||||
&& ln -s /opt/ghidra/ghidraRun /usr/local/bin/ghidra
|
||||
ENV GHIDRA_INSTALL_DIR=/opt/ghidra
|
||||
|
||||
# Enable the "Developer" plugin package (File > Configure > Developer) in the
|
||||
# CodeBrowser. Ghidra copies its default tools into the user's settings on
|
||||
# first start, and settings are reset on every container start, so the
|
||||
# default CodeBrowser.tool inside the jar is patched instead of user config.
|
||||
# A bare <PACKAGE NAME="Developer" /> only adds the package's RELEASED
|
||||
# plugins; the snippet also lists the STABLE ones. The build fails if the
|
||||
# snippet could not be inserted.
|
||||
COPY ghidra/codebrowser-developer.xml /tmp/codebrowser-developer.xml
|
||||
RUN set -e; cd /tmp \
|
||||
&& jar_file=/opt/ghidra/Ghidra/Configurations/Public_Release/lib/Public_Release.jar \
|
||||
&& unzip -q "$jar_file" defaultTools/CodeBrowser.tool \
|
||||
&& awk 'FNR==NR { snip = snip $0 "\n"; next } !done && /<ROOT_NODE/ { printf "%s", snip; done = 1 } { print }' \
|
||||
/tmp/codebrowser-developer.xml defaultTools/CodeBrowser.tool > /tmp/CodeBrowser.tool \
|
||||
&& mv /tmp/CodeBrowser.tool defaultTools/CodeBrowser.tool \
|
||||
&& grep -q 'FidDebugPlugin' defaultTools/CodeBrowser.tool \
|
||||
&& jar uf "$jar_file" defaultTools/CodeBrowser.tool \
|
||||
&& rm -r /tmp/defaultTools /tmp/codebrowser-developer.xml
|
||||
|
||||
# ESP32 Flash Loader (built above), installed like GhidraMCP into Ghidra's
|
||||
# own Extensions directory. It's a loader, not a plugin, so no tool config is
|
||||
# needed: it shows up as a format in File > Import File.
|
||||
COPY --from=esp32-loader-build /out/esp32 /opt/ghidra/Ghidra/Extensions/esp32
|
||||
|
||||
# GhidraMCP (https://github.com/LaurieWired/GhidraMCP), official release zip
|
||||
# checked against the SHA-256 GitHub lists for it. It holds:
|
||||
# - the Ghidra extension, unpacked into Ghidra's own Extensions directory so
|
||||
# it is installed for every user. Its GhidraMCPPlugin is a RELEASED plugin
|
||||
# in the Developer package, so the CodeBrowser config above loads it. It
|
||||
# serves an HTTP backend on 127.0.0.1:8080 while the CodeBrowser is open.
|
||||
# - the ghidra-mcp Python MCP server (wheel), installed into its own venv.
|
||||
# Claude Desktop starts it over stdio; it forwards calls to that backend.
|
||||
# The wheel only gives version ranges, so every dependency is pinned to
|
||||
# the version in GhidraMCP's uv.lock at the release commit.
|
||||
ARG GHIDRAMCP_VERSION=2.0
|
||||
ARG GHIDRAMCP_SHA256=ebf904fd6782e429e95a60a24fe0724b20c0acb2dedb304135a5281f6b5f2e72
|
||||
ARG GHIDRAMCP_COMMIT=1e980252087b2479a21b9ca6d82d5829538651a7
|
||||
ARG GHIDRAMCP_UVLOCK_SHA256=f80ffe8df8167535f253976a1dfb702a9a7f8836d884084017dd7c04e1444837
|
||||
COPY scripts/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py
|
||||
RUN set -e \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rel="GhidraMCP-release-$(echo "${GHIDRAMCP_VERSION}" | tr . -)" \
|
||||
&& curl -fsSLo /tmp/ghidramcp.zip \
|
||||
"https://github.com/LaurieWired/GhidraMCP/releases/download/${GHIDRAMCP_VERSION}/${rel}.zip" \
|
||||
&& echo "${GHIDRAMCP_SHA256} /tmp/ghidramcp.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/ghidramcp.zip -d /tmp/ghidramcp \
|
||||
&& unzip -q /tmp/ghidramcp/${rel}/ghidra_${GHIDRA_VERSION}_PUBLIC_*_GhidraMCP.zip \
|
||||
-d /opt/ghidra/Ghidra/Extensions \
|
||||
&& grep -qx "version=${GHIDRA_VERSION}" /opt/ghidra/Ghidra/Extensions/GhidraMCP/extension.properties \
|
||||
&& curl -fsSLo /tmp/uv.lock \
|
||||
"https://raw.githubusercontent.com/LaurieWired/GhidraMCP/${GHIDRAMCP_COMMIT}/uv.lock" \
|
||||
&& echo "${GHIDRAMCP_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \
|
||||
&& python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \
|
||||
&& python3 -m venv /opt/ghidra-mcp \
|
||||
&& /opt/ghidra-mcp/bin/pip install --no-cache-dir --disable-pip-version-check \
|
||||
--constraint /tmp/constraints.txt /tmp/ghidramcp/${rel}/ghidra_mcp-*-py3-none-any.whl \
|
||||
&& /opt/ghidra-mcp/bin/ghidra-mcp --help >/dev/null \
|
||||
&& rm -rf /tmp/ghidramcp /tmp/ghidramcp.zip /tmp/uv.lock /tmp/constraints.txt /tmp/uvlock-to-constraints.py
|
||||
|
||||
# binwalk and unblob (built above) with the extraction tools their install
|
||||
# scripts (binwalk's Dockerfile, unblob's install-deps.sh) put in place.
|
||||
# sasquatch (SquashFS variants) comes as a .deb from onekey-sec, at the
|
||||
# release unblob 26.6.4 uses. squashfs-tools adds the stock unsquashfs/
|
||||
# mksquashfs next to it (sasquatch only installs sasquatch*), since that's
|
||||
# the command people and Claude reach for first.
|
||||
# unblob's ext2/3/4 extraction needs the rdump options from onekey-sec's
|
||||
# e2fsprogs fork, so its .debs replace Ubuntu's
|
||||
# e2fsprogs (same upstream 1.47.0). These releases publish no checksums;
|
||||
# the SHA-256s were taken when pinned.
|
||||
# The Python tools' entry points are linked into /usr/local/bin; the venvs'
|
||||
# python/pip are not, so the system python3 isn't shadowed. unblob gets a
|
||||
# wrapper that puts its own venv first on PATH, so it calls its own locked
|
||||
# jefferson/ubireader rather than binwalk's.
|
||||
ARG SASQUATCH_RELEASE=sasquatch-v4.5.1-6
|
||||
ARG SASQUATCH_SHA256_AMD64=efbc92bd27594e01211e1b582f105a463a0017360a0746a93de3f283906ade95
|
||||
ARG E2FSPROGS_OK_RELEASE=v1.47.0-3.ok2
|
||||
ARG LIBEXT2FS2_OK_SHA256_AMD64=4eaf27e79a570ec89e15d8851f7df693b8b3ae78ccbb5b2a4a7d3eee845f1beb
|
||||
ARG LIBSS2_OK_SHA256_AMD64=6dfc4612915d885607f36fcea2b851a947f9c73943494208ec48a0cbe9887427
|
||||
ARG E2FSPROGS_OK_SHA256_AMD64=360adf4aa996cc80bf39002775d39900ae207b591c105d12a8ad2a833eb536f4
|
||||
# binwalk -E -p (entropy graph as PNG) renders through Kaleido. binwalk's
|
||||
# build downloads it into the build stage's /root, which the image doesn't
|
||||
# have, so -p would crash; it's fetched here instead (same release the crate
|
||||
# uses, no upstream checksum, SHA-256 taken when pinned) and found through
|
||||
# KALEIDO_PATH at runtime.
|
||||
ARG KALEIDO_VERSION=0.2.1
|
||||
ARG KALEIDO_SHA256_AMD64=3eaa3efd41a00db05dd71add7f54ad4e9d4259552e3f7470ac4c1a8594cea214
|
||||
COPY --from=binwalk-build /out/bin/ /usr/local/bin/
|
||||
COPY --from=binwalk-build /opt/binwalk-py /opt/binwalk-py
|
||||
COPY --from=binwalk-build /opt/esptool /opt/esptool
|
||||
COPY --from=binwalk-build /opt/unblob /opt/unblob
|
||||
RUN set -e \
|
||||
&& arch="$(dpkg --print-architecture)" \
|
||||
&& [ "$arch" = amd64 ] || { echo "sasquatch checksum only pinned for amd64" >&2; exit 1; } \
|
||||
&& curl -fsSLo /tmp/sasquatch.deb \
|
||||
"https://github.com/onekey-sec/sasquatch/releases/download/${SASQUATCH_RELEASE}/sasquatch_1.0_${arch}.deb" \
|
||||
&& echo "${SASQUATCH_SHA256_AMD64} /tmp/sasquatch.deb" | sha256sum -c - \
|
||||
&& mkdir /tmp/e2fsprogs-ok && cd /tmp/e2fsprogs-ok \
|
||||
&& v="${E2FSPROGS_OK_RELEASE#v}" \
|
||||
&& printf '%s %s\n' \
|
||||
"${LIBEXT2FS2_OK_SHA256_AMD64}" "libext2fs2_${v}_${arch}.deb" \
|
||||
"${LIBSS2_OK_SHA256_AMD64}" "libss2_${v}_${arch}.deb" \
|
||||
"${E2FSPROGS_OK_SHA256_AMD64}" "e2fsprogs_${v}_${arch}.deb" > SHA256SUMS \
|
||||
&& for f in $(awk '{print $2}' SHA256SUMS); do \
|
||||
curl -fsSLO "https://github.com/onekey-sec/e2fsprogs/releases/download/${E2FSPROGS_OK_RELEASE}/$f"; \
|
||||
done \
|
||||
&& sha256sum -c SHA256SUMS && cd / \
|
||||
&& curl -fsSLo /tmp/kaleido.zip \
|
||||
"https://github.com/plotly/Kaleido/releases/download/v${KALEIDO_VERSION}/kaleido_linux_x64.zip" \
|
||||
&& echo "${KALEIDO_SHA256_AMD64} /tmp/kaleido.zip" | sha256sum -c - \
|
||||
&& unzip -q /tmp/kaleido.zip -d /opt/kaleido \
|
||||
&& rm /tmp/kaleido.zip \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
/tmp/sasquatch.deb /tmp/e2fsprogs-ok/*.deb \
|
||||
7zip 7zip-standalone zstd srecord tar unzip sleuthkit cabextract \
|
||||
lz4 lzop unrar unyaffs cpio device-tree-compiler \
|
||||
liblzo2-2 libucl1 libfontconfig1 \
|
||||
android-sdk-libsparse-utils erofs-utils lziprecover partclone unar \
|
||||
upx-ucl libmagic1t64 xz-utils squashfs-tools \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/sasquatch.deb /tmp/e2fsprogs-ok \
|
||||
&& for f in /opt/binwalk-py/bin/* /opt/esptool/bin/*; do \
|
||||
case "$(basename "$f")" in python*|pip*|activate*|Activate*) continue ;; esac; \
|
||||
ln -s "$f" /usr/local/bin/; \
|
||||
done \
|
||||
&& printf '%s\n' '#!/bin/sh' 'PATH="/opt/unblob/bin:$PATH" exec /opt/unblob/bin/unblob "$@"' \
|
||||
> /usr/local/bin/unblob \
|
||||
&& chmod 755 /usr/local/bin/unblob \
|
||||
&& binwalk --version && esptool version && unblob --version \
|
||||
&& debugfs -V 2>&1 | grep -q 1.47.0 \
|
||||
&& unblob --show-external-dependencies
|
||||
# unsquashfs/sasquatch exit 2 as non-root on images with security.* xattrs
|
||||
# although every file was extracted; the wrapper adds -no-xattrs by default.
|
||||
COPY --chmod=755 scripts/squashfs-no-xattrs.sh /usr/local/libexec/squashfs-no-xattrs
|
||||
RUN for t in unsquashfs sasquatch sasquatch-v4be; do \
|
||||
ln -s /usr/local/libexec/squashfs-no-xattrs "/usr/local/bin/$t"; \
|
||||
done
|
||||
# Binwalk leaves a symlink to the input file in its extraction directory. The
|
||||
# target is a container path, which is dangling when viewed from the host.
|
||||
ENV BINWALK_RM_EXTRACTION_SYMLINK=1 \
|
||||
KALEIDO_PATH=/opt/kaleido
|
||||
|
||||
# Node.js, from the official tarball checked against nodejs.org's
|
||||
# SHASUMS256.txt. Ubuntu's nodejs (18) is too old for REA; 24.22.1 is the
|
||||
# version REA's own .nvmrc names.
|
||||
ARG NODE_VERSION=24.22.1
|
||||
ARG NODE_SHA256_AMD64=6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6
|
||||
RUN set -e \
|
||||
&& arch="$(dpkg --print-architecture)" \
|
||||
&& [ "$arch" = amd64 ] || { echo "Node.js checksum only pinned for amd64" >&2; exit 1; } \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends xz-utils procps \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSLo /tmp/node.tar.xz \
|
||||
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
|
||||
&& echo "${NODE_SHA256_AMD64} /tmp/node.tar.xz" | sha256sum -c - \
|
||||
&& mkdir /opt/node \
|
||||
&& tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 --no-same-owner \
|
||||
&& rm /tmp/node.tar.xz
|
||||
ENV PATH=/opt/node/bin:${PATH}
|
||||
|
||||
# REA, "Reverse Engineer Anything" (https://github.com/morluto/rea), from the
|
||||
# rea-agents npm package. The tarball is checked against the integrity hash
|
||||
# the npm registry lists for it; its dependencies are locked by the
|
||||
# npm-shrinkwrap.json it ships. npm blocks dependency install scripts by
|
||||
# default, so frida (optional, runtime instrumentation) stays unbuilt.
|
||||
# REA runs in its own container (see run.sh), not next to Claude Desktop,
|
||||
# because some of its tools run the target. It finds Ghidra through
|
||||
# GHIDRA_INSTALL_DIR and needs procps' ps (installed above).
|
||||
ARG REA_VERSION=6.8.0
|
||||
ARG REA_INTEGRITY=sha512-9QDeRnGqEDzABZyr04eLnBXBv/c6I9/TWu/6M0MD4pEfkySI/BIgwTODIbAReX3C1x6hjw9+Jlx4A2yUSPZ9ww==
|
||||
RUN set -e; cd /tmp \
|
||||
&& npm pack --silent "rea-agents@${REA_VERSION}" \
|
||||
&& actual="sha512-$(sha512sum "rea-agents-${REA_VERSION}.tgz" | cut -d' ' -f1 \
|
||||
| python3 -I -c 'import base64,sys; print(base64.b64encode(bytes.fromhex(sys.stdin.read().strip())).decode())')" \
|
||||
&& [ "$actual" = "${REA_INTEGRITY}" ] || { echo "rea-agents integrity mismatch: $actual" >&2; exit 1; } \
|
||||
&& npm install --global --omit=optional --no-audit --no-fund "./rea-agents-${REA_VERSION}.tgz" \
|
||||
&& rm -rf "rea-agents-${REA_VERSION}.tgz" /root/.npm \
|
||||
&& [ "$(rea --version)" = "${REA_VERSION}" ]
|
||||
ENV REA_BINWALK_COMMAND=/usr/local/bin/binwalk \
|
||||
REA_UNBLOB_COMMAND=/usr/local/bin/unblob
|
||||
|
||||
# stdio-to-HTTP bridge (mcp-remote) that Claude Desktop starts to reach REA's
|
||||
# HTTP endpoint in the other container. Installed from a committed lockfile.
|
||||
COPY claude-desktop/mcp-bridge/package.json claude-desktop/mcp-bridge/package-lock.json /opt/mcp-bridge/
|
||||
RUN cd /opt/mcp-bridge \
|
||||
&& npm ci --omit=dev --ignore-scripts --no-audit --no-fund \
|
||||
&& rm -rf /root/.npm \
|
||||
&& test -f node_modules/mcp-remote/dist/proxy.js
|
||||
|
||||
# Ubuntu 24.04 ships a default "ubuntu" user on UID 1000; replace it.
|
||||
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \
|
||||
&& groupadd -g "${USER_GID}" "${USER_NAME}" \
|
||||
&& useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash "${USER_NAME}" \
|
||||
&& install -d -o "${USER_UID}" -g "${USER_GID}" /workspace \
|
||||
&& ln -s /workspace "/home/${USER_NAME}/workspace"
|
||||
&& ln -s /workspace "/home/${USER_NAME}/workspace" \
|
||||
&& install -d -o "${USER_UID}" -g "${USER_GID}" \
|
||||
"/home/${USER_NAME}/.config" \
|
||||
"/home/${USER_NAME}/.config/ghidra" \
|
||||
"/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" \
|
||||
"/home/${USER_NAME}/.config/Claude"
|
||||
|
||||
# Ghidra's project window has no default config to patch; it builds one in
|
||||
# code and saves it on exit. So a starting FrontEndTool.xml with the Developer
|
||||
# plugins is put in the user settings. Pre-creating the settings dir makes
|
||||
# Ghidra ask for its user agreement, which it otherwise accepts by itself on
|
||||
# a fresh start, so preferences carries the same USER_AGREEMENT=ACCEPT.
|
||||
COPY --chown=${USER_UID}:${USER_GID} ghidra/FrontEndTool.xml ghidra/preferences \
|
||||
/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC/
|
||||
|
||||
# Claude Desktop's managed configuration directory. run.sh copies a
|
||||
# managed-settings.json in (root-owned, as Claude Desktop requires) when a
|
||||
# third-party model provider is chosen at start.
|
||||
RUN install -d -m 755 /etc/claude-desktop
|
||||
|
||||
# Claude Desktop's MCP servers. Only this file is put in ~/.config/Claude;
|
||||
# sign-in data is still created at runtime and discarded with the container.
|
||||
COPY --chown=${USER_UID}:${USER_GID} claude-desktop/claude_desktop_config.json \
|
||||
/home/${USER_NAME}/.config/Claude/claude_desktop_config.json
|
||||
|
||||
# REA's skill (workflow instructions) for Claude Code sessions in Claude
|
||||
# Desktop's Code tab, which load skills from ~/.claude/skills. Copied from
|
||||
# the installed rea-agents package, so it matches the REA version. Chat in
|
||||
# Claude Desktop does not read this folder.
|
||||
RUN install -d -o "${USER_UID}" -g "${USER_GID}" \
|
||||
"/home/${USER_NAME}/.claude" "/home/${USER_NAME}/.claude/skills" \
|
||||
&& cp -r /opt/node/lib/node_modules/rea-agents/skills/reverse-engineer-anything \
|
||||
"/home/${USER_NAME}/.claude/skills/" \
|
||||
&& chown -R "${USER_UID}:${USER_GID}" "/home/${USER_NAME}/.claude/skills/reverse-engineer-anything" \
|
||||
&& test -f "/home/${USER_NAME}/.claude/skills/reverse-engineer-anything/SKILL.md"
|
||||
|
||||
COPY --chmod=755 launch-apps.sh /usr/local/bin/launch-apps
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# claude_re_setup
|
||||
|
||||
A Docker-based reverse-engineering workstation for Linux. Claude Desktop is
|
||||
connected over MCP to Ghidra and to REA ("Reverse Engineer Anything"), and
|
||||
binwalk, unblob and esptool are installed alongside them. Nothing beyond
|
||||
Docker and an X server (or XWayland) is needed on the host.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./install.sh # build the image and add "Claude RE Workstation" to the app menu
|
||||
./install.sh --uninstall # remove that launcher again
|
||||
./run.sh # build the image if needed, pick a folder to share, start
|
||||
./run.sh --build # rebuild the image first
|
||||
./run.sh --workspace ~/re/target-x # share this folder without asking
|
||||
docker stop claude-desktop # stop everything
|
||||
```
|
||||
|
||||
On every start a window asks for the model provider:
|
||||
|
||||
- **Base URL empty:** Anthropic's models; sign in to claude.ai as usual.
|
||||
- **Base URL set:** Claude Desktop runs in its third-party "gateway" mode, with
|
||||
no claude.ai account: Chat, Code and Cowork send their requests to that URL
|
||||
with the token. The server must speak the Anthropic Messages API
|
||||
(`/v1/messages`), e.g. a self-hosted model behind LiteLLM. Models are
|
||||
optional (comma-separated, first = default); without them Claude Desktop
|
||||
asks the server's `/v1/models`. A server on this machine is reachable as
|
||||
`http://host.docker.internal:PORT` (`localhost` is rewritten to that) and must
|
||||
listen on an address Docker can reach. With `http://` the token is sent
|
||||
unencrypted.
|
||||
|
||||
The shared folder is mounted at `/workspace` in both containers. Neither the
|
||||
sign-in nor the token is kept between starts. Closing Claude
|
||||
Desktop or Ghidra's project window stops everything.
|
||||
|
||||
The launcher runs `run.sh` from this checkout, so re-run `./install.sh` after
|
||||
moving it. Started from the menu, errors appear in a dialog.
|
||||
|
||||
## What runs where
|
||||
|
||||
| Container | Contents | Network |
|
||||
| ---------------- | --------------------------------------------------------------- | -------------------------- |
|
||||
| `claude-desktop` | Claude Desktop, Ghidra GUI + GhidraMCP, binwalk, unblob, esptool | default + internal |
|
||||
| `claude-re-mcp` | REA MCP server (headless Ghidra, binwalk, unblob), no display | internal only, no internet |
|
||||
|
||||
Both containers run from the same image. REA gets its own container because
|
||||
some of its tools run the target. Claude Desktop reaches it at
|
||||
`http://rea:8765/mcp` through `mcp-remote`.
|
||||
|
||||
MCP servers in Claude Desktop (`claude-desktop/claude_desktop_config.json`):
|
||||
|
||||
- **ghidra**: needs a program open in Ghidra's CodeBrowser.
|
||||
- **rea**: opens files from `/workspace` by itself, with its own headless
|
||||
Ghidra project.
|
||||
|
||||
## Sources
|
||||
|
||||
- Claude Desktop: Anthropic's apt repository (https://code.claude.com/docs/en/desktop-linux)
|
||||
- Ghidra 12.1.4: https://github.com/NationalSecurityAgency/ghidra
|
||||
- GhidraMCP 2.0: https://github.com/LaurieWired/GhidraMCP
|
||||
- ESP32 Flash Loader: https://github.com/dynacylabs/ghidra-esp32-flash-loader
|
||||
- REA 6.8.0 (Reverse Engineer Anything): https://github.com/morluto/rea, npm `rea-agents`
|
||||
- mcp-remote 0.15.1: https://github.com/punkpeye/mcp-remote (formerly geelen/mcp-remote)
|
||||
- binwalk 3.1.1: https://github.com/ReFirmLabs/binwalk
|
||||
- unblob 26.6.4: https://github.com/onekey-sec/unblob
|
||||
- esptool 5.6.0: https://github.com/espressif/esptool
|
||||
|
||||
Versions and checksums are pinned in the `Dockerfile`. `CLAUDE.md` has the
|
||||
details.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ghidra": {
|
||||
"command": "/opt/ghidra-mcp/bin/ghidra-mcp",
|
||||
"args": ["--ghidra-server", "http://127.0.0.1:8080/"]
|
||||
},
|
||||
"rea": {
|
||||
"command": "/opt/node/bin/node",
|
||||
"args": [
|
||||
"/opt/mcp-bridge/node_modules/mcp-remote/dist/proxy.js",
|
||||
"http://rea:8765/mcp",
|
||||
"--allow-http",
|
||||
"--transport",
|
||||
"http-only"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "claude-re-setup-mcp-bridge",
|
||||
"private": true,
|
||||
"description": "stdio-to-HTTP bridge Claude Desktop uses to reach MCP servers in other containers",
|
||||
"dependencies": {
|
||||
"mcp-remote": "0.15.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Initial config for Ghidra's project window (front end). Ghidra overwrites
|
||||
it on exit; it is copied into the image so that every container start
|
||||
opens with the "Developer" plugin package enabled (File > Configure). -->
|
||||
<!-- VERSION must match GHIDRA_VERSION in the Dockerfile. -->
|
||||
<FRONTEND VERSION="12.1.4">
|
||||
<TOOL_CONFIG CONFIG_NAME="NO_LONGER_USED">
|
||||
<ICON LOCATION="view_detailed.png" />
|
||||
<TOOL TOOL_NAME="Ghidra: NO ACTIVE PROJECT" INSTANCE_NAME="">
|
||||
<OPTIONS />
|
||||
<PACKAGE NAME="Utility" />
|
||||
<PACKAGE NAME="Ghidra Core" />
|
||||
<!-- Developer plugins that may run in the project window. All are
|
||||
STABLE, not RELEASED, so each must be listed explicitly. -->
|
||||
<PACKAGE NAME="Developer">
|
||||
<INCLUDE CLASS="ghidra.app.plugin.debug.DomainFolderChangesDisplayPlugin" />
|
||||
<INCLUDE CLASS="ghidra.app.plugin.debug.GenerateOldLanguagePlugin" />
|
||||
<INCLUDE CLASS="ghidra.app.plugin.debug.JavaHelpPlugin" />
|
||||
</PACKAGE>
|
||||
<ROOT_NODE X_POS="560" Y_POS="240" WIDTH="800" HEIGHT="600" EX_STATE="0" FOCUSED_OWNER="FrontEnd Tool" FOCUSED_NAME="FrontEnd" FOCUSED_TITLE="Project Window">
|
||||
<COMPONENT_NODE TOP_INFO="0">
|
||||
<COMPONENT_INFO NAME="FrontEnd" OWNER="FrontEnd Tool" TITLE="Project Window" ACTIVE="true" GROUP="Default" />
|
||||
</COMPONENT_NODE>
|
||||
</ROOT_NODE>
|
||||
</TOOL>
|
||||
</TOOL_CONFIG>
|
||||
</FRONTEND>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!-- Inserted at build time (see Dockerfile). The bare package adds its
|
||||
RELEASED plugins; the STABLE ones must be listed explicitly. -->
|
||||
<PACKAGE NAME="Developer">
|
||||
<INCLUDE CLASS="ghidra.app.plugin.debug.GenerateOldLanguagePlugin" />
|
||||
<INCLUDE CLASS="ghidra.app.plugin.debug.JavaHelpPlugin" />
|
||||
<INCLUDE CLASS="ghidra.app.plugin.gui.WindowLocationPlugin" />
|
||||
<INCLUDE CLASS="ghidra.feature.fid.plugin.FidDebugPlugin" />
|
||||
</PACKAGE>
|
||||
@@ -0,0 +1,4 @@
|
||||
# Same value Ghidra writes on its own on a fresh start (no settings dir).
|
||||
# Needed because the image pre-creates the settings dir for FrontEndTool.xml,
|
||||
# which otherwise makes Ghidra show its user agreement dialog every start.
|
||||
USER_AGREEMENT=ACCEPT
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the image and add a "Claude RE Workstation" launcher to the desktop's
|
||||
# application menu. The launcher runs ./run.sh from this checkout, so keep
|
||||
# the checkout where it is (re-run this script after moving it).
|
||||
#
|
||||
# Usage: ./install.sh [--uninstall]
|
||||
# --uninstall remove the launcher and its icons (the image is kept;
|
||||
# remove it with: docker rmi claude-re-setup:latest)
|
||||
#
|
||||
# RUNTIME=podman is passed on to the launcher.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${IMAGE:-claude-re-setup:latest}"
|
||||
RUNTIME="${RUNTIME:-docker}"
|
||||
|
||||
app_id="claude-re-setup"
|
||||
script_dir="$(dirname "$(readlink -f "$0")")"
|
||||
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
desktop_file="$data_home/applications/$app_id.desktop"
|
||||
icon_root="$data_home/icons/hicolor"
|
||||
icon_sizes=(16 32 48 128 256)
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
info() { echo "==> $*"; }
|
||||
|
||||
refresh_caches() {
|
||||
command -v update-desktop-database >/dev/null \
|
||||
&& update-desktop-database -q "$data_home/applications" || true
|
||||
command -v gtk-update-icon-cache >/dev/null && [[ -f "$icon_root/index.theme" ]] \
|
||||
&& gtk-update-icon-cache -q -t "$icon_root" || true
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--uninstall)
|
||||
rm -f "$desktop_file"
|
||||
for size in "${icon_sizes[@]}"; do
|
||||
rm -f "$icon_root/${size}x${size}/apps/$app_id.png"
|
||||
done
|
||||
refresh_caches
|
||||
info "Removed the launcher. The image is still there: $RUNTIME rmi $IMAGE"
|
||||
exit 0 ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
"") ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
|
||||
# The desktop entry quotes the path; these characters would need escaping
|
||||
# there, so such a checkout location is refused instead.
|
||||
[[ "$script_dir" != *[\"\`\$\\%]* ]] \
|
||||
|| die "the checkout path contains a character a .desktop file can't hold: $script_dir"
|
||||
|
||||
# --- Image -------------------------------------------------------------------
|
||||
info "Building $IMAGE (cached layers are reused)"
|
||||
RUNTIME="$RUNTIME" IMAGE="$IMAGE" "$script_dir/run.sh" --build-only
|
||||
|
||||
# --- Icons -------------------------------------------------------------------
|
||||
# Claude Desktop's own icons, taken from the image, under this launcher's
|
||||
# name so they don't clash with a natively installed Claude Desktop.
|
||||
info "Installing icons"
|
||||
cid="$("$RUNTIME" create "$IMAGE")"
|
||||
trap '"$RUNTIME" rm "$cid" >/dev/null 2>&1 || true' EXIT
|
||||
for size in "${icon_sizes[@]}"; do
|
||||
dir="$icon_root/${size}x${size}/apps"
|
||||
mkdir -p "$dir"
|
||||
"$RUNTIME" cp "$cid:/usr/share/icons/hicolor/${size}x${size}/apps/claude-desktop.png" \
|
||||
"$dir/$app_id.png"
|
||||
done
|
||||
|
||||
# --- Desktop entry -----------------------------------------------------------
|
||||
exec_line="\"$script_dir/run.sh\""
|
||||
[[ "$RUNTIME" != docker ]] && exec_line="env RUNTIME=$RUNTIME $exec_line"
|
||||
|
||||
info "Writing $desktop_file"
|
||||
mkdir -p "$(dirname "$desktop_file")"
|
||||
cat > "$desktop_file" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Claude RE Workstation
|
||||
GenericName=Reverse-engineering workstation
|
||||
Comment=Claude Desktop with Ghidra and REA in Docker
|
||||
Keywords=Claude;Ghidra;REA;Reverse;Engineering;Firmware;
|
||||
Exec=$exec_line
|
||||
Path=$script_dir
|
||||
Icon=$app_id
|
||||
Terminal=false
|
||||
Categories=Development;
|
||||
StartupNotify=true
|
||||
# Claude Desktop's windows in the container carry this class, so docks
|
||||
# group them under this launcher.
|
||||
StartupWMClass=com.anthropic.Claude
|
||||
EOF
|
||||
|
||||
if command -v desktop-file-validate >/dev/null; then
|
||||
desktop-file-validate "$desktop_file"
|
||||
fi
|
||||
refresh_caches
|
||||
|
||||
info "Done. Start \"Claude RE Workstation\" from the application menu."
|
||||
@@ -15,6 +15,12 @@ set -u
|
||||
# stops everything it started (Ghidra's launch script runs java as a child).
|
||||
set -m
|
||||
|
||||
# Plain-HTTP model gateway chosen in run.sh: Claude Desktop only allows http
|
||||
# to loopback, so it talks to 127.0.0.1:18080 and socat forwards that.
|
||||
if [[ -n "${CLAUDE_RE_GATEWAY_FORWARD:-}" ]]; then
|
||||
socat TCP-LISTEN:18080,bind=127.0.0.1,reuseaddr,fork "TCP:${CLAUDE_RE_GATEWAY_FORWARD}" &
|
||||
fi
|
||||
|
||||
claude-desktop "$@" &
|
||||
claude=$!
|
||||
"${GHIDRA_INSTALL_DIR:-/opt/ghidra}/support/launch.sh" fg jdk Ghidra "" "" ghidra.GhidraRun &
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
# Before launching, verify that a window from the container can actually be
|
||||
# drawn on the host display. Asks (in a folder picker) which host folder to
|
||||
# share with Claude; it is mounted read-write at /workspace in the container.
|
||||
# Also starts the REA MCP server in a second, isolated container and stops it
|
||||
# when Claude Desktop's container ends.
|
||||
#
|
||||
# Usage: ./run.sh [--build] [--check-only] [--workspace DIR]
|
||||
# Usage: ./run.sh [--build] [--build-only] [--check-only] [--workspace DIR]
|
||||
# --build rebuild the image even if it exists
|
||||
# --build-only (re)build the image and exit
|
||||
# --check-only run the display check and exit without starting Claude
|
||||
# --workspace DIR share DIR without asking (also: WORKSPACE=DIR)
|
||||
|
||||
@@ -13,6 +16,9 @@ set -euo pipefail
|
||||
|
||||
IMAGE="${IMAGE:-claude-re-setup:latest}"
|
||||
CONTAINER="${CONTAINER:-claude-desktop}"
|
||||
REA_CONTAINER="${REA_CONTAINER:-claude-re-mcp}"
|
||||
# Internal network (no route out) shared by Claude Desktop and REA.
|
||||
NETWORK="${NETWORK:-claude-re-setup-internal}"
|
||||
RUNTIME="${RUNTIME:-docker}"
|
||||
WORKSPACE="${WORKSPACE:-}"
|
||||
|
||||
@@ -22,23 +28,50 @@ last_workspace_file="$state_dir/last_workspace"
|
||||
|
||||
build=0
|
||||
check_only=0
|
||||
build_only=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--build) build=1 ;;
|
||||
--build-only) build=1 build_only=1 ;;
|
||||
--check-only) check_only=1 ;;
|
||||
--workspace) [[ $# -ge 2 ]] || { echo "--workspace needs a directory" >&2; exit 2; }
|
||||
WORKSPACE="$2"; shift ;;
|
||||
--workspace=*) WORKSPACE="${1#*=}" ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
# Started from the app menu (install.sh) there is no terminal to read
|
||||
# errors in, so they are also shown in a dialog.
|
||||
die() {
|
||||
echo "error: $*" >&2
|
||||
if [[ ! -t 2 ]] && command -v zenity >/dev/null; then
|
||||
zenity --error --no-wrap --title="Claude RE workstation" --text="$*" 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
info() { echo "==> $*"; }
|
||||
|
||||
command -v "$RUNTIME" >/dev/null || die "'$RUNTIME' not found (set RUNTIME=podman to use podman)"
|
||||
|
||||
# --- Image build -------------------------------------------------------------
|
||||
ensure_image() {
|
||||
if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
info "Building $IMAGE"
|
||||
"$RUNTIME" build \
|
||||
--build-arg USER_UID="$(id -u)" \
|
||||
--build-arg USER_GID="$(id -g)" \
|
||||
-t "$IMAGE" "$script_dir"
|
||||
fi
|
||||
}
|
||||
if (( build_only )); then
|
||||
ensure_image
|
||||
info "Built $IMAGE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v xauth >/dev/null || die "xauth not found on the host (dnf install xorg-x11-xauth)"
|
||||
|
||||
# --- Display -----------------------------------------------------------------
|
||||
@@ -64,13 +97,7 @@ xauth nlist "$DISPLAY" | sed -e 's/^..../ffff/' | xauth -f "$xauth_file" nmerge
|
||||
chmod 600 "$xauth_file"
|
||||
|
||||
# --- Image -------------------------------------------------------------------
|
||||
if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
info "Building $IMAGE"
|
||||
"$RUNTIME" build \
|
||||
--build-arg USER_UID="$(id -u)" \
|
||||
--build-arg USER_GID="$(id -g)" \
|
||||
-t "$IMAGE" "$script_dir"
|
||||
fi
|
||||
ensure_image
|
||||
|
||||
# label=disable: the X socket and cookie are host files that SELinux would
|
||||
# otherwise block, and relabeling /tmp/.X11-unix with :z would break the host.
|
||||
@@ -115,10 +142,121 @@ fi
|
||||
(( check_only )) && { info "Display check passed"; exit 0; }
|
||||
|
||||
# --- Claude Desktop ----------------------------------------------------------
|
||||
if "$RUNTIME" container inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
die "container '$CONTAINER' already exists; stop it with: $RUNTIME stop $CONTAINER"
|
||||
for c in "$CONTAINER" "$REA_CONTAINER"; do
|
||||
if "$RUNTIME" container inspect "$c" >/dev/null 2>&1; then
|
||||
die "container '$c' already exists; stop it with: $RUNTIME stop $c"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Model provider ----------------------------------------------------------
|
||||
# Asked on every start. Left empty, Claude Desktop starts normally (claude.ai
|
||||
# sign-in, Anthropic's models). Given a base URL, Claude Desktop runs in its
|
||||
# third-party "gateway" mode: Chat, Code and Cowork send their requests to
|
||||
# that URL, which must speak the Anthropic Messages API (/v1/messages), e.g.
|
||||
# a self-hosted model behind LiteLLM. The settings go into Claude Desktop's
|
||||
# managed configuration file (/etc/claude-desktop/managed-settings.json on
|
||||
# Linux). Claude Desktop ignores that file unless root owns it and only root
|
||||
# can write it, so it is copied into the created container (as root, 0644)
|
||||
# just before it starts, rather than mounted; the copy on this side, in a
|
||||
# private runtime folder, is deleted right after. Nothing is remembered
|
||||
# between starts, the token included.
|
||||
managed_settings_dir="$xauth_dir/managed-settings"
|
||||
rm -rf "$managed_settings_dir"
|
||||
gateway_url=""
|
||||
gateway_token=""
|
||||
gateway_scheme="bearer"
|
||||
gateway_models=""
|
||||
|
||||
# JSON string literal for $1 (refuses control characters).
|
||||
json_str() {
|
||||
[[ "$1" != *[[:cntrl:]]* ]] || die "control characters are not allowed in: $1"
|
||||
local s="${1//\\/\\\\}"
|
||||
printf '"%s"' "${s//\"/\\\"}"
|
||||
}
|
||||
|
||||
provider_text="Leave the base URL empty to use Anthropic's models (sign in to claude.ai as usual).
|
||||
|
||||
To use another model, enter the base URL of a server that speaks the Anthropic Messages API, without /v1 (e.g. http://host.docker.internal:4000 for a LiteLLM proxy on this machine). With http:// the token is sent unencrypted. Models: comma-separated IDs, the first is the default; leave empty to ask the server's /v1/models."
|
||||
if command -v zenity >/dev/null; then
|
||||
info "Choose the model provider in the dialog"
|
||||
form="$(zenity --forms --title="Claude RE workstation: model provider" \
|
||||
--text="$provider_text" --separator=$'\x1f' \
|
||||
--add-entry="Base URL (ANTHROPIC_BASE_URL)" \
|
||||
--add-password="Token" \
|
||||
--add-combo="Send token as" --combo-values="Authorization: Bearer|x-api-key" \
|
||||
--add-entry="Models (optional)" 2>/dev/null)" \
|
||||
|| die "model provider dialog cancelled; not starting Claude"
|
||||
IFS=$'\x1f' read -r gateway_url gateway_token scheme_choice gateway_models <<< "$form"
|
||||
[[ "${scheme_choice:-}" == "x-api-key" ]] && gateway_scheme="x-api-key"
|
||||
elif [[ -t 0 ]]; then
|
||||
echo "$provider_text"
|
||||
read -r -p "Base URL (empty = Anthropic): " gateway_url
|
||||
if [[ -n "$gateway_url" ]]; then
|
||||
read -r -s -p "Token: " gateway_token; echo
|
||||
read -r -p "Models (optional): " gateway_models
|
||||
fi
|
||||
fi
|
||||
|
||||
# Trim spaces and a trailing slash or /v1 (Claude Desktop appends /v1/messages).
|
||||
gateway_url="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's#/*$##' -e 's#/v1$##' <<< "$gateway_url")"
|
||||
gateway_token="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<< "$gateway_token")"
|
||||
if [[ -n "$gateway_url" ]]; then
|
||||
[[ "$gateway_url" =~ ^https?://[^/[:space:]]+ ]] \
|
||||
|| die "the base URL must start with http:// or https://: $gateway_url"
|
||||
# localhost in the container is the container itself; a server on this
|
||||
# machine is reached through host.docker.internal (added to the container
|
||||
# below). It has to listen on an address Docker's bridge can reach, not
|
||||
# only 127.0.0.1.
|
||||
if [[ "$gateway_url" =~ ^(https?://)(localhost|127\.0\.0\.1|\[::1\])(:|/|$)(.*)$ ]]; then
|
||||
gateway_url="${BASH_REMATCH[1]}host.docker.internal${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
|
||||
info "Using $gateway_url (localhost inside the container is the container itself)"
|
||||
fi
|
||||
gateway_shown="$gateway_url"
|
||||
# Claude Desktop accepts plain http only for loopback addresses. For an
|
||||
# http:// server elsewhere, socat in the container listens on
|
||||
# 127.0.0.1:18080 and forwards to it (started by launch-apps.sh), and
|
||||
# Claude Desktop is pointed there. The token still crosses the network
|
||||
# unencrypted, as it would with any plain-HTTP server.
|
||||
if [[ "$gateway_url" =~ ^http://([^/:]+)(:([0-9]+))?(/.*)?$ ]]; then
|
||||
fwd_target="${BASH_REMATCH[1]}:${BASH_REMATCH[3]:-80}"
|
||||
run_args+=(-e "CLAUDE_RE_GATEWAY_FORWARD=$fwd_target")
|
||||
gateway_url="http://127.0.0.1:18080${BASH_REMATCH[4]}"
|
||||
info "Plain HTTP: forwarding 127.0.0.1:18080 in the container to $fwd_target (token not encrypted)"
|
||||
elif [[ "$gateway_url" == http://* ]]; then
|
||||
die "unsupported http:// address (use a hostname or IPv4 address, or https://): $gateway_url"
|
||||
fi
|
||||
# The key is required in gateway mode; local servers usually ignore it.
|
||||
[[ -n "$gateway_token" ]] || gateway_token="none"
|
||||
|
||||
models_json=""
|
||||
IFS=',' read -r -a model_list <<< "$gateway_models"
|
||||
for m in "${model_list[@]}"; do
|
||||
m="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<< "$m")"
|
||||
[[ -n "$m" ]] && models_json+="${models_json:+, }$(json_str "$m")"
|
||||
done
|
||||
|
||||
# Removed again if the start fails before it is copied in.
|
||||
trap 'rm -rf "$managed_settings_dir"' EXIT
|
||||
mkdir -m 700 "$managed_settings_dir"
|
||||
( umask 077
|
||||
{
|
||||
echo "{"
|
||||
echo " \"inferenceProvider\": \"gateway\","
|
||||
echo " \"inferenceGatewayBaseUrl\": $(json_str "$gateway_url"),"
|
||||
echo " \"inferenceGatewayApiKey\": $(json_str "$gateway_token"),"
|
||||
echo " \"inferenceGatewayAuthScheme\": \"$gateway_scheme\","
|
||||
[[ -n "$models_json" ]] && echo " \"inferenceModels\": [$models_json],"
|
||||
echo " \"disableDeploymentModeChooser\": true"
|
||||
echo "}"
|
||||
} > "$managed_settings_dir/managed-settings.json" )
|
||||
info "Model provider: gateway at $gateway_shown"
|
||||
else
|
||||
info "Model provider: Anthropic (claude.ai sign-in)"
|
||||
fi
|
||||
unset gateway_token
|
||||
# For self-hosted servers on this machine (see above).
|
||||
run_args+=(--add-host host.docker.internal:host-gateway)
|
||||
|
||||
# --- Workspace ---------------------------------------------------------------
|
||||
# The folder Claude can read and write. Chosen in the desktop's own folder
|
||||
# picker (zenity on GNOME, kdialog on KDE), else asked in the terminal.
|
||||
@@ -188,13 +326,58 @@ problem="$(workspace_problem "$WORKSPACE")"
|
||||
mkdir -p "$state_dir"
|
||||
printf '%s\n' "$WORKSPACE" > "$last_workspace_file"
|
||||
|
||||
# --- REA MCP server ----------------------------------------------------------
|
||||
# REA (Reverse Engineer Anything) gets its own container from the same image,
|
||||
# because some of its tools run the target: no display, no capabilities, only
|
||||
# the workspace mounted, and only the internal network, which has no route
|
||||
# out. It serves MCP over HTTP as "rea:8765"; Claude Desktop reaches it
|
||||
# through mcp-remote (see claude-desktop/claude_desktop_config.json).
|
||||
if ! "$RUNTIME" network inspect "$NETWORK" >/dev/null 2>&1; then
|
||||
info "Creating internal network $NETWORK"
|
||||
"$RUNTIME" network create --internal "$NETWORK" >/dev/null
|
||||
fi
|
||||
|
||||
ws_mount=(-v "$WORKSPACE:/workspace")
|
||||
info "Starting the REA MCP server in container '$REA_CONTAINER'"
|
||||
"$RUNTIME" run -d --rm --init \
|
||||
--name "$REA_CONTAINER" \
|
||||
--network "$NETWORK" --network-alias rea \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--security-opt label=disable \
|
||||
"${ws_mount[@]}" \
|
||||
"$IMAGE" rea mcp --http --host 0.0.0.0 --port 8765 >/dev/null
|
||||
stop_rea() { "$RUNTIME" stop "$REA_CONTAINER" >/dev/null 2>&1 || true; }
|
||||
|
||||
# --- Claude Desktop ----------------------------------------------------------
|
||||
# No config volume: the container is removed on stop (--rm), so the sign-in
|
||||
# is discarded and Claude asks for a login on every start.
|
||||
# No :z on the workspace mount: label=disable already lets the container use
|
||||
# it, and relabeling an arbitrary host folder could break other apps.
|
||||
# Created first, so it is on the internal network (as well as the default one,
|
||||
# for sign-in) before it starts.
|
||||
info "Starting Claude Desktop and Ghidra in container '$CONTAINER' (workspace: $WORKSPACE -> /workspace)"
|
||||
"$RUNTIME" run -d "${run_args[@]}" \
|
||||
--name "$CONTAINER" \
|
||||
-v "$WORKSPACE:/workspace" \
|
||||
"$IMAGE" >/dev/null
|
||||
info "Running. Logs: $RUNTIME logs -f $CONTAINER Stop: $RUNTIME stop $CONTAINER"
|
||||
# Copies the provider settings (if any) into the created container, owned
|
||||
# by root and readable by Claude Desktop, then deletes them on this side.
|
||||
copy_managed_settings() {
|
||||
[[ -d "$managed_settings_dir" ]] || return 0
|
||||
tar -C "$managed_settings_dir" --owner=0 --group=0 --numeric-owner --mode=0644 \
|
||||
-cf - managed-settings.json \
|
||||
| "$RUNTIME" cp - "$CONTAINER:/etc/claude-desktop/" \
|
||||
&& rm -rf "$managed_settings_dir"
|
||||
}
|
||||
if ! { "$RUNTIME" create "${run_args[@]}" --name "$CONTAINER" "${ws_mount[@]}" "$IMAGE" >/dev/null \
|
||||
&& "$RUNTIME" network connect "$NETWORK" "$CONTAINER" \
|
||||
&& copy_managed_settings \
|
||||
&& "$RUNTIME" start "$CONTAINER" >/dev/null; }; then
|
||||
"$RUNTIME" rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
stop_rea
|
||||
die "could not start Claude Desktop"
|
||||
fi
|
||||
trap - EXIT
|
||||
|
||||
# Stop REA once Claude Desktop's container is gone, in the background and
|
||||
# detached from this terminal.
|
||||
setsid nohup bash -c '"$1" wait "$2" >/dev/null 2>&1; "$1" stop "$3" >/dev/null 2>&1' \
|
||||
_ "$RUNTIME" "$CONTAINER" "$REA_CONTAINER" </dev/null >/dev/null 2>&1 &
|
||||
|
||||
info "Running. Logs: $RUNTIME logs -f $CONTAINER (REA: $REA_CONTAINER) Stop: $RUNTIME stop $CONTAINER"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Installed in the image as /usr/local/bin/{unsquashfs,sasquatch,sasquatch-v4be}
|
||||
# (symlinks to this script), in front of the real tools in /usr/bin.
|
||||
#
|
||||
# As the unprivileged container user these tools can't restore security.*
|
||||
# xattrs (SELinux labels, file capabilities) that firmware images often
|
||||
# carry: they extract every file but print "not superuser" errors and exit 2,
|
||||
# which reads as a failed extraction. Pass -no-xattrs unless the caller
|
||||
# already chose an xattr option (-xattrs, -user-xattrs, -xattrs-include, ...).
|
||||
tool="/usr/bin/$(basename "$0")"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-*xattrs*) exec "$tool" "$@" ;;
|
||||
esac
|
||||
done
|
||||
exec "$tool" -no-xattrs "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Print pip constraints (name==version) for every package in a uv.lock.
|
||||
|
||||
Used at image build time to install a Python tool's release wheel
|
||||
(ghidra-mcp, unblob) with the exact dependency versions its upstream lock
|
||||
file was tested with. The locked project itself (source "editable"/"virtual"
|
||||
at ".") is skipped; it is installed from the wheel. A package that uv
|
||||
resolved differently per Python version appears more than once; those
|
||||
entries carry their resolution markers, so pip applies only the one that
|
||||
matches the interpreter.
|
||||
"""
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
lock = tomllib.load(f)
|
||||
|
||||
for package in lock["package"]:
|
||||
source = package.get("source", {})
|
||||
if source.get("editable") == "." or source.get("virtual") == ".":
|
||||
continue
|
||||
line = f'{package["name"]}=={package["version"]}'
|
||||
markers = package.get("resolution-markers")
|
||||
if markers:
|
||||
line += " ; " + " or ".join(f"({m})" for m in markers)
|
||||
print(line)
|
||||
Reference in New Issue
Block a user