- Shell 94.6%
- Dockerfile 5.4%
| app | ||
| haproxy | ||
| .env.example | ||
| .gitignore | ||
| app-firewall.sh | ||
| devcontainer-lock.json | ||
| devcontainer.json | ||
| docker-compose.yml | ||
| install-egress-proxy.sh | ||
| readme.md | ||
| setup-claude-plugins.sh | ||
| setup-nuget.sh | ||
Devcontainer Egress Proxy Setup
This devcontainer routes app traffic through an egress-proxy sidecar. The app container uses dnsmasq in the sidecar as its resolver. dnsmasq resolves every hostname to the sidecar IP, and HAProxy then enforces the outbound allowlist by TLS SNI.
The design goal is not general internet access. The design goal is controlled HTTPS egress from the development container, with a small allowlist and visible logs for both allowed and blocked traffic.
Flow:
app DNS lookup -> dnsmasq -> 10.10.10.10
app HTTPS connection -> HAProxy sidecar
HAProxy reads TLS SNI
HAProxy allows listed SNI values and rejects everything else
Why This Setup Exists
Rootless Podman behaves differently from a normal Docker daemon in a few places that matter for devcontainers:
- The Docker CLI is emulated by Podman, so generated devcontainer image tags can be interpreted differently.
- Compose's
dns:setting works by rewriting/etc/resolv.confafter the container starts, and Podman does not always honor it reliably. - SELinux labeling can prevent the container from reading bind-mounted workspace files unless the mount is labeled correctly.
- Host-owned files can appear as the wrong container owner unless the user namespace preserves the host UID/GID mapping.
The setup uses two layers, and deliberately nothing else:
Compose network isolation: app only joins an internal network
HAProxy enforcement: only allowlisted TLS SNI values are forwarded, everything else rejected
DNS steering (every hostname resolving to the sidecar) is what routes app traffic into HAProxy in the first place, but it isn't a security layer on its own — it's implemented as a bind-mounted, static /etc/resolv.conf (app/resolv.conf) rather than a runtime script, which is also what sidesteps the Podman reliability issue above: the mountpoint is occupied before the container's init process runs, so there's nothing to race.
An earlier version of this setup also ran an in-container iptables firewall as defense in depth, which needed cap_add: NET_ADMIN and a sudo escalation path for the otherwise-unprivileged vscode user to invoke it. Both were removed. The reasoning: a security boundary has to live somewhere the confined process can't reach, no matter how much privilege it gets — and app's own root, capabilities, or sudo grants are all inside the container being confined, so they can never be that boundary. The only enforcement that matters is the internal: true Compose network below, which is applied by the container engine outside app's reach regardless of what runs inside it. Once that's true, an in-container firewall doesn't add security, it only adds a capability (NET_ADMIN inside a user namespace, since userns_mode: keep-id puts this container in one) that's historically been a source of Linux kernel netfilter/nf_tables privilege-escalation bugs — a real cost for close to no benefit. app now runs as a fully unprivileged user: no sudo, no added capabilities, no root at any point in its lifecycle.
Operational Model
Allowed HTTPS egress works like this:
curl https://platform.claude.com
DNS: platform.claude.com -> 10.10.10.10
TCP: app -> 10.10.10.10:443
TLS ClientHello SNI: platform.claude.com
HAProxy allowlist: match
HAProxy resolves real platform.claude.com upstream IP
HAProxy forwards the TCP stream without decrypting TLS
Blocked HTTPS egress works like this:
curl https://example.com
DNS: example.com -> 10.10.10.10
TCP: app -> 10.10.10.10:443
TLS ClientHello SNI: example.com
HAProxy allowlist: no match
HAProxy rejects the connection
Non-HTTPS traffic is not generally supported. SSH passthrough was intentionally removed because SSH does not expose SNI, so HAProxy cannot tell which hostname the app meant to reach after catch-all DNS points everything at the sidecar.
Allowlist Changes
To allow a new HTTPS endpoint, add its hostname to the acl sni_allowed req.ssl_sni -m str -i ... line in haproxy/haproxy.cfg, then rebuild or recreate egress-proxy.
For domain families, prefer suffix matching with a leading dot:
acl sni_allowed req.ssl_sni -m end -i .githubusercontent.com
For single hosts, use exact string matching:
acl sni_allowed req.ssl_sni -m str -i api.anthropic.com mcp-proxy.anthropic.com
Do not use broad suffixes unless the whole domain family is acceptable. For example, allowing .example.com permits every subdomain under example.com.
Logging
HAProxy writes structured TCP egress logs to stdout. Allowed and denied connections are visible in the proxy container logs.
Useful log command:
podman logs company_devcontainer-egress-proxy-1 | grep 'event=egress'
Example allowed log:
event=egress src=10.10.10.3:44608 frontend=fe_https backend=be_https_allowed server=sni-router sni=platform.claude.com termination_state=-- bytes=10595 timers=195/106/672
Example denied log:
event=egress src=10.10.10.3:44610 frontend=fe_https backend=be_deny server=<NOSRV> sni=example.com termination_state=PR bytes=0 timers=-1/-1/1
The healthcheck deliberately checks for running haproxy and dnsmasq processes instead of opening port 443. A port-only healthcheck has no SNI, so HAProxy correctly denies it and fills the logs with noise.
Secrets
devcontainer.env is not referenced through Compose env_file. That is intentional. docker compose config expands env_file values and prints secrets. Instead, setup-nuget.sh sources devcontainer.env inside the container when it needs NuGet credentials.
Keep real values in devcontainer.env. Keep .env.example empty and commit-safe.
Validation Commands
After copying or committing this setup, run the installer once from the repository root:
install-egress-proxy.sh
Optional checks:
install-egress-proxy.sh --validate-runtime
install-egress-proxy.sh --devcontainer-up
The installer is commit-safe. It does not contain secrets, does not print rendered Compose config, and only creates local devcontainer.env from .env.example when the file is missing.
Use these from the host:
devcontainer up --workspace-folder /path/to/company-repository
Use these from the host to check behavior inside the app container:
podman exec --user vscode company_devcontainer-app-1 sh -lc 'cd /workspaces && dotnet nuget list source'
podman exec company_devcontainer-app-1 sh -lc 'getent hosts platform.claude.com && curl -I --connect-timeout 15 https://platform.claude.com'
podman exec company_devcontainer-app-1 sh -lc 'curl -I --connect-timeout 8 https://example.com'
The example.com request should fail with a TLS/connectivity error because HAProxy rejects non-allowlisted SNI.
Caveats
- This setup is HTTPS/SNI based. It does not decrypt TLS.
- Clients must send TLS SNI. Most modern HTTPS clients do.
- Plain HTTP on port
80is blocked. - SSH and arbitrary TCP protocols are blocked.
- DNS always succeeds because every hostname maps to the sidecar; a blocked host fails at TLS connection time instead of DNS lookup time.
- HAProxy resolves upstreams as IPv4.
dnsmasqfilters AAAA records so app clients do not attempt IPv6 paths the sidecar is not configured to forward. vscodehas no sudo access at all (app/Dockerfileremoves the base image's grant). Nothing inappruns as root, ever, after the image is built — there is nothing left in this container that legitimately needs it.userns_mode: keep-idis Podman-specific. It preserves host UID/GID mapping so files likenuget.configremain readable by thevscodeuser. It does not affect the sudo/capability decisions above — rootless Podman uses a user namespace either way;keep-idonly changes which UID it maps to.- The
:Zbind mount label is for SELinux systems such as Fedora. Without it, the container may be unable to read/workspaceseven when Unix permissions look correct. It is not needed on hosts without SELinux enforcement. - If the app container is recreated directly with
docker compose up app, it may use the plain base image and miss Dev Container features such as .NET. Preferdevcontainer upfor the app container.
File: devcontainer.json
This file tells Dev Containers to use Compose, select the app service, install the language/tooling features, and run the setup hooks. The workspaceFolder is /workspaces, which is where the repository is mounted by Compose.
{
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces",
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/dotnet:2": {
"version": "10.0"
}
},
"postCreateCommand": "bash setup-claude-plugins.sh",
"postStartCommand": {
"tmp-permissions": "mkdir -m 700 -p /tmp/claude-$(id -u)",
"setup-nuget": "bash setup-nuget.sh"
}
}
Neither postStartCommand entry needs sudo. /tmp is already world-writable (chmod 1777 happens once, at image build time — see app/Dockerfile), so mkdir under it works unprivileged as vscode.
File: docker-compose.yml
This file defines two services. app is the development container. egress-proxy is the only container with an internet-connected network. The egress network is internal, so the app cannot route directly to the internet.
Important details:
userns_mode: keep-idkeeps rootless Podman from mapping host-owned files to the wrong container owner.image: localhost/devcontainer-egress-proxy-app:latestkeeps Podman's local app image tag aligned with the base image name used by the Dev Container UID update build...:/workspaces:Zmounts the repository and relabels it for SELinux../app/resolv.conf:/etc/resolv.conf:ro,Zpoints the app at the sidecar resolver via a static bind mount, not Compose'sdns:setting — see "Why This Setup Exists" for why.apphas nocap_addand nodns:entry. It needs neither: DNS steering is the bind-mounted file above, and there is no in-container firewall to grant capabilities to.egress-proxydrops all capabilities, then adds back onlyNET_BIND_SERVICE,SETGID, andSETUID: binding ports 53/443 requires the first, whilednsmasqand HAProxy need the latter two to drop to their configured unprivileged users/groups.- The healthcheck avoids port
443so it does not create false deny logs. pids_limit/mem_limiton both services cap blast radius from a runaway or compromised process (fork bomb, memory exhaustion). They're set generously forappsince it runs real dev tooling, and tightly foregress-proxysince haproxy/dnsmasq have a small, predictable footprint.egress-proxyalso runs withread_only: trueandtmpfsfor/run//tmp: it's the internet-facing container, and neither haproxy (logs to stdout, no stats socket) nor dnsmasq (DNS-only, nodhcp-range, so no lease file) needs to persist anything to disk.
services:
app:
image: localhost/devcontainer-egress-proxy-app:latest
build:
context: ./app
userns_mode: keep-id
# Blast-radius reduction: this container has no legitimate need for any
# Linux capability (it runs dev tooling as the unprivileged `vscode`
# user - see app/Dockerfile) or for privilege-escalation via setuid
# binaries. Neither is load-bearing for egress control, which remains
# entirely the `internal: true` network below.
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
sysctls:
- net.ipv6.conf.all.disable_ipv6=1
- net.ipv6.conf.default.disable_ipv6=1
# Blast-radius cap, not a tuning knob: bounds a runaway build/test
# process or compromised dependency (fork bomb, memory exhaustion)
# without constraining normal dotnet/node dev workloads. Raise if a
# legitimate build needs more.
pids_limit: 4096
mem_limit: 8g
volumes:
- ..:/workspaces:Z
# Static resolver pointing at the sidecar. Bind-mounted rather than set
# via Compose's `dns:` because that works by rewriting this same file
# after the container starts, and Podman does not always honor it -
# occupying the mountpoint from the start leaves nothing to race.
- ./app/resolv.conf:/etc/resolv.conf:ro,Z
command: sleep infinity
# No other network may be added to this service, and no capabilities
# should be added beyond the drop above. There is no in-container
# firewall - egress control is entirely the `internal: true` network
# below, enforced by the engine outside this container's reach
# regardless of privilege level inside it. A second network here
# bypasses the whole design.
networks:
egress:
depends_on:
egress-proxy:
condition: service_healthy
egress-proxy:
build:
context: ./haproxy
# This is the one container with real internet access, so it's the
# highest-value target in this design - it keeps only the capability
# dnsmasq/haproxy actually need to bind ports 53/443 (both <1024).
# haproxy also drops from root to its own `haproxy` user post-bind
# (see global `user`/`group` in haproxy.cfg).
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
sysctls:
- net.ipv6.conf.all.disable_ipv6=1
- net.ipv6.conf.default.disable_ipv6=1
# Blast-radius cap: haproxy+dnsmasq have a small, well-known footprint,
# so a limit here is tight on purpose - this is the internet-facing
# container, the highest-value target in this design.
pids_limit: 128
mem_limit: 256m
# Neither process needs persistent on-disk state: haproxy logs to
# stdout and has no configured stats socket; dnsmasq here is DNS-only
# (no dhcp-range), so it has no lease file to persist. /run holds only
# dnsmasq's pid file for the lifetime of the container.
read_only: true
tmpfs:
- /run
- /tmp
networks:
egress:
ipv4_address: 10.10.10.10
internet:
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pidof haproxy >/dev/null && pidof dnsmasq >/dev/null"]
interval: 2s
timeout: 2s
retries: 15
networks:
# app's only network: no route out except to egress-proxy, enforced by Docker itself
egress:
internal: true
enable_ipv6: false
ipam:
config:
- subnet: 10.10.10.0/24
# egress-proxy's real connectivity out to the actual internet
internet:
driver: bridge
enable_ipv6: false
Resource limits (pids_limit, mem_limit) are a blast-radius cap on both services, not a performance tuning knob - raise them if a legitimate workload needs more, but don't remove them. egress-proxy additionally runs with read_only: true plus tmpfs for /run and /tmp, since it has no legitimate need to write anywhere else: haproxy logs to stdout with no stats socket configured, and dnsmasq here is DNS-only (no dhcp-range) so it never touches a lease file.
File: app/Dockerfile
The app service builds this instead of using mcr.microsoft.com/devcontainers/base:ubuntu directly, so /tmp permissions can be set once at build time and the base image's unrestricted sudo grant can be removed with no replacement.
FROM mcr.microsoft.com/devcontainers/base:ubuntu
USER root
# /tmp permissions are filesystem state, so they belong in the image layer,
# not a runtime script - this is the only thing container-init.sh used to
# do that still needs doing anywhere.
RUN chmod 1777 /tmp
# vscode has no legitimate reason to become root in this container: DNS
# steering is a bind-mounted /etc/resolv.conf (see docker-compose.yml) and
# there is no in-container firewall - egress is controlled entirely by the
# `internal: true` Compose network, which nothing inside this container can
# reach around regardless of privilege level. Remove the base image's
# unrestricted "vscode ALL=(root) NOPASSWD:ALL" grant with no replacement.
RUN rm -f /etc/sudoers.d/vscode
USER vscode
File: app/resolv.conf
Bind-mounted read-only over /etc/resolv.conf (see docker-compose.yml). This is the entire DNS-steering mechanism — every hostname app looks up resolves to the sidecar, which is what routes its HTTPS traffic into HAProxy in the first place.
nameserver 10.10.10.10
File: haproxy/Dockerfile
The proxy image starts from HAProxy Alpine and adds dnsmasq. Both processes run in the same sidecar: dnsmasq handles app DNS, HAProxy handles HTTPS enforcement and forwarding.
FROM haproxy:3.0-alpine
USER root
RUN apk add --no-cache dnsmasq
COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg
COPY dnsmasq.conf /etc/dnsmasq.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
File: haproxy/entrypoint.sh
The entrypoint starts dnsmasq first, then runs HAProxy in the foreground. Foreground HAProxy plus log stdout makes egress behavior visible through container logs.
#!/bin/sh
set -eu
# dnsmasq daemonizes itself; runs as a background helper for app's DNS lookups.
dnsmasq --conf-file=/etc/dnsmasq.conf
# haproxy is the container's main process.
exec haproxy -W -db -f /usr/local/etc/haproxy/haproxy.cfg
File: haproxy/dnsmasq.conf
This DNS config intentionally returns the sidecar IP for every hostname. HAProxy remains the enforcement point; DNS is only steering traffic into the proxy.
filter-AAAA avoids returning IPv6 records to the app, because the proxy path is configured around IPv4 upstream resolution.
# Resolver for the app container. Every hostname resolves to the sidecar;
# HAProxy is the enforcement point and resolves allowed upstreams itself.
# No `server=` upstream is configured on purpose: dnsmasq's `address=`
# override only ever applies to A/AAAA, so any upstream here would leave
# every other query type (TXT/MX/NS/SOA/...) forwarded to the real internet
# with live answers over both UDP and TCP/53 -- a DNS-tunneling exfil path
# the app has no legitimate need for, since HAProxy resolves allowed
# upstreams itself via its own independent `resolvers dns` section.
no-resolv
filter-AAAA
address=/#/10.10.10.10
listen-address=10.10.10.10
bind-interfaces
File: haproxy/haproxy.cfg
This is the main policy file. HAProxy reads TLS ClientHello SNI, checks it against the allowlist, resolves the real upstream IP only for allowed hosts, and rejects everything else.
Ordering matters: do-resolve and set-dst must run before tcp-request content accept, otherwise HAProxy accepts the ClientHello before setting the upstream destination.
The dst_is_private ACL rejects the connection if the resolved upstream IP falls in a private, loopback, link-local, or other reserved range, even when the SNI itself is allow-listed. An allowed hostname is trusted by name, not by whatever address a DNS answer happens to carry — without this check, a poisoned or hijacked answer for an allowed SNI could redirect app traffic to an internal address, since egress-proxy sits on both the internal egress network and the real internet network.
Keep the long acl ... -m str -i ... host list on one directive line unless you use HAProxy-supported continuation syntax carefully. Earlier multiline formatting was parsed as unknown frontend keywords.
global
maxconn 2000
log stdout format raw local0 info
# Bind :443 while still root/CAP_NET_BIND_SERVICE, then drop to the
# image's unprivileged `haproxy` user for everything else.
user haproxy
group haproxy
resolvers dns
nameserver pub1 1.1.1.1:53
nameserver pub2 8.8.8.8:53
hold valid 10s
defaults
mode tcp
log global
log-format "event=egress src=%ci:%cp frontend=%ft backend=%b server=%s sni=%[var(txn.sni)] termination_state=%ts bytes=%B timers=%Tw/%Tc/%Tt"
timeout connect 5s
timeout client 1m
timeout server 1m
#######################################
# HTTPS egress: allow-by-SNI, passthrough (no decryption, no cert needed)
#######################################
frontend fe_https
bind *:443
tcp-request inspect-delay 5s
tcp-request content set-var(txn.sni) req.ssl_sni if { req.ssl_hello_type 1 }
# Domain families - suffix match on the leading dot so nothing can
# sneak in by ending in the same characters without being a real
# subdomain (e.g. "evilblob.core.windows.net" would NOT match).
acl sni_allowed req.ssl_sni -m end -i .blob.core.windows.net .githubusercontent.com
# Singleton hosts - exact match, not a domain family.
acl sni_allowed req.ssl_sni -m str -i api.anthropic.com mcp-proxy.anthropic.com docs.claude.com platform.claude.com statsig.anthropic.com statsig.com sentry.io registry.npmjs.org marketplace.visualstudio.com update.code.visualstudio.com github.com api.github.com github.githubassets.com collector.github.com ghcr.io api.nuget.org www.nuget.org globalcdn.nuget.org nuget.org nuget.pkg.github.com pkgs.dev.azure.com prod-files-secure.s3.us-west-2.amazonaws.com
tcp-request content do-resolve(txn.dstip,dns,ipv4) req.ssl_sni if sni_allowed
acl dst_resolved var(txn.dstip) -m found
# Refuse to forward if the resolved address lands in a private,
# loopback, link-local, or other reserved range. An allow-listed
# hostname is trusted by name, not by whatever IP a DNS answer happens
# to carry - without this, a poisoned/hijacked answer for an allowed
# SNI could redirect app traffic to an internal address (SSRF via the
# egress proxy itself, which sits on both the internal and internet
# networks).
acl dst_is_private var(txn.dstip) -m ip 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4
tcp-request content reject if sni_allowed dst_is_private
tcp-request content set-dst var(txn.dstip) if sni_allowed dst_resolved !dst_is_private
tcp-request content accept if { req.ssl_hello_type 1 }
use_backend be_https_allowed if sni_allowed dst_resolved !dst_is_private
default_backend be_deny
backend be_https_allowed
# Destination IP is resolved from SNI in the frontend and applied with set-dst.
server sni-router 0.0.0.0:443
backend be_deny
tcp-request content reject
File: setup-nuget.sh
This script configures private NuGet feeds after the container starts. It sources devcontainer.env directly so Compose does not expand secrets into docker compose config output.
Azure DevOps feeds use --valid-authentication-types basic because PAT-based feed auth can fail or behave inconsistently without it.
The source existence check compares exact source names. A plain substring match is unsafe when one source name contains another.
#!/usr/bin/env bash
set -euo pipefail
if [ -f devcontainer.env ]; then
set -a
# shellcheck disable=SC1091
source devcontainer.env
set +a
fi
# Configures credentials for a NuGet source. Adds the source if it isn't
# already registered (e.g. missing from nuget.config), otherwise just
# updates its credentials.
# Usage: configure_source <name> <url> <username> <password>
source_exists() {
local name="$1"
dotnet nuget list source 2>/dev/null | awk -v name="$name" '
/^[[:space:]]*[0-9]+\. / {
source_name = $0
sub(/^[[:space:]]*[0-9]+\. /, "", source_name)
sub(/[[:space:]]\[(Enabled|Disabled)\][[:space:]]*$/, "", source_name)
if (source_name == name) found = 1
}
END { exit found ? 0 : 1 }
'
}
configure_source() {
local name="$1" url="$2" username="$3" password="$4"
local valid_authentication_types="${5:-}"
local authentication_args=()
if [ -n "$valid_authentication_types" ]; then
authentication_args=(--valid-authentication-types "$valid_authentication_types")
fi
if source_exists "$name"; then
dotnet nuget update source "$name" \
--username "$username" \
--password "$password" \
--store-password-in-clear-text \
"${authentication_args[@]}"
else
dotnet nuget add source "$url" \
--name "$name" \
--username "$username" \
--password "$password" \
--store-password-in-clear-text \
"${authentication_args[@]}"
fi
echo "setup-nuget: $name credentials configured"
}
# GitHub NuGet Package Source
if [ -n "${NUGET_GITHUB_URL:-}" ] && [ -n "${NUGET_GITHUB_TOKEN:-}" ]; then
configure_source "github-packages" \
"$NUGET_GITHUB_URL" \
"${NUGET_GITHUB_USER:-github-user}" \
"$NUGET_GITHUB_TOKEN"
else
echo "setup-nuget: NUGET_GITHUB_URL or NUGET_GITHUB_TOKEN not set, skipping GitHub source configuration"
fi
# Azure DevOps NuGet Source
if [ -n "${AZURE_DEVOPS_URL:-}" ] && [ -n "${AZURE_DEVOPS_PAT:-}" ]; then
configure_source "azure-devops-packages" \
"$AZURE_DEVOPS_URL" \
"${AZURE_DEVOPS_USER:-azureuser}" \
"$AZURE_DEVOPS_PAT" \
"basic"
else
echo "setup-nuget: AZURE_DEVOPS_URL or AZURE_DEVOPS_PAT not set, skipping Azure DevOps source configuration"
fi
File: setup-claude-plugins.sh
This script attempts to configure the Claude plugin marketplace and install the Lattice plugin. Failures are non-fatal so the devcontainer remains usable even when the Claude CLI is unavailable or the plugin endpoint is blocked.
#!/usr/bin/env bash
set -u
MARKETPLACE_SOURCE="techygarg/lattice"
PLUGIN_NAME="lattice"
SCOPE="project"
log() {
printf '[claude-plugins] %s\n' "$1"
}
if ! command -v claude >/dev/null 2>&1; then
log "Claude CLI not found on PATH. Skipping plugin setup."
exit 0
fi
# Add marketplace if not present yet.
if claude plugins marketplace list 2>/dev/null | grep -Eiq 'techygarg/lattice|\blattice\b'; then
log "Marketplace already configured."
else
if claude plugins marketplace add "$MARKETPLACE_SOURCE" --scope "$SCOPE" >/dev/null 2>&1; then
log "Marketplace added: $MARKETPLACE_SOURCE"
else
log "Marketplace add failed (non-fatal)."
fi
fi
# Install plugin if not present yet.
if claude plugins list 2>/dev/null | grep -Eiq '(^|[[:space:]])lattice(@|[[:space:]]|$)'; then
log "Plugin already installed."
else
if claude plugins install "$PLUGIN_NAME" --scope "$SCOPE" -y >/dev/null 2>&1; then
log "Plugin installed: $PLUGIN_NAME"
elif claude plugins install "$PLUGIN_NAME@$PLUGIN_NAME" --scope "$SCOPE" -y >/dev/null 2>&1; then
log "Plugin installed via explicit marketplace selector."
else
log "Plugin install failed (non-fatal)."
fi
fi
log "Final installed plugins:"
claude plugins list || true
File: install-egress-proxy.sh
This script prepares the committed setup for local use. It checks that all required files exist, fixes executable bits, creates local devcontainer.env from .env.example when missing, validates Compose without printing rendered config, and can optionally validate HAProxy/dnsmasq or run devcontainer up.
It is safe to commit because it contains no secrets and does not embed machine-specific paths.
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
RUN_DEVCONTAINER_UP=0
RUN_RUNTIME_VALIDATION=0
usage() {
cat <<'USAGE'
Usage: ./install-egress-proxy.sh [options]
Prepares the committed devcontainer egress-proxy setup for local use.
Options:
--validate-runtime Also validate HAProxy/dnsmasq config in a container runtime.
--devcontainer-up Run `devcontainer up` after local preparation.
-h, --help Show this help text.
This script does not print `docker compose config`, because Compose expands
environment values and can expose secrets if env_file is reintroduced later.
USAGE
}
log() {
printf '[egress-proxy-install] %s\n' "$1"
}
fail() {
printf '[egress-proxy-install] ERROR: %s\n' "$1" >&2
exit 1
}
while [ "$#" -gt 0 ]; do
case "$1" in
--validate-runtime)
RUN_RUNTIME_VALIDATION=1
;;
--devcontainer-up)
RUN_DEVCONTAINER_UP=1
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown option: $1"
;;
esac
shift
done
required_files=(
"devcontainer.json"
"docker-compose.yml"
"app/Dockerfile"
"app/resolv.conf"
"setup-nuget.sh"
"setup-claude-plugins.sh"
".env.example"
"haproxy/Dockerfile"
"haproxy/entrypoint.sh"
"haproxy/dnsmasq.conf"
"haproxy/haproxy.cfg"
)
for file in "${required_files[@]}"; do
[ -f "$SCRIPT_DIR/$file" ] || fail "missing required file: $file"
done
chmod +x \
"$SCRIPT_DIR/setup-nuget.sh" \
"$SCRIPT_DIR/setup-claude-plugins.sh" \
"$SCRIPT_DIR/haproxy/entrypoint.sh"
if [ ! -f "$SCRIPT_DIR/devcontainer.env" ]; then
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/devcontainer.env"
chmod 600 "$SCRIPT_DIR/devcontainer.env"
log "created local devcontainer.env from .env.example"
else
chmod 600 "$SCRIPT_DIR/devcontainer.env"
log "kept existing local devcontainer.env"
fi
if grep -Eq '^[[:space:]]*env_file:' "$SCRIPT_DIR/docker-compose.yml"; then
fail "docker-compose.yml must not use env_file; setup-nuget.sh sources devcontainer.env directly to avoid printing secrets"
fi
if command -v docker >/dev/null 2>&1; then
docker compose -f "$SCRIPT_DIR/docker-compose.yml" config >/dev/null
log "compose syntax validated without printing rendered config"
else
log "docker command not found; skipped compose syntax validation"
fi
if [ "$RUN_RUNTIME_VALIDATION" -eq 1 ]; then
runtime=""
if command -v podman >/dev/null 2>&1; then
runtime="podman"
elif command -v docker >/dev/null 2>&1; then
runtime="docker"
fi
[ -n "$runtime" ] || fail "--validate-runtime requires podman or docker"
"$runtime" run --rm \
-v "$SCRIPT_DIR/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro,Z" \
haproxy:3.0-alpine \
haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg >/dev/null
"$runtime" run --rm --user root \
-v "$SCRIPT_DIR/haproxy/dnsmasq.conf:/etc/dnsmasq.conf:ro,Z" \
haproxy:3.0-alpine \
sh -lc 'apk add --no-cache dnsmasq >/dev/null && dnsmasq --test --conf-file=/etc/dnsmasq.conf' >/dev/null
log "runtime validation passed for HAProxy and dnsmasq"
fi
if [ "$RUN_DEVCONTAINER_UP" -eq 1 ]; then
command -v devcontainer >/dev/null 2>&1 || fail "--devcontainer-up requires the devcontainer CLI"
devcontainer up --workspace-folder "$REPOSITORY_ROOT"
fi
log "done"
File: .env.example
This file documents the required variables without storing secrets. Create or update devcontainer.env locally with real values.
NUGET_GITHUB_URL=
NUGET_GITHUB_TOKEN=
NUGET_GITHUB_USER=
AZURE_DEVOPS_URL=
AZURE_DEVOPS_PAT=
AZURE_DEVOPS_USER=
Do not put real secrets in this README. Keep local secret values in devcontainer.env only.