diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b30b52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +target +node_modules +__pycache__ +*.pyc +*.class +*.o +*.a +.idea +.vscode +*.swp +*.swo +*~ +.DS_Store +.env +.env.local diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c83ab0a..efcf264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: ci: uses: ./.github/workflows/ci.yml - # ── Cross-compile Rust binaries (native speed via cross) ─────────── + # ── Build Rust binaries (native per-arch) ────────────────────────── build-rust: name: Build Rust (${{ matrix.arch }}) needs: ci @@ -27,11 +27,9 @@ jobs: strategy: matrix: include: - - target: x86_64-unknown-linux-gnu - arch: amd64 + - arch: amd64 runner: ubuntu-latest - - target: aarch64-unknown-linux-gnu - arch: arm64 + - arch: arm64 runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 @@ -40,7 +38,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - key: ${{ matrix.target }} + key: ${{ matrix.arch }} - name: Build run: cargo build --release --features full @@ -60,7 +58,6 @@ jobs: cp target/release/envproxy dist/ cp target/release/libenvproxy.so dist/ tar czf "$ARCHIVE" -C dist . - echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - name: Upload binaries (for Docker build) uses: actions/upload-artifact@v4 @@ -74,6 +71,36 @@ jobs: name: tarball-${{ matrix.arch }} path: "*.tar.gz" + # ── Build Go injector binary (native per-arch) ───────────────────── + build-injector: + name: Build Injector (${{ matrix.arch }}) + needs: ci + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: k8s/injector/go.mod + cache-dependency-path: k8s/injector/go.sum + + - name: Build + run: CGO_ENABLED=0 go build -ldflags="-s -w" -o injector ./cmd/ + working-directory: k8s/injector + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: injector-${{ matrix.arch }} + path: k8s/injector/injector + # ── Build Java agent JAR ─────────────────────────────────────────── build-java: name: Build Java agent JAR @@ -95,25 +122,28 @@ jobs: name: java-agent path: support/java/envproxy-agent.jar - # ── Build envproxy Docker image (multi-arch) ─────────────────────── + # ── Build envproxy Docker image (per-arch native, then merge) ────── build-envproxy-image: - name: Docker (envproxy) + name: Docker envproxy (${{ matrix.arch }}) needs: [build-rust, build-java] - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - - name: Download amd64 binaries + - name: Download binaries uses: actions/download-artifact@v4 with: - name: binaries-amd64 - path: binaries/amd64 - - - name: Download arm64 binaries - uses: actions/download-artifact@v4 - with: - name: binaries-arm64 - path: binaries/arm64 + name: binaries-${{ matrix.arch }} + path: binaries/${{ matrix.arch }} - name: Download Java agent JAR uses: actions/download-artifact@v4 @@ -121,7 +151,6 @@ jobs: name: java-agent path: java-agent - - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 @@ -135,31 +164,75 @@ jobs: with: images: ${{ env.ENVPROXY_IMAGE }} tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha- - type=raw,value=latest + type=semver,pattern={{version}},suffix=-${{ matrix.arch }} + type=sha,prefix=sha-,suffix=-${{ matrix.arch }} - uses: docker/build-push-action@v6 with: context: . file: k8s/Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: ${{ matrix.platform }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=envproxy-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=envproxy-${{ matrix.arch }} - # ── Build injector Docker image (multi-arch) ─────────────────────── - build-injector-image: - name: Docker (injector) - needs: ci + # ── Merge envproxy multi-arch manifest ───────────────────────────── + merge-envproxy-manifest: + name: Merge envproxy manifest + needs: build-envproxy-image runs-on: ubuntu-latest + steps: + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest + run: | + VERSION="${GITHUB_REF_NAME#v}" + SHA="sha-${GITHUB_SHA::7}" + + # Create version tag manifest + docker buildx imagetools create -t "$ENVPROXY_IMAGE:${VERSION}" \ + "$ENVPROXY_IMAGE:${VERSION}-amd64" \ + "$ENVPROXY_IMAGE:${VERSION}-arm64" + + # Create sha tag manifest + docker buildx imagetools create -t "$ENVPROXY_IMAGE:${SHA}" \ + "$ENVPROXY_IMAGE:${SHA}-amd64" \ + "$ENVPROXY_IMAGE:${SHA}-arm64" + + # Create latest tag manifest + docker buildx imagetools create -t "$ENVPROXY_IMAGE:latest" \ + "$ENVPROXY_IMAGE:${VERSION}-amd64" \ + "$ENVPROXY_IMAGE:${VERSION}-arm64" + + # ── Build injector Docker image (per-arch native, then merge) ────── + build-injector-image: + name: Docker injector (${{ matrix.arch }}) + needs: build-injector + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 + - name: Download injector binary + uses: actions/download-artifact@v4 + with: + name: injector-${{ matrix.arch }} + path: k8s/injector/binaries/${{ matrix.arch }} + - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 @@ -173,26 +246,53 @@ jobs: with: images: ${{ env.INJECTOR_IMAGE }} tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha- - type=raw,value=latest + type=semver,pattern={{version}},suffix=-${{ matrix.arch }} + type=sha,prefix=sha-,suffix=-${{ matrix.arch }} - uses: docker/build-push-action@v6 with: context: k8s/injector - file: k8s/injector/Dockerfile - platforms: linux/amd64,linux/arm64 + file: k8s/injector/Dockerfile.release + platforms: ${{ matrix.platform }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=injector-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=injector-${{ matrix.arch }} + + # ── Merge injector multi-arch manifest ───────────────────────────── + merge-injector-manifest: + name: Merge injector manifest + needs: build-injector-image + runs-on: ubuntu-latest + steps: + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest + run: | + VERSION="${GITHUB_REF_NAME#v}" + SHA="sha-${GITHUB_SHA::7}" + + docker buildx imagetools create -t "$INJECTOR_IMAGE:${VERSION}" \ + "$INJECTOR_IMAGE:${VERSION}-amd64" \ + "$INJECTOR_IMAGE:${VERSION}-arm64" + + docker buildx imagetools create -t "$INJECTOR_IMAGE:${SHA}" \ + "$INJECTOR_IMAGE:${SHA}-amd64" \ + "$INJECTOR_IMAGE:${SHA}-arm64" + + docker buildx imagetools create -t "$INJECTOR_IMAGE:latest" \ + "$INJECTOR_IMAGE:${VERSION}-amd64" \ + "$INJECTOR_IMAGE:${VERSION}-arm64" # ── Package and push Helm chart to OCI registry ──────────────────── helm-publish: name: Helm chart (OCI) - needs: [build-envproxy-image, build-injector-image] + needs: [merge-envproxy-manifest, merge-injector-manifest] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -236,7 +336,6 @@ jobs: - name: Download arm64 tarball uses: actions/download-artifact@v4 - with: name: tarball-arm64 @@ -248,11 +347,16 @@ jobs: - name: Create release run: | VERSION="${GITHUB_REF_NAME#v}" + PRERELEASE="" + if [[ "$VERSION" == *"-"* ]]; then + PRERELEASE="--prerelease" + fi gh release create "$GITHUB_REF_NAME" \ "envproxy-${VERSION}-linux-amd64.tar.gz" \ "envproxy-${VERSION}-linux-arm64.tar.gz" \ "envproxy-${VERSION}.tgz" \ --title "EnvProxy ${VERSION}" \ - --generate-notes + --generate-notes \ + $PRERELEASE env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/crates/envproxy-cli/src/main.rs b/crates/envproxy-cli/src/main.rs index 05f105f..fe82a68 100644 --- a/crates/envproxy-cli/src/main.rs +++ b/crates/envproxy-cli/src/main.rs @@ -56,6 +56,19 @@ enum Commands { /// Check if the agent is running and responsive. Status, + + /// Copy envproxy runtime files to a target directory and optionally write agent config. + /// Used by the Kubernetes init container (works on distroless images, no shell needed). + Init { + /// Target directory to copy files to. + #[arg(short, long, default_value = "/envproxy")] + target: PathBuf, + + /// Agent config content to write to /config.toml. + /// Passed as a string (the webhook generates it from pod annotations). + #[arg(long)] + write_config: Option, + }, } fn main() -> Result<()> { @@ -68,6 +81,7 @@ fn main() -> Result<()> { cmd_status(&cli.socket); Ok(()) } + Commands::Init { target, write_config } => cmd_init(&target, write_config.as_deref()), } } @@ -155,6 +169,76 @@ fn cmd_get(key: &str, socket: &Path) -> Result<()> { Ok(()) } +/// Copy envproxy runtime files to a target directory. +/// +/// This replaces the `sh -c "cp ... && mkdir ..."` init container script, +/// enabling the use of distroless images (no shell required). +/// Optionally writes a config.toml file if `config_content` is provided. +fn cmd_init(target: &Path, config_content: Option<&str>) -> Result<()> { + use std::fs; + + // Create directory structure. + let dirs = ["lib", "python", "java"]; + for dir in &dirs { + fs::create_dir_all(target.join(dir)) + .with_context(|| format!("failed to create {}/{dir}", target.display()))?; + } + + // Source → destination mappings. + let files: &[(&str, &str)] = &[ + ("/usr/bin/envproxy", "envproxy"), + ("/usr/bin/envproxy-agent", "envproxy-agent"), + ("/usr/lib/envproxy/lib/libenvproxy.so", "lib/libenvproxy.so"), + ("/usr/lib/envproxy/java/envproxy-agent.jar", "java/envproxy-agent.jar"), + ]; + + for (src, dst) in files { + let dest = target.join(dst); + fs::copy(src, &dest) + .with_context(|| format!("failed to copy {src} → {}", dest.display()))?; + } + + // Copy Python support directory (multiple files). + let python_src = Path::new("/usr/lib/envproxy/python"); + if python_src.is_dir() { + for entry in fs::read_dir(python_src).context("failed to read python dir")? { + let entry = entry?; + let src_path = entry.path(); + if src_path.is_file() { + let file_name = entry.file_name(); + let dest = target.join("python").join(&file_name); + fs::copy(&src_path, &dest).with_context(|| { + format!("failed to copy {} → {}", src_path.display(), dest.display()) + })?; + } + } + } + + // Set executable permissions. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let executables = ["envproxy", "envproxy-agent"]; + for name in &executables { + let path = target.join(name); + if path.exists() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("failed to chmod {}", path.display()))?; + } + } + } + + // Write config.toml if provided. + if let Some(content) = config_content { + let config_path = target.join("config.toml"); + fs::write(&config_path, content) + .with_context(|| format!("failed to write {}", config_path.display()))?; + } + + println!("envproxy init: copied runtime files to {}", target.display()); + Ok(()) +} + /// Check if the agent is running and responsive. fn cmd_status(socket: &Path) { // Try to connect and send a test request. diff --git a/k8s/Dockerfile b/k8s/Dockerfile index a56eac8..3eaddf0 100644 --- a/k8s/Dockerfile +++ b/k8s/Dockerfile @@ -3,7 +3,7 @@ # # Used as: # - Sidecar: envproxy-agent runs alongside app containers in each pod -# - Init container: copies /usr/lib/envproxy/* into a shared emptyDir volume +# - Init container: runs "envproxy init --target /envproxy" to copy runtime files # # Build modes: # Local: docker build -f k8s/Dockerfile . @@ -11,8 +11,8 @@ # CI: Pre-built binaries are placed in binaries/{amd64,arm64}/ by the # release workflow. The Rust build stage is skipped via cache hit. # -# NOTE: Runtime is debian (not alpine) because libenvproxy.so and the CLI -# must be glibc-linked — they get copied into glibc-based app pods. +# Runtime is distroless/cc-debian12 (glibc included for libenvproxy.so). +# No shell — the init container uses "envproxy init" subcommand instead of sh -c. # ── Java build stage (architecture-independent) ────────────────────── FROM eclipse-temurin:21-jdk AS java-builder @@ -20,8 +20,8 @@ WORKDIR /src COPY support/java/src/ src/ COPY support/java/META-INF/ META-INF/ RUN mkdir -p build && \ - javac -d build src/envproxy/EnvProxyAgent.java src/envproxy/EnvProxyMap.java && \ - jar cfm envproxy-agent.jar META-INF/MANIFEST.MF -C build . + javac -d build src/envproxy/EnvProxyAgent.java src/envproxy/EnvProxyMap.java && \ + jar cfm envproxy-agent.jar META-INF/MANIFEST.MF -C build . # ── Rust build stage (for local builds; CI uses pre-built binaries) ── FROM rust:1.88-trixie AS rust-builder @@ -29,33 +29,27 @@ WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ RUN cargo build --release --features full && \ - strip target/release/envproxy-agent && \ - strip target/release/envproxy && \ - strip target/release/libenvproxy.so + strip target/release/envproxy-agent && \ + strip target/release/envproxy && \ + strip target/release/libenvproxy.so -# ── Runtime ────────────────────────────────────────────────────────── -FROM debian:trixie-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates && \ - rm -rf /var/lib/apt/lists/* +# ── Runtime (distroless, non-root — no shell, minimal attack surface) ─ +FROM gcr.io/distroless/cc-debian13:nonroot # Rust binaries — from CI pre-built artifacts or from rust-builder stage. -# In CI, the release workflow places binaries in binaries/{amd64,arm64}/ -# and buildx sets TARGETARCH automatically. +# --chmod=755 required for distroless non-root (no chmod available in image). ARG TARGETARCH -COPY binaries/${TARGETARCH}/envproxy-agent /usr/bin/envproxy-agent -COPY binaries/${TARGETARCH}/envproxy /usr/bin/envproxy -COPY binaries/${TARGETARCH}/libenvproxy.so /usr/lib/envproxy/lib/libenvproxy.so +COPY --chmod=755 binaries/${TARGETARCH}/envproxy-agent /usr/bin/envproxy-agent +COPY --chmod=755 binaries/${TARGETARCH}/envproxy /usr/bin/envproxy +COPY --chmod=755 binaries/${TARGETARCH}/libenvproxy.so /usr/lib/envproxy/lib/libenvproxy.so # Python hook -COPY support/python/ /usr/lib/envproxy/python/ +COPY --chmod=755 support/python/ /usr/lib/envproxy/python/ # Java agent JAR -COPY --from=java-builder /src/envproxy-agent.jar /usr/lib/envproxy/java/envproxy-agent.jar +COPY --chmod=755 --from=java-builder /src/envproxy-agent.jar /usr/lib/envproxy/java/envproxy-agent.jar -RUN mkdir -p /var/run/envproxy && \ - chmod +x /usr/bin/envproxy-agent /usr/bin/envproxy +USER 65534:65534 ENTRYPOINT ["envproxy-agent"] CMD ["--config", "/etc/envproxy/config.toml"] diff --git a/k8s/chart/envproxy/values.yaml b/k8s/chart/envproxy/values.yaml index 2aa7524..0df140f 100644 --- a/k8s/chart/envproxy/values.yaml +++ b/k8s/chart/envproxy/values.yaml @@ -60,8 +60,7 @@ tls: issuerName: envproxy-selfsigned # -- Image pull secrets for private registries -imagePullSecrets: - - name: ghcr-pull-secret +imagePullSecrets: [] # -- ServiceAccount configuration serviceAccount: diff --git a/k8s/injector/Dockerfile b/k8s/injector/Dockerfile index 3628006..4c6bd44 100644 --- a/k8s/injector/Dockerfile +++ b/k8s/injector/Dockerfile @@ -1,5 +1,6 @@ # envproxy-injector: mutating admission webhook for Kubernetes # Supports multi-arch builds (amd64, arm64) via buildx TARGETARCH. +# Runtime is distroless/static — Go binary is fully static (CGO_ENABLED=0). FROM golang:1.26-alpine AS builder ARG TARGETOS=linux @@ -14,11 +15,9 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go build -ldflags="-s -w" -o /injector ./cmd/ # ------------------------------------------------------------------- -FROM alpine:3.21 - -RUN apk add --no-cache ca-certificates +FROM gcr.io/distroless/static:nonroot COPY --from=builder /injector /injector -USER nobody:nobody +USER nonroot:nonroot ENTRYPOINT ["/injector"] diff --git a/k8s/injector/Dockerfile.release b/k8s/injector/Dockerfile.release new file mode 100644 index 0000000..e1805ed --- /dev/null +++ b/k8s/injector/Dockerfile.release @@ -0,0 +1,10 @@ +# Release Dockerfile for envproxy-injector. +# Uses a pre-built Go binary (built natively per-arch in CI). +# No Go compilation in Docker — just COPY the binary into distroless. +FROM gcr.io/distroless/static:nonroot + +ARG TARGETARCH +COPY --chmod=755 binaries/${TARGETARCH}/injector /injector + +USER 65534:65534 +ENTRYPOINT ["/injector"] diff --git a/k8s/injector/internal/webhook/mutator.go b/k8s/injector/internal/webhook/mutator.go index eb2c03e..6a06d56 100644 --- a/k8s/injector/internal/webhook/mutator.go +++ b/k8s/injector/internal/webhook/mutator.go @@ -123,28 +123,18 @@ log_level = "info" %s`, config.SocketPath, backendConfig) - // The init script copies binaries and writes the config. - initScript := fmt.Sprintf( - `cp /usr/bin/envproxy /envproxy/envproxy && `+ - `cp /usr/bin/envproxy-agent /envproxy/envproxy-agent && `+ - `mkdir -p /envproxy/lib /envproxy/python /envproxy/java && `+ - `cp /usr/lib/envproxy/lib/libenvproxy.so /envproxy/lib/libenvproxy.so && `+ - `cp -r /usr/lib/envproxy/python/* /envproxy/python/ && `+ - `cp /usr/lib/envproxy/java/envproxy-agent.jar /envproxy/java/envproxy-agent.jar && `+ - `cat > /envproxy/config.toml << 'ENVPROXY_EOF' -%s -ENVPROXY_EOF`, - configContent, - ) - return corev1.Container{ - Name: "envproxy-init", - Image: m.cfg.EnvproxyImage, - Command: []string{"sh", "-c"}, - Args: []string{initScript}, + Name: "envproxy-init", + Image: m.cfg.EnvproxyImage, + Command: []string{ + "/usr/bin/envproxy", "init", + "--target", config.MountPath, + "--write-config", configContent, + }, VolumeMounts: []corev1.VolumeMount{ {Name: config.VolumeName, MountPath: config.MountPath}, }, + SecurityContext: secureContext(), Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("50m"), @@ -173,6 +163,7 @@ func (m *Mutator) buildSidecar(pod *corev1.Pod) corev1.Container { VolumeMounts: []corev1.VolumeMount{ {Name: config.VolumeName, MountPath: config.MountPath}, }, + SecurityContext: secureContext(), Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(cpuLimit), @@ -284,3 +275,26 @@ func shouldInject(name string, targets map[string]bool) bool { } return targets[name] } + +// secureContext returns a hardened SecurityContext for injected containers: +// non-root, read-only root filesystem, no privilege escalation, all capabilities dropped. +func secureContext() *corev1.SecurityContext { + uid := int64(65534) + return &corev1.SecurityContext{ + RunAsNonRoot: boolPtr(true), + RunAsUser: &uid, + ReadOnlyRootFilesystem: boolPtr(true), + AllowPrivilegeEscalation: boolPtr(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func int64Ptr(i int64) *int64 { + return &i +}