From 7a61c3088272b252f73c190469756735594e26e9 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 11:13:48 +0100 Subject: [PATCH 1/3] Add Dev Container Feature for Dev Proxy Adds a Dev Container Feature (features/src/devproxy), published as ghcr.io/dotnet/dev-proxy/devproxy, that installs Dev Proxy and, after the container is created, generates a per-container root certificate and trusts it system-wide, for Node.js and Python. Includes tests for the devcontainer CLI and workflows to test and publish the Feature. Closes #1334 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + .../devcontainer-feature-release.yml | 33 ++ .../workflows/devcontainer-feature-test.yml | 61 ++++ README.md | 8 + features/src/devproxy/README.md | 83 +++++ .../src/devproxy/devcontainer-feature.json | 48 +++ features/src/devproxy/install.sh | 286 ++++++++++++++++++ features/src/devproxy/scripts/post-create.sh | 59 ++++ features/src/devproxy/scripts/trust-cert.sh | 47 +++ features/test/devproxy/beta.sh | 24 ++ features/test/devproxy/common.sh | 122 ++++++++ features/test/devproxy/duplicate.sh | 23 ++ .../test/devproxy/no_trust_certificate.sh | 20 ++ .../devproxy/no_trust_certificate/Dockerfile | 4 + features/test/devproxy/non_root_user.sh | 28 ++ .../test/devproxy/non_root_user/Dockerfile | 4 + features/test/devproxy/runtimes.sh | 79 +++++ features/test/devproxy/scenarios.json | 61 ++++ features/test/devproxy/specific_version.sh | 22 ++ .../test/devproxy/specific_version_with_v.sh | 22 ++ features/test/devproxy/test.sh | 29 ++ skills/dev-proxy/references/installation.md | 12 + 22 files changed, 1076 insertions(+) create mode 100644 .github/workflows/devcontainer-feature-release.yml create mode 100644 .github/workflows/devcontainer-feature-test.yml create mode 100644 features/src/devproxy/README.md create mode 100644 features/src/devproxy/devcontainer-feature.json create mode 100755 features/src/devproxy/install.sh create mode 100755 features/src/devproxy/scripts/post-create.sh create mode 100755 features/src/devproxy/scripts/trust-cert.sh create mode 100755 features/test/devproxy/beta.sh create mode 100755 features/test/devproxy/common.sh create mode 100755 features/test/devproxy/duplicate.sh create mode 100755 features/test/devproxy/no_trust_certificate.sh create mode 100644 features/test/devproxy/no_trust_certificate/Dockerfile create mode 100755 features/test/devproxy/non_root_user.sh create mode 100644 features/test/devproxy/non_root_user/Dockerfile create mode 100755 features/test/devproxy/runtimes.sh create mode 100644 features/test/devproxy/scenarios.json create mode 100755 features/test/devproxy/specific_version.sh create mode 100755 features/test/devproxy/specific_version_with_v.sh create mode 100755 features/test/devproxy/test.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d900463f..9cc4aee4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,6 +60,7 @@ dotnet run --project DevProxy -- --detach --as-system-proxy false --port 0 --api - **CLI Commands:** `DevProxy/Commands/` — built with System.CommandLine, registered via `DevProxyCommand.cs` - **Configuration:** `DevProxy/config/devproxyrc.json` and other JSON files in `config/` - **macOS scripts:** `trust-cert.sh`, `remove-cert.sh`, `toggle-proxy.sh` in `DevProxy/` +- **Dev Container Feature:** `features/src/devproxy` (published as `ghcr.io/dotnet/dev-proxy/devproxy`), tests in `features/test/devproxy`. Run tests from `features/` with `npx @devcontainers/cli features test -f devproxy .`. Bump the version in `devcontainer-feature.json` to publish a new Feature version ## Resources diff --git a/.github/workflows/devcontainer-feature-release.yml b/.github/workflows/devcontainer-feature-release.yml new file mode 100644 index 00000000..b657aa1a --- /dev/null +++ b/.github/workflows/devcontainer-feature-release.yml @@ -0,0 +1,33 @@ +name: Dev Container Feature - Release + +# Publishes the Dev Container Feature in features/src to GHCR +# (ghcr.io/dotnet/dev-proxy/devproxy). To release a new version of the +# Feature, bump the version in devcontainer-feature.json. Versions that are +# already published are skipped. + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: [ 'features/src/**' ] + +permissions: + contents: read + +jobs: + publish: + if: github.repository == 'dotnet/dev-proxy' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Publish the Feature + uses: devcontainers/action@bef2cf2d1a019b00b45e184f83035e01bd1972f3 # v1.4.4 + with: + publish-features: 'true' + base-path-to-features: ./features/src + disable-repo-tagging: 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/devcontainer-feature-test.yml b/.github/workflows/devcontainer-feature-test.yml new file mode 100644 index 00000000..12b172b4 --- /dev/null +++ b/.github/workflows/devcontainer-feature-test.yml @@ -0,0 +1,61 @@ +name: Dev Container Feature - Test + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: [ 'features/**', '.github/workflows/devcontainer-feature-test.yml' ] + pull_request: + paths: [ 'features/**', '.github/workflows/devcontainer-feature-test.yml' ] + +permissions: + contents: read + +env: + DEVCONTAINER_CLI_VERSION: 0.89.0 + +jobs: + test-autogenerated: + name: Test on ${{ matrix.baseImage }} (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: [ ubuntu-latest, ubuntu-24.04-arm ] + baseImage: + - debian:latest + - ubuntu:latest + - mcr.microsoft.com/devcontainers/base:ubuntu + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the Dev Container CLI + run: npm install -g @devcontainers/cli@${{ env.DEVCONTAINER_CLI_VERSION }} + - name: Test the Feature with default options, installed once and twice + working-directory: features + run: devcontainer features test --skip-scenarios -f devproxy -i ${{ matrix.baseImage }} . + + test-scenarios: + name: Test scenarios (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: [ ubuntu-latest, ubuntu-24.04-arm ] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the Dev Container CLI + run: npm install -g @devcontainers/cli@${{ env.DEVCONTAINER_CLI_VERSION }} + - name: Test the Feature scenarios + working-directory: features + run: devcontainer features test -f devproxy --skip-autogenerated --skip-duplicated . + + validate: + name: Validate the Feature metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate devcontainer-feature.json + uses: devcontainers/action@bef2cf2d1a019b00b45e184f83035e01bd1972f3 # v1.4.4 + with: + validate-only: 'true' + base-path-to-features: ./features/src diff --git a/README.md b/README.md index 10b01862..28015b08 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ brew install dev-proxy bash -c "$(curl -sL https://aka.ms/devproxy/setup.sh)" ``` +**Dev containers and GitHub Codespaces** (installs Dev Proxy and trusts its certificate in the container, see [the Feature's docs](./features/src/devproxy/README.md)): + +```json +"features": { + "ghcr.io/dotnet/dev-proxy/devproxy:1": {} +} +``` + Download the preset for the API your app calls, and start Dev Proxy with it: ```bash diff --git a/features/src/devproxy/README.md b/features/src/devproxy/README.md new file mode 100644 index 00000000..17d114af --- /dev/null +++ b/features/src/devproxy/README.md @@ -0,0 +1,83 @@ +# Dev Proxy (devproxy) + +Installs [Dev Proxy](https://aka.ms/devproxy), an API simulator that helps you test how your app handles API failures, rate limits, latency, and more. Trusts a per-container Dev Proxy root certificate system-wide. + +## Example Usage + +```json +"features": { + "ghcr.io/dotnet/dev-proxy/devproxy:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Dev Proxy version to install: 'latest', 'beta', or a specific version like '2.2.0' or 'v2.2.0'. | string | latest | +| trustCertificate | After the container is created, generate a Dev Proxy root certificate unique to the container and trust it system-wide and for common runtimes. | boolean | true | + +## Customizations + +### VS Code Extensions + +- `garrytrinder.dev-proxy-toolkit` + +## What the Feature does + +When the image is built, the Feature: + +- Installs Dev Proxy in `/opt/devproxy` and adds the `devproxy` command to `/usr/local/bin`. For beta versions, `devproxy-beta` is available too. +- Installs the required dependencies (`curl`, `unzip`, `ca-certificates`, `openssl`, ICU) if they're missing. +- Creates the Dev Proxy configuration folder (`~/.config/dev-proxy`) for the remote user. +- If `trustCertificate` is `true`, installs a root-owned helper that adds the Dev Proxy root certificate to the system certificate store, and allows the remote user to run it with `sudo` without a password. + +The Feature doesn't generate the Dev Proxy root certificate when building the image. If it did, the certificate and its private key would end up in an image layer, and everyone using the same image or Codespaces prebuild would share the same trusted CA. + +Instead, after the container is created (`postCreateCommand`), the Feature: + +1. Runs `devproxy cert ensure` as the remote user to generate a root certificate unique to the container. +2. Adds the certificate to the system certificate store (`/usr/local/share/ca-certificates` and `update-ca-certificates`), so that curl, .NET, and other tools that use the system store trust it. + +Some runtimes don't use the system certificate store, so the Feature points them at the system CA bundle (`/etc/ssl/certs/ca-certificates.crt`): + +| Variable | Used by | +|----------|---------| +| `NODE_EXTRA_CA_CERTS` | Node.js | +| `REQUESTS_CA_BUNDLE` | Python `requests` | +| `SSL_CERT_FILE` | Python and other OpenSSL-based tools | + +Java keystores aren't supported. + +Python 3.13 and newer enable strict certificate verification (`VERIFY_X509_STRICT`), which rejects the certificates that Dev Proxy v3 and earlier generate for intercepted hosts. Use Dev Proxy v4 or newer (`"version": "beta"` until v4 is released) to call APIs through Dev Proxy from Python 3.13+. + +## Using Dev Proxy + +The Feature doesn't start Dev Proxy or set `HTTP_PROXY`/`HTTPS_PROXY` for the whole container. Container-wide proxy variables would route package managers, git, and VS Code's own traffic through Dev Proxy, which breaks them when Dev Proxy isn't running. + +Start Dev Proxy with your configuration, and configure the app you're testing to use it: + +```bash +devproxy --config-file .devproxy/devproxyrc.json +``` + +```bash +HTTPS_PROXY=http://127.0.0.1:8000 npm start +``` + +## Not installing the Dev Proxy Toolkit + +The Feature adds the [Dev Proxy Toolkit](https://marketplace.visualstudio.com/items?itemName=garrytrinder.dev-proxy-toolkit) VS Code extension. To skip it, exclude it in your `devcontainer.json`: + +```json +"customizations": { + "vscode": { + "extensions": ["-garrytrinder.dev-proxy-toolkit"] + } +} +``` + +## OS support + +The Feature supports Debian and Ubuntu-based images on x64 and arm64. Alpine and other musl-based images aren't supported. + diff --git a/features/src/devproxy/devcontainer-feature.json b/features/src/devproxy/devcontainer-feature.json new file mode 100644 index 00000000..a5ec9d33 --- /dev/null +++ b/features/src/devproxy/devcontainer-feature.json @@ -0,0 +1,48 @@ +{ + "id": "devproxy", + "version": "1.0.0", + "name": "Dev Proxy", + "documentationURL": "https://github.com/dotnet/dev-proxy/tree/main/features/src/devproxy", + "licenseURL": "https://github.com/dotnet/dev-proxy/blob/main/LICENSE", + "description": "Installs Dev Proxy, an API simulator that helps you test how your app handles API failures, rate limits, latency, and more. Trusts a per-container Dev Proxy root certificate system-wide.", + "keywords": [ + "dev-proxy", + "devproxy", + "api", + "proxy", + "testing", + "mocking" + ], + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "beta" + ], + "default": "latest", + "description": "Dev Proxy version to install: 'latest', 'beta', or a specific version like '2.2.0' or 'v2.2.0'." + }, + "trustCertificate": { + "type": "boolean", + "default": true, + "description": "After the container is created, generate a Dev Proxy root certificate unique to the container and trust it system-wide and for common runtimes." + } + }, + "containerEnv": { + "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt" + }, + "customizations": { + "vscode": { + "extensions": [ + "garrytrinder.dev-proxy-toolkit" + ] + } + }, + "postCreateCommand": "/usr/local/share/devproxy/post-create.sh", + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/features/src/devproxy/install.sh b/features/src/devproxy/install.sh new file mode 100755 index 00000000..67678911 --- /dev/null +++ b/features/src/devproxy/install.sh @@ -0,0 +1,286 @@ +#!/bin/sh +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Installs Dev Proxy into a dev container. Runs as root at image build time. +# This script intentionally doesn't generate the Dev Proxy root certificate: +# that happens per container in post-create.sh so that the CA private key +# never ends up in an image layer or prebuild. + +# Check for unsupported (musl-based) distros before relying on bash. +if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then + echo "(!) The Dev Proxy Feature doesn't support Alpine or other musl-based images. Use a Debian or Ubuntu-based image instead." >&2 + exit 1 +fi + +if [ -z "${BASH_VERSION:-}" ]; then + if ! command -v bash >/dev/null 2>&1; then + echo "(!) The Dev Proxy Feature requires bash. Use a Debian or Ubuntu-based image." >&2 + exit 1 + fi + exec bash "$0" "$@" +fi + +set -euo pipefail + +VERSION="${VERSION:-latest}" +TRUST_CERTIFICATE="${TRUSTCERTIFICATE:-true}" + +INSTALL_DIR=/opt/devproxy +SHARE_DIR=/usr/local/share/devproxy +BIN_DIR=/usr/local/bin +TRUST_HELPER="${SHARE_DIR}/trust-cert.sh" +SUDOERS_FILE=/etc/sudoers.d/devproxy +REPO_URL="https://github.com/dotnet/dev-proxy" +FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fail() { + echo "(!) $*" >&2 + exit 1 +} + +if [ "$(id -u)" -ne 0 ]; then + fail "The Dev Proxy Feature install script must run as root." +fi + +if [ "$(uname -s)" != "Linux" ]; then + fail "The Dev Proxy Feature supports only Linux containers." +fi + +if ! command -v apt-get >/dev/null 2>&1; then + fail "The Dev Proxy Feature supports only Debian and Ubuntu-based images (apt-get not found)." +fi + +case "$(uname -m)" in + x86_64 | amd64) ARCH=x64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) fail "Unsupported architecture $(uname -m). Dev Proxy supports x64 and arm64." ;; +esac + +REMOTE_USER="${_REMOTE_USER:-root}" +if ! id "${REMOTE_USER}" >/dev/null 2>&1; then + echo "User '${REMOTE_USER}' doesn't exist. Using root instead." + REMOTE_USER=root +fi +REMOTE_USER_HOME="$(getent passwd "${REMOTE_USER}" | cut -d: -f6)" +REMOTE_USER_GROUP="$(id -gn "${REMOTE_USER}")" + +# --- Dependencies ----------------------------------------------------------- + +APT_UPDATED=false +apt_update() { + if [ "${APT_UPDATED}" = false ]; then + echo "Updating package lists..." + apt-get update -y + APT_UPDATED=true + fi +} + +apt_install() { + local missing=() + local pkg + for pkg in "$@"; do + if ! dpkg -s "${pkg}" >/dev/null 2>&1; then + missing+=("${pkg}") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + apt_update + echo "Installing ${missing[*]}..." + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${missing[@]}" + fi +} + +install_dependencies() { + local packages=(curl ca-certificates unzip openssl) + if [ "${TRUST_CERTIFICATE}" = true ] && [ "${REMOTE_USER}" != root ]; then + packages+=(sudo) + fi + apt_install "${packages[@]}" + + # Dev Proxy is a .NET app and needs ICU for globalization support + if ! ldconfig -p 2>/dev/null | grep -q 'libicuuc\.so'; then + apt_update + local icu_package + icu_package="$(apt-cache pkgnames libicu 2>/dev/null | grep -E '^libicu[0-9]+$' | sort -V | tail -n 1 || true)" + if [ -z "${icu_package}" ]; then + fail "Couldn't find a libicu package to install. Dev Proxy requires ICU." + fi + apt_install "${icu_package}" + fi +} + +# --- Dev Proxy -------------------------------------------------------------- + +download_url() { + echo "${REPO_URL}/releases/download/$1/dev-proxy-linux-${ARCH}-$1.zip" +} + +resolve_version() { + local tag + case "${VERSION}" in + "" | latest) + echo "Resolving the latest Dev Proxy version..." >&2 + tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "${REPO_URL}/releases/latest")" + tag="${tag##*/}" + ;; + beta) + echo "Resolving the latest Dev Proxy beta version..." >&2 + # A tag can show up before its release assets are uploaded, so + # pick the newest beta that has a download for this architecture. + local candidates candidate + candidates="$(curl -fsSL "${REPO_URL}/releases.atom" | grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+' | sed 's|.*/||' || true)" + if [ -z "${candidates}" ]; then + candidates="$(curl -fsSL "https://api.github.com/repos/dotnet/dev-proxy/releases?per_page=50" | grep -oE '"tag_name": *"v[^"]+-beta[^"]*"' | sed -E 's/.*"(v[^"]+)"$/\1/' || true)" + fi + tag="" + for candidate in ${candidates}; do + if curl -fsSLI -o /dev/null "$(download_url "${candidate}")"; then + tag="${candidate}" + break + fi + done + ;; + v*) tag="${VERSION}" ;; + *) tag="v${VERSION}" ;; + esac + + if ! [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + fail "Couldn't resolve Dev Proxy version '${VERSION}'. Use 'latest', 'beta', or a version like '2.2.0'." + fi + echo "${tag}" +} + +install_devproxy() { + local tag="$1" + local version_file="${INSTALL_DIR}/.feature-version" + + if [ -f "${version_file}" ] && [ "$(cat "${version_file}")" = "${tag}" ]; then + echo "Dev Proxy ${tag} is already installed in ${INSTALL_DIR}." + return + fi + + local url + url="$(download_url "${tag}")" + local tmp_dir + tmp_dir="$(mktemp -d)" + + echo "Downloading Dev Proxy ${tag} (linux-${ARCH})..." + if ! curl -fsSL --retry 3 -o "${tmp_dir}/devproxy.zip" "${url}"; then + rm -rf "${tmp_dir}" + fail "Couldn't download Dev Proxy ${tag} from ${url}. Check that the version exists." + fi + + unzip -q "${tmp_dir}/devproxy.zip" -d "${tmp_dir}/devproxy" + rm -rf "${INSTALL_DIR}" + mkdir -p "$(dirname "${INSTALL_DIR}")" + mv "${tmp_dir}/devproxy" "${INSTALL_DIR}" + rm -rf "${tmp_dir}" + echo "${tag}" > "${version_file}" +} + +link_devproxy() { + local binary=devproxy + if [ -f "${INSTALL_DIR}/devproxy-beta" ]; then + binary=devproxy-beta + fi + if [ ! -f "${INSTALL_DIR}/${binary}" ]; then + fail "Couldn't find the Dev Proxy executable in ${INSTALL_DIR}." + fi + + chmod -R a+rX "${INSTALL_DIR}" + chmod +x "${INSTALL_DIR}/${binary}" + if [ -f "${INSTALL_DIR}/libe_sqlite3.so" ]; then + chmod +x "${INSTALL_DIR}/libe_sqlite3.so" + fi + + rm -f "${BIN_DIR}/devproxy" "${BIN_DIR}/devproxy-beta" + ln -s "${INSTALL_DIR}/${binary}" "${BIN_DIR}/devproxy" + if [ "${binary}" = devproxy-beta ]; then + ln -s "${INSTALL_DIR}/${binary}" "${BIN_DIR}/devproxy-beta" + fi +} + +verify_devproxy() { + # Some versions create a root certificate on any run. Use a throwaway home + # so that no state (like a root certificate) ends up in the image. .NET + # resolves the config folder to an empty (relative) path if it doesn't + # exist, so create it and run from the throwaway home. + local tmp_home + tmp_home="$(mktemp -d)" + mkdir -p "${tmp_home}/.config" "${tmp_home}/.local/share" + local installed_version + if ! installed_version="$(cd "${tmp_home}" && HOME="${tmp_home}" XDG_CONFIG_HOME="${tmp_home}/.config" XDG_DATA_HOME="${tmp_home}/.local/share" "${BIN_DIR}/devproxy" --version /dev/null | tail -n 1)"; then + rm -rf "${tmp_home}" + fail "Dev Proxy was installed but couldn't run. Run 'devproxy --version' in the container for details." + fi + rm -rf "${tmp_home}" + echo "Installed Dev Proxy ${installed_version}" +} + +# --- Remote user and certificate trust -------------------------------------- + +prepare_config_dir() { + if [ -z "${REMOTE_USER_HOME}" ] || [ ! -d "${REMOTE_USER_HOME}" ]; then + echo "Home directory for '${REMOTE_USER}' not found. Skipping config directory setup." + return + fi + + local config_root="${REMOTE_USER_HOME}/.config" + if [ ! -d "${config_root}" ]; then + mkdir -p "${config_root}" + chown "${REMOTE_USER}:${REMOTE_USER_GROUP}" "${config_root}" + fi + mkdir -p "${config_root}/dev-proxy" + chown -R "${REMOTE_USER}:${REMOTE_USER_GROUP}" "${config_root}/dev-proxy" +} + +install_scripts() { + mkdir -p "${SHARE_DIR}" + install -o root -g root -m 0755 "${FEATURE_DIR}/scripts/post-create.sh" "${SHARE_DIR}/post-create.sh" + cat > "${SHARE_DIR}/feature.env" < "${SUDOERS_FILE}" + chmod 0440 "${SUDOERS_FILE}" + if command -v visudo >/dev/null 2>&1 && ! visudo -cf "${SUDOERS_FILE}" >/dev/null; then + rm -f "${SUDOERS_FILE}" + fail "Couldn't configure sudo for the Dev Proxy certificate helper." + fi + fi +} + +# --- Main ------------------------------------------------------------------- + +echo "Installing Dev Proxy (version: ${VERSION}, trustCertificate: ${TRUST_CERTIFICATE}, user: ${REMOTE_USER})..." + +install_dependencies +TAG="$(resolve_version)" +install_devproxy "${TAG}" +link_devproxy +verify_devproxy +prepare_config_dir +install_scripts + +if [ "${APT_UPDATED}" = true ]; then + rm -rf /var/lib/apt/lists/* +fi + +echo "Done. Dev Proxy is available as 'devproxy'." +if [ "${TRUST_CERTIFICATE}" = true ]; then + echo "The Dev Proxy root certificate will be generated and trusted after the container is created." +fi diff --git a/features/src/devproxy/scripts/post-create.sh b/features/src/devproxy/scripts/post-create.sh new file mode 100755 index 00000000..a747462d --- /dev/null +++ b/features/src/devproxy/scripts/post-create.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Runs as the remote user after the container is created (postCreateCommand). +# Generates a Dev Proxy root certificate unique to this container and trusts +# it system-wide. Never prompts for input. + +set -euo pipefail + +SHARE_DIR=/usr/local/share/devproxy +TRUST_HELPER="${SHARE_DIR}/trust-cert.sh" + +DEVPROXY_FEATURE_TRUST_CERTIFICATE=false +if [ -f "${SHARE_DIR}/feature.env" ]; then + # shellcheck source=/dev/null + . "${SHARE_DIR}/feature.env" +fi + +if [ "${DEVPROXY_FEATURE_TRUST_CERTIFICATE}" != true ]; then + exit 0 +fi + +echo "Setting up the Dev Proxy root certificate..." + +config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/dev-proxy" +# .NET resolves the config folder to a relative path if it doesn't exist, +# which would put the certificate in the current (workspace) folder. +mkdir -p "${config_dir}" +cd "${config_dir}" + +if ! devproxy cert ensure &2 + exit 1 +fi + +cert_path="${DEV_PROXY_CERT_PATH:-rootCert.pfx}" +if [[ "${cert_path}" != /* ]]; then + cert_path="${config_dir}/${cert_path}" +fi + +if [ ! -f "${cert_path}" ]; then + echo "(!) Dev Proxy root certificate not found at ${cert_path}." >&2 + exit 1 +fi + +if ! pem="$(openssl pkcs12 -in "${cert_path}" -clcerts -nokeys -passin pass: 2>/dev/null | openssl x509 -outform PEM 2>/dev/null)"; then + if ! pem="$(openssl pkcs12 -legacy -in "${cert_path}" -clcerts -nokeys -passin pass: 2>/dev/null | openssl x509 -outform PEM 2>/dev/null)"; then + echo "(!) Couldn't export the Dev Proxy root certificate from ${cert_path}." >&2 + exit 1 + fi +fi + +if [ "$(id -u)" -eq 0 ]; then + printf '%s\n' "${pem}" | "${TRUST_HELPER}" +else + printf '%s\n' "${pem}" | sudo -n "${TRUST_HELPER}" +fi diff --git a/features/src/devproxy/scripts/trust-cert.sh b/features/src/devproxy/scripts/trust-cert.sh new file mode 100755 index 00000000..57958021 --- /dev/null +++ b/features/src/devproxy/scripts/trust-cert.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Adds the Dev Proxy root certificate to the system certificate store. +# Runs as root (the remote user can run it through sudo without a password). +# Reads a PEM-encoded certificate from stdin so that root never reads files +# from user-controlled paths, and only accepts a single CA certificate. + +set -euo pipefail + +CERT_FILE=/usr/local/share/ca-certificates/devproxy-ca.crt + +if [ "$(id -u)" -ne 0 ]; then + echo "(!) This script must run as root." >&2 + exit 1 +fi + +input="$(head -c 65536)" +if [ -z "${input}" ]; then + echo "(!) Pipe the Dev Proxy root certificate (PEM) to this script." >&2 + exit 1 +fi + +if ! pem="$(printf '%s\n' "${input}" | openssl x509 -outform PEM 2>/dev/null)"; then + echo "(!) The input isn't a valid PEM-encoded certificate." >&2 + exit 1 +fi + +if ! printf '%s\n' "${pem}" | openssl x509 -noout -text 2>/dev/null | grep -q 'CA:TRUE'; then + echo "(!) The certificate isn't a CA certificate." >&2 + exit 1 +fi + +mkdir -p "$(dirname "${CERT_FILE}")" +tmp_file="$(mktemp "${CERT_FILE}.XXXXXX")" +printf '%s\n' "${pem}" > "${tmp_file}" +chmod 0644 "${tmp_file}" +mv -f "${tmp_file}" "${CERT_FILE}" + +if ! output="$(update-ca-certificates 2>&1)"; then + echo "${output}" >&2 + echo "(!) Couldn't update the system certificate store." >&2 + exit 1 +fi +echo "Dev Proxy root certificate added to the system certificate store." diff --git a/features/test/devproxy/beta.sh b/features/test/devproxy/beta.sh new file mode 100755 index 00000000..c0bdf644 --- /dev/null +++ b/features/test/devproxy/beta.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Latest beta version. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "installs a beta version" bash -c "devproxy --version | grep -q -- '-beta'" +check "devproxy-beta is on PATH" bash -c "command -v devproxy-beta" +check "devproxy and devproxy-beta point to the same executable" bash -c "[ \"\$(readlink -f \"\$(command -v devproxy)\")\" = \"\$(readlink -f \"\$(command -v devproxy-beta)\")\" ]" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/features/test/devproxy/common.sh b/features/test/devproxy/common.sh new file mode 100755 index 00000000..ca73a86c --- /dev/null +++ b/features/test/devproxy/common.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Shared helpers for the Dev Proxy Feature tests. Source after +# dev-container-features-test-lib. + +DEVPROXY_TEST_PORT="${DEVPROXY_TEST_PORT:-8000}" +DEVPROXY_TEST_API_PORT="${DEVPROXY_TEST_API_PORT:-8897}" +DEVPROXY_TEST_URL="https://devproxy-feature.test/hello" +DEVPROXY_TEST_BODY="hello-from-dev-proxy" +DEVPROXY_TEST_PROXY="http://127.0.0.1:${DEVPROXY_TEST_PORT}" +DEVPROXY_TEST_CERT=/usr/local/share/ca-certificates/devproxy-ca.crt +DEVPROXY_TEST_BUNDLE=/etc/ssl/certs/ca-certificates.crt + +devproxy_config_dir() { + echo "${XDG_CONFIG_HOME:-${HOME}/.config}/dev-proxy" +} + +# Checks that the Dev Proxy root certificate in the user's profile is the one +# trusted in the system store. +devproxy_cert_matches_profile() { + local pfx + pfx="$(devproxy_config_dir)/rootCert.pfx" + [ -f "${pfx}" ] || return 1 + local profile_fp system_fp + profile_fp="$(openssl pkcs12 -in "${pfx}" -clcerts -nokeys -passin pass: 2>/dev/null | openssl x509 -noout -fingerprint -sha256)" + system_fp="$(openssl x509 -in "${DEVPROXY_TEST_CERT}" -noout -fingerprint -sha256)" + [ -n "${profile_fp}" ] && [ "${profile_fp}" = "${system_fp}" ] +} + +# Checks that the certificate is part of the system CA bundle. +devproxy_cert_in_bundle() { + local cert_body + cert_body="$(sed -n '2p' "${DEVPROXY_TEST_CERT}")" + grep -qF "${cert_body}" "${DEVPROXY_TEST_BUNDLE}" +} + +# Checks that no Dev Proxy root certificate exists outside of the given home +# directory (e.g. in root's profile or the install directory). +devproxy_no_stray_certs() { + local allowed_dir="${1:-}" + local found + found="$(find / -xdev -name 'rootCert.pfx' -not -path '/proc/*' 2>/dev/null || true)" + if [ -n "${allowed_dir}" ]; then + found="$(echo "${found}" | grep -v "^${allowed_dir}/" || true)" + fi + [ -z "${found}" ] +} + +# Starts Dev Proxy with a mock that answers DEVPROXY_TEST_URL so that tests +# don't depend on external services. +devproxy_start() { + DEVPROXY_TEST_DIR="$(mktemp -d)" + cat > "${DEVPROXY_TEST_DIR}/mocks.json" < "${DEVPROXY_TEST_DIR}/devproxyrc.json" </dev/null + devproxy --config-file devproxyrc.json --as-system-proxy false "${DEVPROXY_TEST_DIR}/devproxy.log" 2>&1 & + DEVPROXY_TEST_PID=$! + popd >/dev/null + + local i + for i in $(seq 1 60); do + if curl -s -o /dev/null "http://127.0.0.1:${DEVPROXY_TEST_API_PORT}/proxy"; then + return 0 + fi + sleep 1 + done + echo "Dev Proxy didn't start. Log:" >&2 + cat "${DEVPROXY_TEST_DIR}/devproxy.log" >&2 + return 1 +} + +devproxy_stop() { + if [ -n "${DEVPROXY_TEST_PID:-}" ]; then + kill "${DEVPROXY_TEST_PID}" 2>/dev/null || true + wait "${DEVPROXY_TEST_PID}" 2>/dev/null || true + fi + rm -rf "${DEVPROXY_TEST_DIR:-}" +} + +# Checks that a response body is the mocked one. Some Dev Proxy versions +# return the mocked string body JSON-encoded. +devproxy_is_mock_body() { + [ "$(echo "$1" | tr -d '"[:space:]')" = "${DEVPROXY_TEST_BODY}" ] +} + +# Requests through Dev Proxy don't disable certificate validation, so they +# only succeed if the Dev Proxy root certificate is trusted. +devproxy_curl() { + devproxy_is_mock_body "$(curl -sS --fail -x "${DEVPROXY_TEST_PROXY}" "${DEVPROXY_TEST_URL}")" +} diff --git a/features/test/devproxy/duplicate.sh b/features/test/devproxy/duplicate.sh new file mode 100755 index 00000000..e1e4812b --- /dev/null +++ b/features/test/devproxy/duplicate.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# The Feature installed twice with different options. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "devproxy runs" devproxy --version +check "a single Dev Proxy installation" bash -c "[ \"\$(ls /opt | grep -c devproxy)\" = 1 ]" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/features/test/devproxy/no_trust_certificate.sh b/features/test/devproxy/no_trust_certificate.sh new file mode 100755 index 00000000..2a8d8851 --- /dev/null +++ b/features/test/devproxy/no_trust_certificate.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# trustCertificate disabled: Dev Proxy is installed, but no certificate is +# generated or trusted, and no sudo access is granted. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "devproxy runs" devproxy --version +check "no root certificate is generated" devproxy_no_stray_certs +check "no certificate in the system store" test ! -e "${DEVPROXY_TEST_CERT}" +check "certificate helper isn't installed" test ! -e /usr/local/share/devproxy/trust-cert.sh +check "no sudo rule is added" test ! -e /etc/sudoers.d/devproxy + +reportResults diff --git a/features/test/devproxy/no_trust_certificate/Dockerfile b/features/test/devproxy/no_trust_certificate/Dockerfile new file mode 100644 index 00000000..e7b3a543 --- /dev/null +++ b/features/test/devproxy/no_trust_certificate/Dockerfile @@ -0,0 +1,4 @@ +# Debian image with a non-root user that has no sudo access, to verify that +# the Feature only grants the permissions it needs. +FROM debian:latest +RUN useradd -m -s /bin/bash devuser diff --git a/features/test/devproxy/non_root_user.sh b/features/test/devproxy/non_root_user.sh new file mode 100755 index 00000000..56b5d251 --- /dev/null +++ b/features/test/devproxy/non_root_user.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Non-root remote user without sudo access. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "runs as devuser" bash -c "[ \"\$(id -un)\" = devuser ]" +check "devproxy runs" devproxy --version +check "config folder is owned by the user" bash -c "[ \"\$(stat -c %U \"$(devproxy_config_dir)\")\" = devuser ]" +check "user can't run arbitrary commands with sudo" bash -c "! sudo -n true 2>/dev/null" +check "user can run the certificate helper with sudo" bash -c "sudo -n -l /usr/local/share/devproxy/trust-cert.sh" +check "certificate helper is owned by root" bash -c "[ \"\$(stat -c %U:%a /usr/local/share/devproxy/trust-cert.sh)\" = root:755 ]" +check "certificate helper rejects non-certificates" bash -c "! echo not-a-cert | sudo -n /usr/local/share/devproxy/trust-cert.sh" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/features/test/devproxy/non_root_user/Dockerfile b/features/test/devproxy/non_root_user/Dockerfile new file mode 100644 index 00000000..e7b3a543 --- /dev/null +++ b/features/test/devproxy/non_root_user/Dockerfile @@ -0,0 +1,4 @@ +# Debian image with a non-root user that has no sudo access, to verify that +# the Feature only grants the permissions it needs. +FROM debian:latest +RUN useradd -m -s /bin/bash devuser diff --git a/features/test/devproxy/runtimes.sh b/features/test/devproxy/runtimes.sh new file mode 100755 index 00000000..b6d6bb12 --- /dev/null +++ b/features/test/devproxy/runtimes.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Node.js, Python, and .NET apps trust Dev Proxy without extra configuration. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +node_request() { + local body + # NODE_USE_ENV_PROXY makes fetch use HTTPS_PROXY (Node.js 24+). Node.js + # doesn't use the system store, so this relies on NODE_EXTRA_CA_CERTS. + body="$(NODE_USE_ENV_PROXY=1 HTTPS_PROXY="${DEVPROXY_TEST_PROXY}" node -e ' +fetch(process.env.DEVPROXY_TEST_URL) + .then(r => r.text()) + .then(t => console.log(t)) + .catch(e => { console.error(String(e.cause || e)); process.exit(1); }); +')" + devproxy_is_mock_body "${body}" +} + +python_urllib_request() { + local body + body="$(python3 - <<'PYTHON' +import os, urllib.request +proxy = os.environ["DEVPROXY_TEST_PROXY"] +opener = urllib.request.build_opener(urllib.request.ProxyHandler({"https": proxy})) +print(opener.open(os.environ["DEVPROXY_TEST_URL"]).read().decode()) +PYTHON +)" + devproxy_is_mock_body "${body}" +} + +python_requests_request() { + local body + body="$(/tmp/devproxy-venv/bin/python - <<'PYTHON' +import os, requests +proxy = os.environ["DEVPROXY_TEST_PROXY"] +print(requests.get(os.environ["DEVPROXY_TEST_URL"], proxies={"https": proxy}).text) +PYTHON +)" + devproxy_is_mock_body "${body}" +} + +dotnet_request() { + local body + body="$(cd /tmp/devproxy-dotnet && HTTPS_PROXY="${DEVPROXY_TEST_PROXY}" dotnet run --no-build app.cs)" + devproxy_is_mock_body "${body}" +} + +export DEVPROXY_TEST_URL DEVPROXY_TEST_PROXY DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 + +# Prepare the clients before starting Dev Proxy +python3 -m venv /tmp/devproxy-venv +/tmp/devproxy-venv/bin/pip install -q requests +mkdir -p /tmp/devproxy-dotnet +cat > /tmp/devproxy-dotnet/app.cs <<'CSHARP' +using var client = new HttpClient(); +Console.WriteLine(await client.GetStringAsync(Environment.GetEnvironmentVariable("DEVPROXY_TEST_URL"))); +CSHARP +(cd /tmp/devproxy-dotnet && dotnet build app.cs >/dev/null) + +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +check "Node.js trusts Dev Proxy" node_request +check "Python (urllib) trusts Dev Proxy" python_urllib_request +check "Python (requests) trusts Dev Proxy" python_requests_request +check ".NET trusts Dev Proxy" dotnet_request +devproxy_stop + +reportResults diff --git a/features/test/devproxy/scenarios.json b/features/test/devproxy/scenarios.json new file mode 100644 index 00000000..3e247da9 --- /dev/null +++ b/features/test/devproxy/scenarios.json @@ -0,0 +1,61 @@ +{ + "non_root_user": { + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "devuser", + "features": { + "devproxy": {} + } + }, + "specific_version": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "vscode", + "features": { + "devproxy": { + "version": "3.3.0" + } + } + }, + "specific_version_with_v": { + "image": "debian:latest", + "features": { + "devproxy": { + "version": "v2.2.0" + } + } + }, + "beta": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "remoteUser": "vscode", + "features": { + "devproxy": { + "version": "beta" + } + } + }, + "no_trust_certificate": { + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "devuser", + "features": { + "devproxy": { + "trustCertificate": false + } + } + }, + "runtimes": { + "image": "mcr.microsoft.com/devcontainers/python:3", + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "10.0" + }, + "devproxy": { + "version": "beta" + } + } + } +} diff --git a/features/test/devproxy/specific_version.sh b/features/test/devproxy/specific_version.sh new file mode 100755 index 00000000..04a7fcbc --- /dev/null +++ b/features/test/devproxy/specific_version.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Specific version without the 'v' prefix. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "installs Dev Proxy 3.3.0" bash -c "devproxy --version | grep -q '^3.3.0'" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/features/test/devproxy/specific_version_with_v.sh b/features/test/devproxy/specific_version_with_v.sh new file mode 100755 index 00000000..6dc0b92b --- /dev/null +++ b/features/test/devproxy/specific_version_with_v.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Specific version with the 'v' prefix, as root. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "installs Dev Proxy 2.2.0" bash -c "devproxy --version | grep -q '^2.2.0'" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/features/test/devproxy/test.sh b/features/test/devproxy/test.sh new file mode 100755 index 00000000..5a77ec0c --- /dev/null +++ b/features/test/devproxy/test.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +# Default options, run against several base images. + +set -e + +source dev-container-features-test-lib +source "$(dirname "$0")/common.sh" + +check "devproxy is on PATH" bash -c "command -v devproxy" +check "devproxy runs" devproxy --version +check "devproxy is installed in /opt/devproxy" test -L /usr/local/bin/devproxy -a -d /opt/devproxy +check "config folder is owned by the user" bash -c "[ \"\$(stat -c %U \"$(devproxy_config_dir)\")\" = \"\$(id -un)\" ]" +check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" +check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile +check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" +check "NODE_EXTRA_CA_CERTS is set" test -f "${NODE_EXTRA_CA_CERTS}" +check "SSL_CERT_FILE is set" test -f "${SSL_CERT_FILE}" +check "REQUESTS_CA_BUNDLE is set" test -f "${REQUESTS_CA_BUNDLE}" + +check "start Dev Proxy" devproxy_start +check "curl trusts Dev Proxy" devproxy_curl +devproxy_stop + +reportResults diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index d6c04631..80510199 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -58,6 +58,18 @@ Or with PowerShell: ``` 3. Reload: `source ~/.bashrc` +### Dev Containers and GitHub Codespaces + +Add the Dev Proxy Feature to `devcontainer.json`: + +```json +"features": { + "ghcr.io/dotnet/dev-proxy/devproxy:1": {} +} +``` + +The Feature installs Dev Proxy, and after the container is created, generates a root certificate unique to the container and trusts it system-wide (including Node.js and Python). No first run setup is needed. Use the `version` option to install a specific version (`"version": "4.0.0"`) or the latest beta (`"version": "beta"`). + ### Install a Specific Version Pass the version to the setup script: From bad7d90e77a1265d2d27cf2c77906b83bf843878 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 11:44:51 +0100 Subject: [PATCH 2/3] Harden Dev Container Feature certificate handling - Pin DEV_PROXY_CERT_PATH to the throwaway home during the build-time version check so an inherited absolute path can't leave a CA in the image - Generate the root certificate with umask 077 and restrict it to 0600 - Scan for stray certificates as root when possible and fail on incomplete scans Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- features/src/devproxy/install.sh | 2 +- features/src/devproxy/scripts/post-create.sh | 7 ++++++ features/test/devproxy/common.sh | 23 +++++++++++++++++--- features/test/devproxy/test.sh | 1 + 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/features/src/devproxy/install.sh b/features/src/devproxy/install.sh index 67678911..d19313d6 100755 --- a/features/src/devproxy/install.sh +++ b/features/src/devproxy/install.sh @@ -211,7 +211,7 @@ verify_devproxy() { tmp_home="$(mktemp -d)" mkdir -p "${tmp_home}/.config" "${tmp_home}/.local/share" local installed_version - if ! installed_version="$(cd "${tmp_home}" && HOME="${tmp_home}" XDG_CONFIG_HOME="${tmp_home}/.config" XDG_DATA_HOME="${tmp_home}/.local/share" "${BIN_DIR}/devproxy" --version /dev/null | tail -n 1)"; then + if ! installed_version="$(cd "${tmp_home}" && HOME="${tmp_home}" XDG_CONFIG_HOME="${tmp_home}/.config" XDG_DATA_HOME="${tmp_home}/.local/share" DEV_PROXY_CERT_PATH="${tmp_home}/rootCert.pfx" "${BIN_DIR}/devproxy" --version /dev/null | tail -n 1)"; then rm -rf "${tmp_home}" fail "Dev Proxy was installed but couldn't run. Run 'devproxy --version' in the container for details." fi diff --git a/features/src/devproxy/scripts/post-create.sh b/features/src/devproxy/scripts/post-create.sh index a747462d..a791f94f 100755 --- a/features/src/devproxy/scripts/post-create.sh +++ b/features/src/devproxy/scripts/post-create.sh @@ -30,6 +30,10 @@ config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/dev-proxy" mkdir -p "${config_dir}" cd "${config_dir}" +# The root certificate's private key is stored without a password. Keep it +# readable only by the remote user. +umask 077 + if ! devproxy cert ensure &2 exit 1 @@ -44,6 +48,9 @@ if [ ! -f "${cert_path}" ]; then echo "(!) Dev Proxy root certificate not found at ${cert_path}." >&2 exit 1 fi +if [ -O "${cert_path}" ]; then + chmod 0600 "${cert_path}" +fi if ! pem="$(openssl pkcs12 -in "${cert_path}" -clcerts -nokeys -passin pass: 2>/dev/null | openssl x509 -outform PEM 2>/dev/null)"; then if ! pem="$(openssl pkcs12 -legacy -in "${cert_path}" -clcerts -nokeys -passin pass: 2>/dev/null | openssl x509 -outform PEM 2>/dev/null)"; then diff --git a/features/test/devproxy/common.sh b/features/test/devproxy/common.sh index ca73a86c..697dbf13 100755 --- a/features/test/devproxy/common.sh +++ b/features/test/devproxy/common.sh @@ -38,15 +38,32 @@ devproxy_cert_in_bundle() { } # Checks that no Dev Proxy root certificate exists outside of the given home -# directory (e.g. in root's profile or the install directory). +# directory (e.g. in root's profile or the install directory). Scans as root +# when possible and fails if the scan is incomplete. Users without root access +# can't read root-only folders like /root; root-user scenarios cover those. devproxy_no_stray_certs() { local allowed_dir="${1:-}" + local -a run_as=() + if [ "$(id -u)" -ne 0 ]; then + if sudo -n true 2>/dev/null; then + run_as=(sudo -n) + else + echo "(!) No root access. Root-only folders aren't scanned." >&2 + fi + fi local found - found="$(find / -xdev -name 'rootCert.pfx' -not -path '/proc/*' 2>/dev/null || true)" + if [ "${#run_as[@]}" -gt 0 ] || [ "$(id -u)" -eq 0 ]; then + found="$("${run_as[@]}" find / -xdev -name 'rootCert.pfx' -not -path '/proc/*')" || return 1 + else + found="$(find / -xdev -name 'rootCert.pfx' -not -path '/proc/*' 2>/dev/null || true)" + fi if [ -n "${allowed_dir}" ]; then found="$(echo "${found}" | grep -v "^${allowed_dir}/" || true)" fi - [ -z "${found}" ] + if [ -n "${found}" ]; then + echo "Found certificates: ${found}" >&2 + return 1 + fi } # Starts Dev Proxy with a mock that answers DEVPROXY_TEST_URL so that tests diff --git a/features/test/devproxy/test.sh b/features/test/devproxy/test.sh index 5a77ec0c..9ff906b6 100755 --- a/features/test/devproxy/test.sh +++ b/features/test/devproxy/test.sh @@ -17,6 +17,7 @@ check "config folder is owned by the user" bash -c "[ \"\$(stat -c %U \"$(devpro check "root certificate is in the system store" test -f "${DEVPROXY_TEST_CERT}" check "trusted certificate is the user's root certificate" devproxy_cert_matches_profile check "root certificate is in the CA bundle" devproxy_cert_in_bundle +check "root certificate is only readable by the user" bash -c "[ \"\$(stat -c %a \"$(devproxy_config_dir)/rootCert.pfx\")\" = 600 ]" check "no certificate outside the user's profile" devproxy_no_stray_certs "$(devproxy_config_dir)" check "NODE_EXTRA_CA_CERTS is set" test -f "${NODE_EXTRA_CA_CERTS}" check "SSL_CERT_FILE is set" test -f "${SSL_CERT_FILE}" From 246cc6d618bcf49e343f3a705378f90dcb571514 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 14:27:48 +0100 Subject: [PATCH 3/3] Rename Dev Container Feature ID to dev-proxy Publishes the Feature as ghcr.io/dotnet/dev-proxy/dev-proxy so the name is spelled consistently with the repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- .github/workflows/devcontainer-feature-release.yml | 2 +- .github/workflows/devcontainer-feature-test.yml | 4 ++-- README.md | 4 ++-- features/src/{devproxy => dev-proxy}/README.md | 4 ++-- .../devcontainer-feature.json | 4 ++-- features/src/{devproxy => dev-proxy}/install.sh | 0 .../{devproxy => dev-proxy}/scripts/post-create.sh | 0 .../{devproxy => dev-proxy}/scripts/trust-cert.sh | 0 features/test/{devproxy => dev-proxy}/beta.sh | 0 features/test/{devproxy => dev-proxy}/common.sh | 0 features/test/{devproxy => dev-proxy}/duplicate.sh | 0 .../{devproxy => dev-proxy}/no_trust_certificate.sh | 0 .../no_trust_certificate/Dockerfile | 0 .../test/{devproxy => dev-proxy}/non_root_user.sh | 0 .../{devproxy => dev-proxy}/non_root_user/Dockerfile | 0 features/test/{devproxy => dev-proxy}/runtimes.sh | 0 features/test/{devproxy => dev-proxy}/scenarios.json | 12 ++++++------ .../test/{devproxy => dev-proxy}/specific_version.sh | 0 .../specific_version_with_v.sh | 0 features/test/{devproxy => dev-proxy}/test.sh | 0 skills/dev-proxy/references/installation.md | 2 +- 22 files changed, 17 insertions(+), 17 deletions(-) rename features/src/{devproxy => dev-proxy}/README.md (98%) rename features/src/{devproxy => dev-proxy}/devcontainer-feature.json (96%) rename features/src/{devproxy => dev-proxy}/install.sh (100%) rename features/src/{devproxy => dev-proxy}/scripts/post-create.sh (100%) rename features/src/{devproxy => dev-proxy}/scripts/trust-cert.sh (100%) rename features/test/{devproxy => dev-proxy}/beta.sh (100%) rename features/test/{devproxy => dev-proxy}/common.sh (100%) rename features/test/{devproxy => dev-proxy}/duplicate.sh (100%) rename features/test/{devproxy => dev-proxy}/no_trust_certificate.sh (100%) rename features/test/{devproxy => dev-proxy}/no_trust_certificate/Dockerfile (100%) rename features/test/{devproxy => dev-proxy}/non_root_user.sh (100%) rename features/test/{devproxy => dev-proxy}/non_root_user/Dockerfile (100%) rename features/test/{devproxy => dev-proxy}/runtimes.sh (100%) rename features/test/{devproxy => dev-proxy}/scenarios.json (88%) rename features/test/{devproxy => dev-proxy}/specific_version.sh (100%) rename features/test/{devproxy => dev-proxy}/specific_version_with_v.sh (100%) rename features/test/{devproxy => dev-proxy}/test.sh (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9cc4aee4..bccc5af9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,7 +60,7 @@ dotnet run --project DevProxy -- --detach --as-system-proxy false --port 0 --api - **CLI Commands:** `DevProxy/Commands/` — built with System.CommandLine, registered via `DevProxyCommand.cs` - **Configuration:** `DevProxy/config/devproxyrc.json` and other JSON files in `config/` - **macOS scripts:** `trust-cert.sh`, `remove-cert.sh`, `toggle-proxy.sh` in `DevProxy/` -- **Dev Container Feature:** `features/src/devproxy` (published as `ghcr.io/dotnet/dev-proxy/devproxy`), tests in `features/test/devproxy`. Run tests from `features/` with `npx @devcontainers/cli features test -f devproxy .`. Bump the version in `devcontainer-feature.json` to publish a new Feature version +- **Dev Container Feature:** `features/src/dev-proxy` (published as `ghcr.io/dotnet/dev-proxy/dev-proxy`), tests in `features/test/dev-proxy`. Run tests from `features/` with `npx @devcontainers/cli features test -f dev-proxy .`. Bump the version in `devcontainer-feature.json` to publish a new Feature version ## Resources diff --git a/.github/workflows/devcontainer-feature-release.yml b/.github/workflows/devcontainer-feature-release.yml index b657aa1a..85faf097 100644 --- a/.github/workflows/devcontainer-feature-release.yml +++ b/.github/workflows/devcontainer-feature-release.yml @@ -1,7 +1,7 @@ name: Dev Container Feature - Release # Publishes the Dev Container Feature in features/src to GHCR -# (ghcr.io/dotnet/dev-proxy/devproxy). To release a new version of the +# (ghcr.io/dotnet/dev-proxy/dev-proxy). To release a new version of the # Feature, bump the version in devcontainer-feature.json. Versions that are # already published are skipped. diff --git a/.github/workflows/devcontainer-feature-test.yml b/.github/workflows/devcontainer-feature-test.yml index 12b172b4..5e47dc71 100644 --- a/.github/workflows/devcontainer-feature-test.yml +++ b/.github/workflows/devcontainer-feature-test.yml @@ -32,7 +32,7 @@ jobs: run: npm install -g @devcontainers/cli@${{ env.DEVCONTAINER_CLI_VERSION }} - name: Test the Feature with default options, installed once and twice working-directory: features - run: devcontainer features test --skip-scenarios -f devproxy -i ${{ matrix.baseImage }} . + run: devcontainer features test --skip-scenarios -f dev-proxy -i ${{ matrix.baseImage }} . test-scenarios: name: Test scenarios (${{ matrix.runner }}) @@ -47,7 +47,7 @@ jobs: run: npm install -g @devcontainers/cli@${{ env.DEVCONTAINER_CLI_VERSION }} - name: Test the Feature scenarios working-directory: features - run: devcontainer features test -f devproxy --skip-autogenerated --skip-duplicated . + run: devcontainer features test -f dev-proxy --skip-autogenerated --skip-duplicated . validate: name: Validate the Feature metadata diff --git a/README.md b/README.md index 28015b08..40cce32a 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ brew install dev-proxy bash -c "$(curl -sL https://aka.ms/devproxy/setup.sh)" ``` -**Dev containers and GitHub Codespaces** (installs Dev Proxy and trusts its certificate in the container, see [the Feature's docs](./features/src/devproxy/README.md)): +**Dev containers and GitHub Codespaces** (installs Dev Proxy and trusts its certificate in the container, see [the Feature's docs](./features/src/dev-proxy/README.md)): ```json "features": { - "ghcr.io/dotnet/dev-proxy/devproxy:1": {} + "ghcr.io/dotnet/dev-proxy/dev-proxy:1": {} } ``` diff --git a/features/src/devproxy/README.md b/features/src/dev-proxy/README.md similarity index 98% rename from features/src/devproxy/README.md rename to features/src/dev-proxy/README.md index 17d114af..68cdbc09 100644 --- a/features/src/devproxy/README.md +++ b/features/src/dev-proxy/README.md @@ -1,4 +1,4 @@ -# Dev Proxy (devproxy) +# Dev Proxy (dev-proxy) Installs [Dev Proxy](https://aka.ms/devproxy), an API simulator that helps you test how your app handles API failures, rate limits, latency, and more. Trusts a per-container Dev Proxy root certificate system-wide. @@ -6,7 +6,7 @@ Installs [Dev Proxy](https://aka.ms/devproxy), an API simulator that helps you t ```json "features": { - "ghcr.io/dotnet/dev-proxy/devproxy:1": {} + "ghcr.io/dotnet/dev-proxy/dev-proxy:1": {} } ``` diff --git a/features/src/devproxy/devcontainer-feature.json b/features/src/dev-proxy/devcontainer-feature.json similarity index 96% rename from features/src/devproxy/devcontainer-feature.json rename to features/src/dev-proxy/devcontainer-feature.json index a5ec9d33..f3346a12 100644 --- a/features/src/devproxy/devcontainer-feature.json +++ b/features/src/dev-proxy/devcontainer-feature.json @@ -1,8 +1,8 @@ { - "id": "devproxy", + "id": "dev-proxy", "version": "1.0.0", "name": "Dev Proxy", - "documentationURL": "https://github.com/dotnet/dev-proxy/tree/main/features/src/devproxy", + "documentationURL": "https://github.com/dotnet/dev-proxy/tree/main/features/src/dev-proxy", "licenseURL": "https://github.com/dotnet/dev-proxy/blob/main/LICENSE", "description": "Installs Dev Proxy, an API simulator that helps you test how your app handles API failures, rate limits, latency, and more. Trusts a per-container Dev Proxy root certificate system-wide.", "keywords": [ diff --git a/features/src/devproxy/install.sh b/features/src/dev-proxy/install.sh similarity index 100% rename from features/src/devproxy/install.sh rename to features/src/dev-proxy/install.sh diff --git a/features/src/devproxy/scripts/post-create.sh b/features/src/dev-proxy/scripts/post-create.sh similarity index 100% rename from features/src/devproxy/scripts/post-create.sh rename to features/src/dev-proxy/scripts/post-create.sh diff --git a/features/src/devproxy/scripts/trust-cert.sh b/features/src/dev-proxy/scripts/trust-cert.sh similarity index 100% rename from features/src/devproxy/scripts/trust-cert.sh rename to features/src/dev-proxy/scripts/trust-cert.sh diff --git a/features/test/devproxy/beta.sh b/features/test/dev-proxy/beta.sh similarity index 100% rename from features/test/devproxy/beta.sh rename to features/test/dev-proxy/beta.sh diff --git a/features/test/devproxy/common.sh b/features/test/dev-proxy/common.sh similarity index 100% rename from features/test/devproxy/common.sh rename to features/test/dev-proxy/common.sh diff --git a/features/test/devproxy/duplicate.sh b/features/test/dev-proxy/duplicate.sh similarity index 100% rename from features/test/devproxy/duplicate.sh rename to features/test/dev-proxy/duplicate.sh diff --git a/features/test/devproxy/no_trust_certificate.sh b/features/test/dev-proxy/no_trust_certificate.sh similarity index 100% rename from features/test/devproxy/no_trust_certificate.sh rename to features/test/dev-proxy/no_trust_certificate.sh diff --git a/features/test/devproxy/no_trust_certificate/Dockerfile b/features/test/dev-proxy/no_trust_certificate/Dockerfile similarity index 100% rename from features/test/devproxy/no_trust_certificate/Dockerfile rename to features/test/dev-proxy/no_trust_certificate/Dockerfile diff --git a/features/test/devproxy/non_root_user.sh b/features/test/dev-proxy/non_root_user.sh similarity index 100% rename from features/test/devproxy/non_root_user.sh rename to features/test/dev-proxy/non_root_user.sh diff --git a/features/test/devproxy/non_root_user/Dockerfile b/features/test/dev-proxy/non_root_user/Dockerfile similarity index 100% rename from features/test/devproxy/non_root_user/Dockerfile rename to features/test/dev-proxy/non_root_user/Dockerfile diff --git a/features/test/devproxy/runtimes.sh b/features/test/dev-proxy/runtimes.sh similarity index 100% rename from features/test/devproxy/runtimes.sh rename to features/test/dev-proxy/runtimes.sh diff --git a/features/test/devproxy/scenarios.json b/features/test/dev-proxy/scenarios.json similarity index 88% rename from features/test/devproxy/scenarios.json rename to features/test/dev-proxy/scenarios.json index 3e247da9..901701b7 100644 --- a/features/test/devproxy/scenarios.json +++ b/features/test/dev-proxy/scenarios.json @@ -5,14 +5,14 @@ }, "remoteUser": "devuser", "features": { - "devproxy": {} + "dev-proxy": {} } }, "specific_version": { "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "remoteUser": "vscode", "features": { - "devproxy": { + "dev-proxy": { "version": "3.3.0" } } @@ -20,7 +20,7 @@ "specific_version_with_v": { "image": "debian:latest", "features": { - "devproxy": { + "dev-proxy": { "version": "v2.2.0" } } @@ -29,7 +29,7 @@ "image": "mcr.microsoft.com/devcontainers/base:debian", "remoteUser": "vscode", "features": { - "devproxy": { + "dev-proxy": { "version": "beta" } } @@ -40,7 +40,7 @@ }, "remoteUser": "devuser", "features": { - "devproxy": { + "dev-proxy": { "trustCertificate": false } } @@ -53,7 +53,7 @@ "ghcr.io/devcontainers/features/dotnet:2": { "version": "10.0" }, - "devproxy": { + "dev-proxy": { "version": "beta" } } diff --git a/features/test/devproxy/specific_version.sh b/features/test/dev-proxy/specific_version.sh similarity index 100% rename from features/test/devproxy/specific_version.sh rename to features/test/dev-proxy/specific_version.sh diff --git a/features/test/devproxy/specific_version_with_v.sh b/features/test/dev-proxy/specific_version_with_v.sh similarity index 100% rename from features/test/devproxy/specific_version_with_v.sh rename to features/test/dev-proxy/specific_version_with_v.sh diff --git a/features/test/devproxy/test.sh b/features/test/dev-proxy/test.sh similarity index 100% rename from features/test/devproxy/test.sh rename to features/test/dev-proxy/test.sh diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index 80510199..fdb02092 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -64,7 +64,7 @@ Add the Dev Proxy Feature to `devcontainer.json`: ```json "features": { - "ghcr.io/dotnet/dev-proxy/devproxy:1": {} + "ghcr.io/dotnet/dev-proxy/dev-proxy:1": {} } ```