diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b383d4..090452c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,29 @@ jobs: - name: Run macOS tests run: sudo ./testing/test_install_certs_macos.sh + test-macos-jvm: + name: Test (macOS JVM) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Pass JAVA_HOME and PATH explicitly to the sudo'd test runner rather + # than relying on `sudo --preserve-env=…`: future actions/setup-java + # versions could rename or rescope the JAVA_HOME env var, silently + # breaking the preserve-by-name approach. Explicit `env VAR=…` is + # the same idiom and survives such churn. + - name: Run macOS JVM smoke matrix + run: | + sudo env \ + JAVA_HOME="$JAVA_HOME" \ + PATH="$JAVA_HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + ./testing/test_install_certs_jvm_macos.sh + test-windows: name: Test (Windows) runs-on: windows-latest @@ -27,3 +50,32 @@ jobs: - name: Run Windows tests shell: pwsh run: ./testing/test_install_certs_windows.ps1 + + test-windows-jvm: + name: Test (Windows JVM) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Run Windows JVM smoke matrix + shell: pwsh + run: ./testing/test_install_certs_jvm_windows.ps1 + + test-linux-jvm: + name: Test (Linux JVM) + runs-on: ubuntu-latest + # No actions/setup-java here — unlike the macOS/Windows JVM jobs that + # exercise a single host JDK, this job runs a 4-distro Docker matrix + # (Ubuntu / Debian / RHEL / Amazon Linux) and installs a JDK *inside* + # each container. The host runner doesn't need a JDK; setting one up + # would only mask divergence in the per-distro keytool versions. + steps: + - uses: actions/checkout@v4 + + - name: Run Linux JVM smoke matrix + run: ./testing/test_install_certs_jvm_linux.sh diff --git a/README.md b/README.md index 4335a06..8f3dc6f 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,38 @@ Scripts to install a CA certificate, configure Node/npm, Python (pip, uv, Huggin This document describes the certificate installation and validation scripts for **macOS**, **Linux (Debian/Ubuntu)**, and **Windows**. +## Quickstart — which script do I need? + +| Your toolchain | Your OS | Go to | +|---|---|---| +| **Maven / Gradle / sbt / Ivy** (JVM) | Linux | [Linux (JVM)](#linux-jvm-install_certs_jvm_linuxsh) | +| **Node / npm or Python (pip / uv / Hugging Face)** | macOS | [macOS](#macos-install_certs_macossh) | +| **Node / npm or Python** | Linux (Debian / Ubuntu) | [Linux (Debian/Ubuntu)](#linux-debianubuntu-install_certs_debian_ubuntush) | +| **Node / npm or Python** | Windows | [Windows](#windows-install_certs_windowsps1) | + +Reference: research wiki [Maven Support in package-reroute (DFLOW-136 / DFLOW-116)](https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/). + +## Script index + | Script | Platform | Purpose | |--------|----------|---------| | **install_certs_macos.sh** | macOS | Install cert, set env vars (Node/Python), and clear Docker Hub credentials | | **validate_install_macos.sh** | macOS | Validate PEM and env config | +| **install_certs_jvm_macos.sh** | macOS (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS + per-user LaunchAgent setting `JAVA_TOOL_OPTIONS` | +| **validate_certs_jvm_macos.sh** | macOS (JVM) | Validate JVM truststore install (JKS subject + plist + `launchctl getenv`) | +| **build_jvm_truststore_macos.sh** | macOS (JVM) | Build installer-ready JKS from macOS system roots plus a supplied PEM CA | | **install_certs_debian_ubuntu.sh** | Debian/Ubuntu | Install cert into system trust + profile.d + user shell rc + Docker cleanup | | **validate_certs_debian_ubuntu.sh** | Debian/Ubuntu | Validate PEM and env config | +| **build_jvm_truststore_linux.sh** | Linux (JVM) | Build installer-ready JKS from Linux system CA bundle plus a supplied PEM CA | +| **install_certs_jvm_linux.sh** | Linux (JVM) | Install bundled JVM truststore: JKS at `/etc/ssl/package-route-jvm/truststore.jks` + `JAVA_TOOL_OPTIONS` in `/etc/environment` | +| **validate_certs_jvm_linux.sh** | Linux (JVM) | Validate bundled JVM truststore install (JKS subject + `/etc/environment` + shell-rc) | +| **install_certs_jvm_rhel.sh** | RHEL (JVM) | Install PEM into RHEL-family system trust via `update-ca-trust extract` | +| **validate_certs_jvm_rhel.sh** | RHEL (JVM) | Validate RHEL JVM system-trust install (anchor PEM + extracted Java cacerts) | | **install_certs_windows.ps1** | Windows | Install cert, set env vars (Node/Python/Ruby), and clear Docker Hub credentials | | **validate_install_windows.ps1** | Windows | Validate PEM and env config | +| **install_certs_jvm_windows.ps1** | Windows (JVM) | Install CA for Maven/Gradle/sbt/Ivy: JKS at `%LOCALAPPDATA%` + User-scope `JAVA_TOOL_OPTIONS` | +| **validate_certs_jvm_windows.ps1** | Windows (JVM) | Validate JVM truststore install (JKS subject + User-scope env var) | +| **build_jvm_truststore_windows.ps1** | Windows (JVM) | Build installer-ready JKS from Windows LocalMachine roots plus a supplied PEM CA | Environment variables by platform (see each section for details): @@ -176,7 +200,7 @@ sudo ./validate_install_macos.sh --expected-subject "" --all ### Testing -Tests live in **testing/**. Automated tests cover **macOS** and **Windows** only (not Debian/Ubuntu). +Tests live in **testing/**. Automated tests cover **macOS**, **Windows**, and **Linux JVM** (not Debian/Ubuntu). **test_install_certs_macos.sh** runs automated tests for **install_certs_macos.sh** (CLI and argument validation) and **validate_install_macos.sh** (validation with a temp PEM and mock home). No root required for the default test run. @@ -303,6 +327,106 @@ Users must open a **new terminal** (or `source ~/.zshrc`) for the new environmen --- +## macOS (JVM): install_certs_jvm_macos.sh + +### Overview + +`install_certs_jvm_macos.sh` wires a bundled JVM truststore into the JVM trust path on macOS so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_macos.sh` if you need those. + +Single path on macOS — there is no OS-trust fallback because macOS-specific `KeychainStore` is broken per [JDK-8321045](https://bugs.openjdk.org/browse/JDK-8321045). The script: + +1. Copies the supplied JKS truststore to `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. +2. Writes a per-user LaunchAgent plist at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` that calls `launchctl setenv JAVA_TOOL_OPTIONS=…` at `RunAtLoad`. +3. Bootstraps the agent into `gui/` via `launchctl bootstrap` so the env var becomes available to every subsequently-launched GUI process (Dock-launched IntelliJ, JetBrains Toolbox, `open -a …`). + +The `~/.zshrc` / `~/.bash_profile` shortcut is deliberately NOT used: it silently fails for Dock-launched IDE builds because GUI apps don't read the shell's interactive init. The LaunchAgent is the only recipe verified to reach Dock-launched and `open`-launched GUI applications, which inherit `JAVA_TOOL_OPTIONS` from the launchd `gui/` domain. Terminal sessions inherit transitively because Terminal.app itself is launchd-spawned. + +### Requirements + +- **macOS**. +- **Root** (`sudo`) — needed to chown per-user files and to bootstrap into other users' `gui/` domains under `--all-users`. +- macOS built-ins used by the installer (all preinstalled on supported macOS versions): `plutil` (LaunchAgent plist validation), `launchctl` (bootstrap into `gui/`), `dscl` (user / home lookup), `stat` (UID-based filtering under `--all-users`). + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-truststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | +| `--all-users` | No | Iterate `/Users/*` and install the LaunchAgent + JKS for every account with UID ≥ 501. Default = only `SUDO_USER` (or the GUI console user under JAMF). | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +# Single user (typical: install for the developer running sudo) +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks + +# Fleet onboarding (shared Mac with multiple accounts) +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks --all-users +``` + +### Validation: validate_certs_jvm_macos.sh + +**`--expected-subject` is required.** Per user, asserts: +- JKS file exists at the per-user path. +- `keytool -list -v` shows an `Owner:` line matching the substring (case-insensitive). +- LaunchAgent plist exists and passes `plutil -lint`. +- `launchctl getenv JAVA_TOOL_OPTIONS` in `gui/` returns the JKS path (warn-not-fail when the user is not in an active GUI session — the plist will load at next login). + +```bash +./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +sudo ./validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +``` + +`--all-users` requires root (other users' `~/Library` is `0700`). Exit code 0 if all checks pass, 1 otherwise. + +### Caveats + +- **Already-running apps must be restarted.** macOS does not re-poll the launchd domain env on Cmd-Tab. Quit and relaunch IntelliJ / your IDE after install for the env var to take effect. +- **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. +- **`JAVA_TOOL_OPTIONS` inner quoting.** The JKS path lives under `~/Library/Application Support/` which contains a space; the JVM tokenises `JAVA_TOOL_OPTIONS` on whitespace and only honours embedded `"…"` grouping. The installer therefore writes `-Djavax.net.ssl.trustStore="" -Djavax.net.ssl.trustStorePassword="changeit"` into the LaunchAgent plist so the literal quotes reach the JVM tokenizer. Older installs (pre-fix) that wrote the unquoted form produced a fatal `Unrecognized option` on every Dock-launched JVM. +- **`gui/` domain only exists for logged-in GUI users.** Under `--all-users`, accounts that are not currently logged in get the plist installed but `launchctl bootstrap` is soft-skipped; launchd loads the plist automatically at their next login. +- **JAMF / headless kiosk caveat.** On a Mac running JAMF policies *before* any user has logged in, `gui/` is not yet running, so `launchctl bootstrap gui/` either fails or loads into a non-running domain. The plist will load on first interactive login. For truly headless boxes (rack-mounted Mac mini build agents), pair this installer with a `/Library/LaunchDaemons` (system-scope) trust-bootstrap before the first user login, or run the installer interactively as part of provisioning. +- **`KeychainStore` truststoreType is rejected.** Broken per JDK-8321045 — incomplete for `SystemRootCertificates.keychain`. No OS-trust fallback on macOS. +- **`~/.zshrc` / `~/.bash_profile` are deliberately NOT touched.** They silently fail for Dock-launched IDE builds — see Overview. +- **IntelliJ per-IDE SSL store** (`~/Library/Application Support/JetBrains/IntelliJIdea/ssl/cacerts`) is a different layer (plugin marketplace, VCS). Not configured by this script. + +### Building the bundled truststore + +`build_jvm_truststore_macos.sh` creates the `--use-truststore` input for the installer. It exports macOS system certificates from the system keychains, imports them into a JKS truststore, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```bash +./build_jvm_truststore_macos.sh \ + --use-cert /tmp/ZscalerRoot0.pem \ + --output /tmp/package-route-truststore.jks + +sudo ./install_certs_jvm_macos.sh --use-truststore /tmp/package-route-truststore.jks +``` + +### Testing + +`./testing/test_install_certs_jvm_macos.sh` runs the 9-invariant smoke matrix locally and on CI. Each run targets `SUDO_USER`'s per-user files and cleans up between cases via `trap EXIT`. + +```bash +# Local +sudo ./testing/test_install_certs_jvm_macos.sh +``` + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-macos-jvm` job). + +### Summary (macOS JVM) + +- **One run as root**, single truststore source via `--use-truststore`. +- Per-user JKS at `~/Library/Application Support/JFrog/package-route-jvm/truststore.jks`. +- Per-user LaunchAgent at `~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist` bootstrapped into `gui/`. +- **Idempotent**, **re-runnable**, **JDK-version-agnostic**. New JDK installs do not require re-running the script. +- Restart already-running apps; `gradle --stop` for the Gradle Daemon. + +--- + ## Linux (Debian/Ubuntu): install_certs_debian_ubuntu.sh ### Overview @@ -349,6 +473,138 @@ sudo ./validate_certs_debian_ubuntu.sh --all-users --expected-subject "O=Example --- +## Linux (JVM): install_certs_jvm_linux.sh + +### Overview + +`install_certs_jvm_linux.sh` wires a bundled JVM truststore into the JVM trust path so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_debian_ubuntu.sh` if you need the Node/Python flows or Docker Hub credential cleanup. + +This generic Linux script always uses the JKS + `JAVA_TOOL_OPTIONS` recipe: + +1. Copies the supplied truststore to `/etc/ssl/package-route-jvm/truststore.jks`. +2. Writes `JAVA_TOOL_OPTIONS` to `/etc/environment`. +3. Updates the invoking developer user's `.bashrc` or `.zshrc` when a non-root user can be determined. + +RHEL-family hosts that intentionally use Red Hat OpenJDK system trust should use `install_certs_jvm_rhel.sh` instead. + +### Building the bundled truststore + +`build_jvm_truststore_linux.sh` creates the `--use-truststore` input for the generic Linux installer. It imports certificates from the host Linux system CA bundle, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```bash +./build_jvm_truststore_linux.sh \ + --use-cert /tmp/ZscalerRoot0.pem \ + --output /tmp/package-route-truststore.jks + +sudo ./install_certs_jvm_linux.sh --use-truststore /tmp/package-route-truststore.jks +``` + +If the host uses a non-standard CA bundle path, pass `--system-bundle `. + +### Requirements + +- **Linux**. +- **Root** (`sudo`). +- A prebuilt JVM truststore readable with password `changeit`. + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-truststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +sudo ./install_certs_jvm_linux.sh --use-truststore /tmp/package-route-truststore.jks +``` + +### Validation: validate_certs_jvm_linux.sh + +**`--expected-subject` is required.** The validator asserts the copied JKS contains a cert with the expected subject, `/etc/environment` points at the JKS, and the relevant shell rc files are wired. `--all-users` iterates `/home/*` and requires root. + +```bash +./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" +sudo ./validate_certs_jvm_linux.sh --expected-subject "O=Zscaler" --all-users +``` + +Exit code 0 if all checks pass, 1 otherwise. Missing `JAVA_TOOL_OPTIONS` in a user rc file is a **warning** (not a failure) — `/etc/environment` is the authoritative source for system-wide config. Missing `keytool` while a keystore exists is a **failure** because the validator cannot verify the core invariant. + +### Caveats + +- **`/etc/environment` activation.** GUI-launched apps (IntelliJ from the GNOME/KDE launcher) inherit `/etc/environment` via the session manager at login. Existing sessions need a logoff/login to pick up the new env var. The script updates the SUDO_USER's `.bashrc`/`.zshrc` so the current shell session has it without re-login. +- **Gradle Daemon caching.** A Gradle Daemon started before the env var was set still uses its captured environment. Run `gradle --stop` after onboarding. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM prints this to stderr at startup. CI log parsers that strict-match empty-stderr need to tolerate it. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. +- **Container-internal JDKs.** Maven/Gradle running inside Docker on a developer machine need the CA wired into the container image — host-side install does not propagate. Use a `RUN` step in the Dockerfile or pass `JAVA_TOOL_OPTIONS` via `docker run -e`. +- **`MAVEN_OPTS` clobbering.** If your shell or `~/.mavenrc` sets `MAVEN_OPTS`, those args land AFTER `JAVA_TOOL_OPTIONS` and can override the trust store flags. If `mvn` fails TLS after install, check `env | grep -E '^(JAVA_TOOL_OPTIONS|MAVEN_OPTS)='`. +- **IntelliJ per-IDE SSL store.** `~/.config/JetBrains//ssl/cacerts` is a separate trust store used by the IDE for the plugin marketplace and VCS integration — NOT by Maven/Gradle runs spawned from IntelliJ. + +--- + +## RHEL (JVM): install_certs_jvm_rhel.sh + +### Overview + +`install_certs_jvm_rhel.sh` is for RHEL/Fedora/CentOS/Rocky/Alma/Amazon-Linux hosts where Java trust follows the system trust generated by `update-ca-trust`. + +It installs a PEM anchor into `/etc/pki/ca-trust/source/anchors/.crt` and runs `update-ca-trust extract`. It does **not** write `JAVA_TOOL_OPTIONS`. + +### Requirements + +- **RHEL-family Linux**. +- **Root** (`sudo`). +- **`openssl`** and **`update-ca-trust`** on `PATH`. +- **`keytool`** is optional for install-time verification; the validator requires it to inspect Java cacerts. + +### Options + +| Option | Required | Description | +|--------|----------|-------------| +| `--use-cert ` | **Yes** | Path to an existing PEM/CRT certificate file. Validated: parseable X.509, not expired, and `CA:TRUE` in basicConstraints. | +| `--cert-name ` | No (default: `package-route-custom-ca`) | Base name for `/etc/pki/ca-trust/source/anchors/.crt`. Must match `[A-Za-z0-9._-]+`. | +| `-h`, `--help` | — | Usage. | + +### Examples + +```bash +sudo ./install_certs_jvm_rhel.sh --use-cert /tmp/ZscalerRoot0.pem +sudo ./install_certs_jvm_rhel.sh --use-cert /tmp/ZscalerRoot0.pem --cert-name zscaler-root +``` + +### Validation: validate_certs_jvm_rhel.sh + +```bash +./validate_certs_jvm_rhel.sh --expected-subject "O=Zscaler" +./validate_certs_jvm_rhel.sh --expected-subject "O=Zscaler" --cert-name zscaler-root +``` + +The validator checks the installed anchor PEM and `/etc/pki/ca-trust/extracted/java/cacerts`. + +### Testing + +`./testing/test_install_certs_jvm_linux.sh` runs the Docker smoke matrix across generic Linux and RHEL flows. Each container builds a lab CA; generic Linux tests build a bundled truststore fixture, while the RHEL test exercises the PEM/update-ca-trust path. + +```bash +# From repo root (any host with Docker) +./testing/test_install_certs_jvm_linux.sh +``` + +Exit code 0 if all containers pass, 1 if any test fails. + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-linux-jvm` job). + +### Summary (Linux JVM) + +- Use `install_certs_jvm_linux.sh` for bundled JKS + `JAVA_TOOL_OPTIONS`. +- Use `install_certs_jvm_rhel.sh` for RHEL-family `update-ca-trust`. +- Use `build_jvm_truststore_linux.sh` only for the generic bundled-JKS flow; RHEL uses the PEM directly. +- Linux JVM scripts are self-contained; there is no shared `_jvm_linux_paths.sh`. +- Users of the generic flow must open a new login shell (or `source /etc/environment`) for env changes to take effect. `gradle --stop` refreshes the Gradle Daemon. + +--- + ## Windows: install_certs_windows.ps1 ### Overview @@ -444,6 +700,95 @@ Use a substring from your CA subject as `` (find it with `op --- +## Windows (JVM): install_certs_jvm_windows.ps1 + +### Overview + +`install_certs_jvm_windows.ps1` wires a bundled JVM truststore into the JVM trust path on Windows so Maven, Gradle, sbt, and Apache Ivy traffic redirected through `package-reroute` validates correctly. **JVM trust only** — does not configure Node/npm or Python, and does not touch Docker credentials. Pair with `install_certs_windows.ps1` if you need those. + +Single path on Windows — there is no OS-trust fallback. `-Djavax.net.ssl.trustStoreType=Windows-ROOT` was historically broken under Gradle ([gradle/gradle#6584](https://github.com/gradle/gradle/issues/6584), fixed in Gradle 8.3) and remains less uniform than the JKS recipe across our supported toolchains. The script: + +1. Copies the supplied JKS truststore to `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks`. +2. Sets `JAVA_TOOL_OPTIONS` at **User** scope via `[Environment]::SetEnvironmentVariable(…, 'User')`, which writes `HKCU\Environment` and broadcasts `WM_SETTINGCHANGE`. New JVM processes started after the broadcast inherit the env var; daemons and long-running IDEs need a fresh session. + +**No Administrator required** — the User scope writes to `HKCU\Environment` without elevation, and `%LOCALAPPDATA%` is per-user. + +### Requirements + +- **Windows** with PowerShell 5.1+. + +### Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `-UseTruststore ` | **Yes** | Path to an existing JVM truststore (JKS/PKCS12-compatible). The truststore is copied as-is and must be readable by JVMs with password `changeit`. | + +No `-AllUsers` (User-scope env var is per-user by construction; each developer runs the installer in their own session). No `-Mode` — we ship only the JKS + `JAVA_TOOL_OPTIONS` recipe; see Caveats for why Windows-ROOT is not exposed. + +### Examples + +```powershell +# Single user +powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore C:\tmp\package-route-truststore.jks +``` + +### Validation: validate_certs_jvm_windows.ps1 + +**`-ExpectedSubject` is required.** Asserts: +- JKS file exists at the per-user path. +- `keytool -list -v` shows an `Owner:` line matching the substring (case-insensitive). +- `[Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', 'User')` returns a value referencing the expected JKS path. + +```powershell +powershell -ExecutionPolicy Bypass -File validate_certs_jvm_windows.ps1 -ExpectedSubject "O=Zscaler" +``` + +Exit code 0 if all checks pass, 1 otherwise. Result line is qualified with a count of any non-fatal warnings. + +### Building the bundled truststore + +`build_jvm_truststore_windows.ps1` creates the `-UseTruststore` input for the Windows JVM installer. It imports Windows `LocalMachine\Root` certificates into a JKS truststore, then imports your supplied PEM CA under alias `package-route-custom-ca`. + +```powershell +powershell -ExecutionPolicy Bypass -File .\build_jvm_truststore_windows.ps1 ` + -UseCert C:\tmp\ZscalerRoot0.pem ` + -Output C:\tmp\package-route-truststore.jks + +powershell -ExecutionPolicy Bypass -File .\install_certs_jvm_windows.ps1 ` + -UseTruststore C:\tmp\package-route-truststore.jks +``` + +### Caveats + +- **New sessions only.** `WM_SETTINGCHANGE` reaches Explorer and a few shells but most JVM-launching processes (Gradle Daemon, IntelliJ, Maven via the wrapper) cache their environment at startup. Open a new PowerShell/cmd or log off/on after install. +- **Gradle Daemon caching.** Run `gradle --stop` after onboarding so the daemon re-reads `JAVA_TOOL_OPTIONS` at next start. +- **`Picked up JAVA_TOOL_OPTIONS:` banner.** Every JVM startup prints this to stderr. CI parsers that strict-match empty-stderr need to tolerate it. +- **`changeit` truststore password.** OpenJDK convention; *not* a secret. The JKS holds only public CA certificates and the password protects file integrity, not contents. +- **The supplied JKS must include public roots.** `-Djavax.net.ssl.trustStore=…` in OpenJDK *replaces* the JVM trust source — a JKS containing only the corporate CA would break every public-CA TLS handshake (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). The release process that creates the bundled truststore owns including public roots plus the corporate CA before install. +- **Windows-ROOT trustStoreType is excluded by design.** `-Djavax.net.ssl.trustStoreType=Windows-ROOT` would point JVMs at the system Trusted Root store directly. The Gradle Daemon stale-snapshot bug that historically made this unsafe (gradle/gradle#6584) was fixed in Gradle 8.3 via [gradle/gradle#25106](https://github.com/gradle/gradle/pull/25106), but we still ship only the JKS+`JAVA_TOOL_OPTIONS` recipe so that (a) the trust source is uniform across Linux/macOS/Windows, and (b) developers on Gradle < 8.3 are not silently affected. A future ticket could add a `-TrustStoreType Windows-ROOT` flag for organisations standardised on Gradle ≥ 8.3. +- **Machine scope is excluded.** v1 is User-scope only. Fleet/Intune rollouts that need `HKLM\Environment` should re-run the script per user via a logon script or use a future `-Scope Machine` flag (separate ticket). +- **`%USERPROFILE%\.gradle\jdks\`** is Gradle's auto-provisioned JDK location. It's covered for free because `JAVA_TOOL_OPTIONS` is read by *every* JVM the user launches, regardless of where the JDK came from — subject to the Gradle Daemon caveat above (the daemon caches its environment at startup, so a newly-provisioned toolchain JDK only picks up `JAVA_TOOL_OPTIONS` after `gradle --stop`). + +### Testing + +`./testing/test_install_certs_jvm_windows.ps1` runs the smoke matrix on `windows-latest` in CI and locally. Each run targets the current user's `%LOCALAPPDATA%` + `HKCU`, builds a bundled truststore fixture, and cleans up via try/finally. + +```powershell +powershell -ExecutionPolicy Bypass -File testing\test_install_certs_jvm_windows.ps1 +``` + +The same matrix runs on every push and pull request via `.github/workflows/ci.yml` (`test-windows-jvm` job). + +### Summary (Windows JVM) + +- **No Administrator required.** Single truststore source via `-UseTruststore`. +- Per-user JKS at `%LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks`. +- User-scope `JAVA_TOOL_OPTIONS` in `HKCU\Environment`, broadcast via `WM_SETTINGCHANGE`. +- **Idempotent**, **re-runnable**, **JDK-version-agnostic** across currently-supported JDKs (JKS format is still read by JDK 8–25; a future JDK that drops JKS support would require an installer-side format bump). New JDK installs do not require re-running the script. +- New sessions for activation; `gradle --stop` for the Gradle Daemon. + +--- + ## Continuous integration On **push** and **pull request** to `main` or `master`, GitHub Actions runs: @@ -451,6 +796,9 @@ On **push** and **pull request** to `main` or `master`, GitHub Actions runs: | Job | Runner | Command | |-----|--------|---------| | Test (macOS) | `macos-latest` | `sudo ./testing/test_install_certs_macos.sh` | +| Test (macOS JVM) | `macos-latest` | `sudo ./testing/test_install_certs_jvm_macos.sh` | | Test (Windows) | `windows-latest` | `./testing/test_install_certs_windows.ps1` (PowerShell) | +| Test (Windows JVM) | `windows-latest` | `./testing/test_install_certs_jvm_windows.ps1` (PowerShell) | +| Test (Linux JVM) | `ubuntu-latest` | `./testing/test_install_certs_jvm_linux.sh` | There is no CI job for the Debian/Ubuntu scripts in this workflow. diff --git a/build_jvm_truststore_linux.sh b/build_jvm_truststore_linux.sh new file mode 100755 index 0000000..8737df4 --- /dev/null +++ b/build_jvm_truststore_linux.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from the Linux system CA bundle plus one custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require root. +# +# Run: +# ./build_jvm_truststore_linux.sh --use-cert /path/to/company-ca.pem --output /tmp/package-route-truststore.jks + +set -euo pipefail + +JKS_PASSWORD="changeit" +DEFAULT_CERT_ALIAS="package-route-custom-ca" + +USE_CERT="" +OUTPUT="" +CERT_ALIAS="$DEFAULT_CERT_ALIAS" +SYSTEM_BUNDLE="" +OPENSSL_BIN="${OPENSSL:-openssl}" + +usage() { + cat < --output [--cert-alias ] [--system-bundle ] + +Options: + --use-cert PEM certificate to add to the truststore. Must contain + exactly one non-expired CA certificate with CA:TRUE. + --output Destination truststore path. Replaced atomically after + successful build. + --cert-alias Alias for the custom CA (default: ${DEFAULT_CERT_ALIAS}). + --system-bundle Override the detected Linux system CA PEM bundle. + -h, --help Show this help. + +The generated truststore uses password '${JKS_PASSWORD}'. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --output) + OUTPUT="${2:?Error: --output requires a value}" + shift 2 + ;; + --cert-alias) + CERT_ALIAS="${2:?Error: --cert-alias requires a value}" + shift 2 + ;; + --system-bundle) + SYSTEM_BUNDLE="${2:?Error: --system-bundle requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + [[ -n "$USE_CERT" ]] || { echo "Error: --use-cert is required." >&2; usage >&2; exit 1; } + [[ -n "$OUTPUT" ]] || { echo "Error: --output is required." >&2; usage >&2; exit 1; } + [[ "$CERT_ALIAS" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "Error: --cert-alias must match [A-Za-z0-9._-]+ (got: $CERT_ALIAS)." >&2 + exit 1 + } +} + +check_dependencies() { + command -v keytool >/dev/null 2>&1 || { echo "Error: keytool is required." >&2; exit 1; } + command -v "$OPENSSL_BIN" >/dev/null 2>&1 || { echo "Error: openssl is required." >&2; exit 1; } +} + +detect_system_bundle() { + if [[ -n "$SYSTEM_BUNDLE" ]]; then + [[ -f "$SYSTEM_BUNDLE" && -r "$SYSTEM_BUNDLE" && -s "$SYSTEM_BUNDLE" ]] || { + echo "Error: --system-bundle must point to a readable non-empty file: $SYSTEM_BUNDLE" >&2 + exit 1 + } + echo "$SYSTEM_BUNDLE" + return 0 + fi + + local candidate + for candidate in \ + /etc/ssl/certs/ca-certificates.crt \ + /etc/pki/tls/certs/ca-bundle.crt \ + /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \ + /etc/ssl/ca-bundle.pem; do + if [[ -f "$candidate" && -r "$candidate" && -s "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + + echo "Error: could not find a Linux system CA bundle. Pass --system-bundle ." >&2 + exit 1 +} + +validate_custom_pem() { + local path="$1" count bc + + [[ -f "$path" && -r "$path" && -s "$path" ]] || { + echo "Error: --use-cert must point to a readable non-empty file: $path" >&2 + exit 1 + } + + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || true)" + if [[ "$count" -ne 1 ]]; then + echo "Error: --use-cert must contain exactly one PEM certificate (found $count): $path" >&2 + exit 1 + fi + "$OPENSSL_BIN" x509 -in "$path" -noout >/dev/null 2>&1 || { + echo "Error: invalid PEM certificate: $path" >&2 + exit 1 + } + "$OPENSSL_BIN" x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1 || { + echo "Error: certificate has already expired: $path" >&2 + exit 1 + } + bc="$("$OPENSSL_BIN" x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + exit 1 + fi +} + +split_pem_bundle() { + local bundle="$1" out_dir="$2" + awk -v dir="$out_dir" ' + /-----BEGIN CERTIFICATE-----/ { n++; file=sprintf("%s/cert-%05d.pem", dir, n) } + file != "" { print > file } + /-----END CERTIFICATE-----/ { file="" } + ' "$bundle" +} + +cert_fingerprint() { + "$OPENSSL_BIN" x509 -in "$1" -noout -fingerprint -sha256 \ + | sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]' +} + +import_cert() { + local cert="$1" alias="$2" truststore="$3" keytool_out + + if ! keytool_out="$(keytool -importcert -noprompt -storetype JKS \ + -alias "$alias" \ + -file "$cert" \ + -keystore "$truststore" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool failed while importing $cert as $alias. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi +} + +build_truststore() { + local system_bundle="$1" tmpdir cert fp imported_count=0 tmp_store seen + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + mkdir -p "$tmpdir/system" + seen="$tmpdir/seen-fingerprints.txt" + : > "$seen" + tmp_store="$tmpdir/truststore.jks" + + split_pem_bundle "$system_bundle" "$tmpdir/system" + for cert in "$tmpdir"/system/*.pem; do + [[ -s "$cert" ]] || continue + if ! "$OPENSSL_BIN" x509 -in "$cert" -noout >/dev/null 2>&1; then + continue + fi + fp="$(cert_fingerprint "$cert")" + if grep -qx "$fp" "$seen"; then + continue + fi + printf '%s\n' "$fp" >> "$seen" + import_cert "$cert" "system-$fp" "$tmp_store" + imported_count=$((imported_count + 1)) + done + + if [[ "$imported_count" -eq 0 ]]; then + echo "Error: no certificates could be imported from system bundle: $system_bundle" >&2 + exit 1 + fi + + import_cert "$USE_CERT" "$CERT_ALIAS" "$tmp_store" + + mkdir -p "$(dirname "$OUTPUT")" + mv "$tmp_store" "$OUTPUT" + chmod 0644 "$OUTPUT" + + echo "Built JVM truststore:" + echo " $OUTPUT" + echo "System bundle:" + echo " $system_bundle" + echo "Imported system certificates:" + echo " $imported_count" + echo "Custom CA alias:" + echo " $CERT_ALIAS" +} + +main() { + parse_args "$@" + check_dependencies + validate_custom_pem "$USE_CERT" + + local system_bundle + system_bundle="$(detect_system_bundle)" + build_truststore "$system_bundle" +} + +main "$@" diff --git a/build_jvm_truststore_macos.sh b/build_jvm_truststore_macos.sh new file mode 100755 index 0000000..66796cf --- /dev/null +++ b/build_jvm_truststore_macos.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from macOS system keychains plus one custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require root. +# +# Run: +# ./build_jvm_truststore_macos.sh --use-cert /path/to/company-ca.pem --output /tmp/package-route-truststore.jks + +set -euo pipefail + +JKS_PASSWORD="changeit" +DEFAULT_CERT_ALIAS="package-route-custom-ca" + +USE_CERT="" +OUTPUT="" +CERT_ALIAS="$DEFAULT_CERT_ALIAS" +OPENSSL_BIN="${OPENSSL:-openssl}" + +usage() { + cat < --output [--cert-alias ] + +Options: + --use-cert PEM certificate to add to the truststore. Must contain + exactly one non-expired CA certificate with CA:TRUE. + --output Destination truststore path. Replaced atomically after + successful build. + --cert-alias Alias for the custom CA (default: ${DEFAULT_CERT_ALIAS}). + -h, --help Show this help. + +The generated truststore uses password '${JKS_PASSWORD}'. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --output) + OUTPUT="${2:?Error: --output requires a value}" + shift 2 + ;; + --cert-alias) + CERT_ALIAS="${2:?Error: --cert-alias requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + [[ -n "$USE_CERT" ]] || { echo "Error: --use-cert is required." >&2; usage >&2; exit 1; } + [[ -n "$OUTPUT" ]] || { echo "Error: --output is required." >&2; usage >&2; exit 1; } + [[ "$CERT_ALIAS" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "Error: --cert-alias must match [A-Za-z0-9._-]+ (got: $CERT_ALIAS)." >&2 + exit 1 + } +} + +check_os() { + if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Error: this script supports macOS only." >&2 + exit 1 + fi +} + +check_dependencies() { + command -v keytool >/dev/null 2>&1 || { echo "Error: keytool is required." >&2; exit 1; } + command -v "$OPENSSL_BIN" >/dev/null 2>&1 || { echo "Error: openssl is required." >&2; exit 1; } + command -v security >/dev/null 2>&1 || { echo "Error: macOS security tool is required." >&2; exit 1; } +} + +validate_custom_pem() { + local path="$1" count bc + + [[ -f "$path" && -r "$path" && -s "$path" ]] || { + echo "Error: --use-cert must point to a readable non-empty file: $path" >&2 + exit 1 + } + + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || true)" + if [[ "$count" -ne 1 ]]; then + echo "Error: --use-cert must contain exactly one PEM certificate (found $count): $path" >&2 + exit 1 + fi + "$OPENSSL_BIN" x509 -in "$path" -noout >/dev/null 2>&1 || { + echo "Error: invalid PEM certificate: $path" >&2 + exit 1 + } + "$OPENSSL_BIN" x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1 || { + echo "Error: certificate has already expired: $path" >&2 + exit 1 + } + bc="$("$OPENSSL_BIN" x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + exit 1 + fi +} + +export_system_keychains() { + local output="$1" + local keychains=( + "/System/Library/Keychains/SystemRootCertificates.keychain" + "/Library/Keychains/System.keychain" + ) + local existing=() keychain + + for keychain in "${keychains[@]}"; do + if [[ -e "$keychain" ]]; then + existing+=("$keychain") + fi + done + if [[ "${#existing[@]}" -eq 0 ]]; then + echo "Error: no macOS system keychains found." >&2 + exit 1 + fi + + security find-certificate -a -p "${existing[@]}" > "$output" + if [[ ! -s "$output" ]]; then + echo "Error: macOS system keychain export produced no certificates." >&2 + exit 1 + fi +} + +split_pem_bundle() { + local bundle="$1" out_dir="$2" + awk -v dir="$out_dir" ' + /-----BEGIN CERTIFICATE-----/ { n++; file=sprintf("%s/cert-%05d.pem", dir, n) } + file != "" { print > file } + /-----END CERTIFICATE-----/ { file="" } + ' "$bundle" +} + +cert_fingerprint() { + "$OPENSSL_BIN" x509 -in "$1" -noout -fingerprint -sha256 \ + | sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]' +} + +import_cert() { + local cert="$1" alias="$2" truststore="$3" keytool_out + + if ! keytool_out="$(keytool -importcert -noprompt -storetype JKS \ + -alias "$alias" \ + -file "$cert" \ + -keystore "$truststore" \ + -storepass "$JKS_PASSWORD" 2>&1)"; then + echo "Error: keytool failed while importing $cert as $alias. Output:" >&2 + printf '%s\n' "$keytool_out" | sed 's/^/ /' >&2 + exit 1 + fi +} + +build_truststore() { + local tmpdir cert fp imported_count=0 tmp_store seen system_bundle + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + mkdir -p "$tmpdir/system" + seen="$tmpdir/seen-fingerprints.txt" + : > "$seen" + system_bundle="$tmpdir/macos-system-certs.pem" + tmp_store="$tmpdir/truststore.jks" + + export_system_keychains "$system_bundle" + split_pem_bundle "$system_bundle" "$tmpdir/system" + + for cert in "$tmpdir"/system/*.pem; do + [[ -s "$cert" ]] || continue + if ! "$OPENSSL_BIN" x509 -in "$cert" -noout >/dev/null 2>&1; then + continue + fi + fp="$(cert_fingerprint "$cert")" + if grep -qx "$fp" "$seen"; then + continue + fi + printf '%s\n' "$fp" >> "$seen" + import_cert "$cert" "system-$fp" "$tmp_store" + imported_count=$((imported_count + 1)) + done + + if [[ "$imported_count" -eq 0 ]]; then + echo "Error: no certificates could be imported from macOS system keychains." >&2 + exit 1 + fi + + import_cert "$USE_CERT" "$CERT_ALIAS" "$tmp_store" + + mkdir -p "$(dirname "$OUTPUT")" + mv "$tmp_store" "$OUTPUT" + chmod 0644 "$OUTPUT" + + echo "Built JVM truststore:" + echo " $OUTPUT" + echo "Imported macOS system certificates:" + echo " $imported_count" + echo "Custom CA alias:" + echo " $CERT_ALIAS" +} + +main() { + parse_args "$@" + check_os + check_dependencies + validate_custom_pem "$USE_CERT" + build_truststore +} + +main "$@" diff --git a/build_jvm_truststore_windows.ps1 b/build_jvm_truststore_windows.ps1 new file mode 100644 index 0000000..84c8802 --- /dev/null +++ b/build_jvm_truststore_windows.ps1 @@ -0,0 +1,206 @@ +# (c) JFrog Ltd. (2026) +# Build a JVM truststore from Windows LocalMachine root certificates plus one +# custom CA PEM. +# +# This is a build-time helper for the JVM installers. It does not install +# anything and does not require Administrator rights for normal LocalMachine +# Root read access. +# +# Run: +# powershell -ExecutionPolicy Bypass -File .\build_jvm_truststore_windows.ps1 -UseCert C:\path\company-ca.pem -Output C:\Temp\package-route-truststore.jks + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$UseCert, + + [Parameter(Mandatory = $true)] + [string]$Output, + + [Parameter(Mandatory = $false)] + [string]$CertAlias = 'package-route-custom-ca' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$JksPassword = 'changeit' + +function Fail { + param([string]$Message) + Write-Error $Message + exit 1 +} + +function Get-Keytool { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + Fail 'keytool.exe is required. Install a JDK or set JAVA_HOME.' +} + +function Read-PemCertificate { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Fail "Certificate file not found: $Path" + } + + $raw = Get-Content -LiteralPath $Path -Raw + $matches = [regex]::Matches( + $raw, + '-----BEGIN CERTIFICATE-----\s*(?[A-Za-z0-9+/=\r\n]+?)\s*-----END CERTIFICATE-----' + ) + if ($matches.Count -ne 1) { + Fail "UseCert must contain exactly one PEM certificate (found $($matches.Count)): $Path" + } + + try { + $base64 = ($matches[0].Groups['body'].Value -replace '\s', '') + $bytes = [Convert]::FromBase64String($base64) + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) + } catch { + Fail "Invalid PEM certificate: $Path ($($_.Exception.Message))" + } +} + +function Test-CustomCaCertificate { + param( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert, + [string]$Path + ) + + if ($Cert.NotAfter -le (Get-Date)) { + Fail "Certificate has already expired: $Path" + } + + $basic = $Cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' } | Select-Object -First 1 + if (-not $basic) { + Fail "Certificate is not a CA (basicConstraints missing CA:TRUE): $Path" + } + + $constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $basic, + $basic.Critical + ) + if (-not $constraints.CertificateAuthority) { + Fail "Certificate is not a CA (basicConstraints missing CA:TRUE): $Path" + } +} + +function Invoke-Keytool { + param( + [string]$Keytool, + [string[]]$KeytoolArgs + ) + + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $rc = 0 + try { + $out = & $Keytool @KeytoolArgs 2>&1 + $rc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEap + } + + if ($rc -ne 0) { + $head = ($out | Select-Object -First 5) -join '; ' + Fail "keytool exited $rc. Output: $head" + } +} + +function Import-CertToTruststore { + param( + [string]$Keytool, + [string]$CertPath, + [string]$Alias, + [string]$TruststorePath + ) + + Invoke-Keytool -Keytool $Keytool -KeytoolArgs @( + '-importcert', '-noprompt', + '-storetype', 'JKS', + '-alias', $Alias, + '-file', $CertPath, + '-keystore', $TruststorePath, + '-storepass', $JksPassword + ) +} + +function Get-SystemRootCertificates { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( + [System.Security.Cryptography.X509Certificates.StoreName]::Root, + [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine + ) + + try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + return @($store.Certificates | Where-Object { $_.NotAfter -gt (Get-Date) }) + } finally { + $store.Close() + } +} + +if ($CertAlias -notmatch '^[A-Za-z0-9._-]+$') { + Fail "CertAlias must match [A-Za-z0-9._-]+ (got: $CertAlias)." +} + +$customCert = Read-PemCertificate -Path $UseCert +Test-CustomCaCertificate -Cert $customCert -Path $UseCert + +$keytool = Get-Keytool +$outputParent = Split-Path -Parent $Output +if ($outputParent) { + New-Item -ItemType Directory -Path $outputParent -Force | Out-Null +} + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("package-route-jvm-build-{0}" -f ([Guid]::NewGuid().ToString('N'))) +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + +try { + $tempStore = Join-Path $tempDir 'truststore.jks' + $seen = New-Object 'System.Collections.Generic.HashSet[string]' + $imported = 0 + + foreach ($cert in (Get-SystemRootCertificates)) { + $thumb = $cert.Thumbprint.ToLowerInvariant() + if (-not $seen.Add($thumb)) { + continue + } + + $certPath = Join-Path $tempDir ("system-{0}.cer" -f $thumb) + [System.IO.File]::WriteAllBytes( + $certPath, + $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + ) + Import-CertToTruststore -Keytool $keytool -CertPath $certPath -Alias "system-$thumb" -TruststorePath $tempStore + $imported++ + } + + if ($imported -eq 0) { + Fail 'No certificates could be imported from LocalMachine\Root.' + } + + Import-CertToTruststore -Keytool $keytool -CertPath $UseCert -Alias $CertAlias -TruststorePath $tempStore + Move-Item -LiteralPath $tempStore -Destination $Output -Force + + Write-Host 'Built JVM truststore:' + Write-Host " $Output" + Write-Host 'Imported Windows LocalMachine root certificates:' + Write-Host " $imported" + Write-Host 'Custom CA alias:' + Write-Host " $CertAlias" + Write-Host "Truststore password: $JksPassword" +} finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/install_certs_jvm_linux.sh b/install_certs_jvm_linux.sh new file mode 100755 index 0000000..6d3f887 --- /dev/null +++ b/install_certs_jvm_linux.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a bundled JVM truststore on Linux for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). +# +# Single path: copy a supplied JKS truststore to +# /etc/ssl/package-route-jvm/truststore.jks +# then set JAVA_TOOL_OPTIONS in /etc/environment so every new JVM startup +# inherits the trustStore path. +# +# Run: +# sudo bash install_certs_jvm_linux.sh --use-truststore /path/to/truststore.jks +# +# Notes: +# - Linux only. +# - Must run as root. +# - JVM trust only — does not configure npm/Python/HF and does not touch +# Docker credentials. Pair with install_certs_debian_ubuntu.sh if needed. +# - GUI-launched IDEs need a logoff/login to pick up /etc/environment. +# - RHEL-family hosts that intentionally use Red Hat OpenJDK system trust +# should use install_certs_jvm_rhel.sh instead. + +set -euo pipefail + +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_DIR="/etc/ssl/package-route-jvm" +JKS_PATH="${JKS_DIR}/truststore.jks" +JKS_PASSWORD="changeit" +ENVIRONMENT_FILE="/etc/environment" + +USE_TRUSTSTORE="" +RC_UPDATED=0 + +usage() { + cat < + +Options: + --use-truststore Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy to ${JKS_PATH}. The truststore must be + readable by JVMs with password '${JKS_PASSWORD}'. + -h, --help Show this help. + +Examples: + sudo $0 --use-truststore /tmp/package-route-truststore.jks +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-truststore " >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-truststore) + USE_TRUSTSTORE="${2:?Error: --use-truststore requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_TRUSTSTORE" ]]; then + echo "Error: --use-truststore is required." >&2 + usage >&2 + exit 1 + fi + if [[ ! -f "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file not found: $USE_TRUSTSTORE" >&2 + exit 1 + fi + if [[ ! -r "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is not readable: $USE_TRUSTSTORE" >&2 + exit 1 + fi + if [[ ! -s "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is empty: $USE_TRUSTSTORE" >&2 + exit 1 + fi +} + +check_os() { + if [[ "$(uname -s)" != "Linux" ]]; then + echo "Error: this script supports Linux only." >&2 + exit 1 + fi +} + +replace_export_in_file() { + local file="$1" var="$2" value="$3" + local tmp escaped + + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + + tmp=$(mktemp -p "$(dirname "$file")") + awk -v var="$var" -v val="$escaped" ' + $0 ~ "^export " var "=" { print "export " var "=\"" val "\""; next } + { print } + ' "$file" > "$tmp" + if [[ ! -s "$tmp" && -s "$file" ]]; then + rm -f "$tmp" + echo "Error: awk produced empty output; refusing to overwrite $file" >&2 + exit 1 + fi + mv "$tmp" "$file" +} + +ensure_export_in_file() { + local file="$1" var="$2" value="$3" + + touch "$file" + if grep -qE "^export ${var}=" "$file" 2>/dev/null; then + replace_export_in_file "$file" "$var" "$value" + else + printf 'export %s="%s"\n' "$var" "$value" >> "$file" + fi +} + +ensure_kv_in_environment_file() { + local key="$1" value="$2" + local tmp escaped + + touch "$ENVIRONMENT_FILE" + + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + + if grep -qE "^${key}=" "$ENVIRONMENT_FILE" 2>/dev/null; then + tmp=$(mktemp -p "$(dirname "$ENVIRONMENT_FILE")") + awk -v k="$key" -v v="$escaped" ' + $0 ~ "^" k "=" { print k "=\"" v "\""; next } + { print } + ' "$ENVIRONMENT_FILE" > "$tmp" + if [[ ! -s "$tmp" && -s "$ENVIRONMENT_FILE" ]]; then + rm -f "$tmp" + echo "Error: awk produced empty output; refusing to overwrite $ENVIRONMENT_FILE" >&2 + exit 1 + fi + mv "$tmp" "$ENVIRONMENT_FILE" + else + printf '%s="%s"\n' "$key" "$escaped" >> "$ENVIRONMENT_FILE" + fi + + chmod 0644 "$ENVIRONMENT_FILE" +} + +get_target_user() { + local candidate + + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + candidate="$SUDO_USER" + else + candidate="$(logname 2>/dev/null || true)" + if [[ -z "$candidate" || "$candidate" == "root" ]]; then + if command -v loginctl >/dev/null 2>&1; then + candidate="$(loginctl list-sessions --no-legend 2>/dev/null | awk ' + $2 >= 1000 && $3 != "root" && $4 == "seat0" { print $3; found=1; exit } + $2 >= 1000 && $3 != "root" && !fallback { fallback=$3 } + END { if (!found && fallback) print fallback } + ')" + fi + fi + fi + + if [[ -z "$candidate" || "$candidate" == "root" ]]; then + return 0 + fi + if ! getent passwd "$candidate" >/dev/null 2>&1; then + return 0 + fi + + echo "$candidate" +} + +get_user_home() { + local user="$1" + getent passwd "$user" | cut -d: -f6 +} + +get_user_shell() { + local user="$1" + getent passwd "$user" | cut -d: -f7 +} + +update_user_shell_rc() { + local jto_value="$1" + local target_user user_home user_shell rc_file chown_err + + target_user="$(get_target_user)" + if [[ -z "$target_user" || "$target_user" == "root" ]]; then + echo " [warn] Per-user rc not updated: could not determine non-root target user." >&2 + echo " /etc/environment is written and will reach new login sessions." >&2 + return 0 + fi + + user_home="$(get_user_home "$target_user")" + if [[ -z "$user_home" || ! -d "$user_home" ]]; then + echo " [warn] Per-user rc not updated: home not found for $target_user." >&2 + return 0 + fi + + user_shell="$(get_user_shell "$target_user")" + case "$user_shell" in + */zsh) rc_file="$user_home/.zshrc" ;; + *) rc_file="$user_home/.bashrc" ;; + esac + + touch "$rc_file" + ensure_export_in_file "$rc_file" "JAVA_TOOL_OPTIONS" "$jto_value" + if ! chown_err="$(chown "$target_user":"$target_user" "$rc_file" 2>&1)"; then + echo " [warn] chown failed on $rc_file: $chown_err" >&2 + fi + + echo " Updated $rc_file" + RC_UPDATED=1 +} + +install_truststore() { + echo "[1/4] Installing truststore at $JKS_PATH..." + mkdir -p "$JKS_DIR" + chmod 0755 "$JKS_DIR" + cp "$USE_TRUSTSTORE" "$JKS_PATH" + chmod 0644 "$JKS_PATH" +} + +main() { + require_root + parse_args "$@" + check_os + + install_truststore + + local jto_value="-Djavax.net.ssl.trustStore=${JKS_PATH} -Djavax.net.ssl.trustStorePassword=${JKS_PASSWORD}" + + echo "[2/4] Writing JAVA_TOOL_OPTIONS to $ENVIRONMENT_FILE..." + ensure_kv_in_environment_file "JAVA_TOOL_OPTIONS" "$jto_value" + + echo "[3/4] Updating target user's shell rc file..." + update_user_shell_rc "$jto_value" + + echo "[4/4] Done." + echo + echo "Truststore:" + echo " $JKS_PATH" + echo "JAVA_TOOL_OPTIONS:" + echo " $jto_value" + echo + echo "Notes:" + echo " - Log out and log back in for GDM/KDM-launched IDEs to pick up JAVA_TOOL_OPTIONS." + echo " - Run 'gradle --stop' to refresh the Gradle Daemon if one was already running." + echo " - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected." + + if [[ "$RC_UPDATED" -eq 0 ]]; then + echo + echo "WARNING: per-user shell rc was NOT updated; existing shells of the developer user" + echo " will not see JAVA_TOOL_OPTIONS until they log out and back in (or source" + echo " /etc/environment manually). The system-wide change in $ENVIRONMENT_FILE" + echo " takes effect on the next fresh login." >&2 + fi +} + +main "$@" diff --git a/install_certs_jvm_macos.sh b/install_certs_jvm_macos.sh new file mode 100755 index 0000000..784899d --- /dev/null +++ b/install_certs_jvm_macos.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a bundled JVM truststore on macOS for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). +# +# Single path: copy a supplied JKS truststore to +# ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# then install a per-user LaunchAgent at +# ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# that runs `launchctl setenv JAVA_TOOL_OPTIONS=…` at RunAtLoad. This is the +# ONLY recipe that reaches Dock-launched IDE builds — the ~/.zshrc shortcut +# silently fails for GUI-spawned subprocesses, and macOS-specific +# KeychainStore is broken per JDK-8321045. +# +# Run: +# sudo bash install_certs_jvm_macos.sh --use-truststore /path/to/truststore.jks +# [--all-users] +# +# Notes: +# - macOS only. +# - Must run as root (so per-user files can be chown'd to the target user). +# - JVM trust only — does not configure npm/Python/HF and does not touch +# Docker credentials. Pair with install_certs_macos.sh if you need those. +# - Existing Dock-launched apps must be restarted after install: macOS does +# not re-poll the launchd domain env on app relaunch unless the agent +# was bootstrapped before app launch. +# - JAMF / kiosk caveat: gui/ is the GUI-session launchd domain. On a +# host with NO user logged in (mac-mini in a rack, fresh JAMF bootstrap +# before first login), `launchctl bootstrap gui/` either fails or +# loads into a non-running domain. In that case the agent loads on next +# interactive login. Operators provisioning headless machines should +# also seed the JKS via a separate non-GUI mechanism (e.g. system-wide +# /Library/LaunchDaemons running as root) before any user logs in. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_linux.sh — bundled JKS + JAVA_TOOL_OPTIONS +# install_certs_jvm_rhel.sh — RHEL update-ca-trust +# install_certs_jvm_windows.ps1 — HKCU\Environment + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" +JKS_PASSWORD="changeit" + +USE_TRUSTSTORE="" +ALL_USERS=0 + +usage() { + cat < [--all-users] + +Options: + --use-truststore + Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy into each target user's fixed JKS location. + The truststore must be readable by JVMs with password + '${JKS_PASSWORD}'. + --all-users Iterate /Users/* (UID >= 501, skip Shared) and install + a LaunchAgent + JKS for every account. Default = only + SUDO_USER (or the console-user under JAMF). + -h, --help Show this help. + +Note: unlike the Linux sibling, macOS has only one install path (JKS + +per-user LaunchAgent setting JAVA_TOOL_OPTIONS). There is no --mode flag +because the KeychainStore truststoreType is broken (JDK-8321045) and no +OS-trust fallback exists. + +Examples: + sudo $0 --use-truststore /tmp/package-route-truststore.jks + sudo $0 --use-truststore /tmp/package-route-truststore.jks --all-users +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-truststore [--all-users]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-truststore) + USE_TRUSTSTORE="${2:?Error: --use-truststore requires a value}" + shift 2 + ;; + --all-users) + ALL_USERS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_TRUSTSTORE" ]]; then + echo "Error: --use-truststore is required." >&2 + usage >&2 + exit 1 + fi + + if [[ ! -f "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file not found: $USE_TRUSTSTORE" >&2 + exit 1 + fi + + if [[ ! -r "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is not readable: $USE_TRUSTSTORE" >&2 + exit 1 + fi + + if [[ ! -s "$USE_TRUSTSTORE" ]]; then + echo "Error: truststore file is empty: $USE_TRUSTSTORE" >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +jks_path_for_user() { + local user_home="$1" + echo "${user_home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" +} + +install_truststore_for_user() { + local target_user="$1" user_home="$2" + local jks_dir="${user_home}/${JKS_RELATIVE_DIR}" + local jks_path="${jks_dir}/${JKS_BASENAME}" + + echo " [JKS] Installing truststore at $jks_path" + + # macOS mkdir -p will create the intermediate "Application Support" / + # "JFrog" / "package-route-jvm" tree if missing. Quote the path because + # "Application Support" contains a space. + mkdir -p "$jks_dir" + + # The installer deliberately treats the supplied truststore as final. The + # release process that builds it owns root selection and CA contents. + cp "$USE_TRUSTSTORE" "$jks_path" + + chmod 0755 "$jks_dir" + chmod 0644 "$jks_path" + + # Hand ownership back to the target user so they (and their LaunchAgent + # running in gui/) can read it without needing sudo to manage it. + # A silent chown failure would leave root-owned files in $target_user's + # home — launchd refuses to load root-owned LaunchAgents, so the install + # would appear to succeed and never activate. + local chown_err + if ! chown_err="$(chown -R "$target_user" "$jks_dir" 2>&1)"; then + echo "Error: chown $target_user $jks_dir failed: $chown_err" >&2 + exit 1 + fi + + echo " [JKS] OK" +} + +# Determine the single non-root target user when --all-users is NOT set. +# Fallback order matches install_certs_macos.sh:314-328: SUDO_USER first, +# then /dev/console owner (the JAMF / GUI-elevated case), then `logname` as a +# last resort. `loginwindow` is the special user that owns /dev/console at +# the login screen — must be filtered or we'd install into a non-account. +get_single_target_user() { + local candidate + + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + candidate="$SUDO_USER" + else + candidate="$(stat -f '%Su' /dev/console 2>/dev/null || true)" + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + candidate="$(logname 2>/dev/null || true)" + fi + fi + + if [[ -z "$candidate" || "$candidate" == "root" || "$candidate" == "loginwindow" ]]; then + return 0 + fi + + # Reject users that don't exist on the box. + if ! id -u "$candidate" >/dev/null 2>&1; then + return 0 + fi + + echo "$candidate" +} + +get_user_home() { + local user="$1" + # `dscl . -read /Users/$user NFSHomeDirectory` is the macOS canonical + # source of truth (passwd is a synthetic view). Fall back to dscacheutil + # if dscl is unavailable (sandboxed CI images, OpenDirectory hiccups). + # Avoid `eval echo "~$user"` — even with the upstream id-validation it + # makes the next maintainer's eyes water and would be unsafe if the + # validation is ever loosened. + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]] && command -v dscacheutil >/dev/null 2>&1; then + home="$(dscacheutil -q user -a name "$user" 2>/dev/null | awk '/^dir:/ {print $2}')" + fi + echo "$home" +} + +launch_agent_path_for_user() { + local user_home="$1" + echo "${user_home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" +} + +# XML-escape a single value to make it safe inside a node. +# JKS paths contain spaces ("Application Support") but spaces in XML text are +# fine; the only chars that must be escaped are &, <, >. +plist_xml_escape() { + local s="$1" + s="${s//&/&}" + s="${s///>}" + # `"` inside PCDATA is technically valid XML, but we escape it + # for defence-in-depth: the JTO value embeds literal `"` chars to quote + # the trustStore path against JVM whitespace tokenisation, and plutil + # versions on older macOS releases have been finicky about unescaped + # quotes inside . Escaping is safe across plist consumers. + s="${s//\"/"}" + printf '%s' "$s" +} + +write_launch_agent_plist() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local plist_dir + plist_dir="$(dirname "$plist_path")" + + local jks_path + jks_path="$(jks_path_for_user "$user_home")" + + # The JKS path is under ~/Library/Application Support/ — the embedded + # space breaks unquoted JAVA_TOOL_OPTIONS at the JVM tokenizer (which + # splits on whitespace and only honours `"…"` grouping). Without these + # inner quotes a Dock-launched JVM sees two tokens, the second of which + # starts with `Support/…` and aborts with "Unrecognized option". The + # plist carries the value verbatim through launchctl setenv, + # so the literal quote characters reach the JVM tokenizer and correctly + # group the path. plist_xml_escape converts `"` → `"` for the XML. + local jto_value="-Djavax.net.ssl.trustStore=\"${jks_path}\" -Djavax.net.ssl.trustStorePassword=\"${JKS_PASSWORD}\"" + local jto_escaped + jto_escaped="$(plist_xml_escape "$jto_value")" + + local uid + uid="$(id -u "$target_user")" + local log_out="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.out.log" + local log_err="/tmp/${LAUNCH_AGENT_LABEL}-${uid}.err.log" + + echo " [Agent] Writing plist: $plist_path" + + mkdir -p "$plist_dir" + + # Atomic write via mktemp + mv in the same directory so a half-written + # plist can never be picked up by launchd. + local tmp + tmp="$(mktemp "${plist_dir}/.${LAUNCH_AGENT_BASENAME}.XXXXXX")" + cat > "$tmp" < + + + + Label + ${LAUNCH_AGENT_LABEL} + ProgramArguments + + /bin/launchctl + setenv + JAVA_TOOL_OPTIONS + ${jto_escaped} + + RunAtLoad + + StandardOutPath + ${log_out} + StandardErrorPath + ${log_err} + + +PLIST + + # plutil -lint catches XML / schema errors before launchd ever sees the file. + if ! plutil -lint "$tmp" >/dev/null 2>&1; then + local lint_err + lint_err="$(plutil -lint "$tmp" 2>&1 || true)" + rm -f "$tmp" + echo "Error: generated LaunchAgent plist failed plutil -lint: $lint_err" >&2 + exit 1 + fi + + mv "$tmp" "$plist_path" + chmod 0644 "$plist_path" + + # launchd refuses to load LaunchAgents owned by root in ~/Library/LaunchAgents/, + # so a silent chown failure here turns the install into a phantom success. + local chown_err + if ! chown_err="$(chown "$target_user" "$plist_dir" "$plist_path" 2>&1)"; then + echo "Error: chown $target_user $plist_path failed: $chown_err" >&2 + exit 1 + fi + + echo " [Agent] OK" +} + +bootstrap_launch_agent() { + local target_user="$1" user_home="$2" + local plist_path + plist_path="$(launch_agent_path_for_user "$user_home")" + local uid + uid="$(id -u "$target_user")" + local domain="gui/${uid}" + + # `gui/` exists only while the user is logged into a GUI session. + # Under --all-users this is normally false for everyone except the + # currently-logged-in account. The plist we already wrote into + # ~/Library/LaunchAgents/ is loaded automatically by launchd when the + # user next logs in (RunAtLoad=true), so a missing domain is a soft-fail. + if ! launchctl print "$domain" >/dev/null 2>&1; then + echo " [Agent] $target_user is not in an active GUI session; skipping bootstrap." + echo " [Agent] Plist is installed at $plist_path and will activate at next login." + return 0 + fi + + echo " [Agent] launchctl bootout (in case already loaded; expected to fail on a fresh install)" + # `launchctl bootout` returns non-zero when the agent isn't loaded yet. + # That's the normal first-run case; swallow it. + launchctl bootout "$domain" "$plist_path" 2>/dev/null || true + + echo " [Agent] launchctl bootstrap $domain" + local bootstrap_err + if ! bootstrap_err="$(launchctl bootstrap "$domain" "$plist_path" 2>&1)"; then + echo " [Agent] [warn] launchctl bootstrap failed for $domain: $bootstrap_err" >&2 + echo " Plist is installed at $plist_path and will activate at next login." >&2 + return 0 + fi + + # Sanity-check that setenv actually fired. `launchctl bootstrap` blocks + # until the agent is *loaded* but the ProgramArguments exec + # (`launchctl setenv …`) runs asynchronously after load. Under a quiet + # box this is microseconds; under EDR agents (CrowdStrike, Spotlight + # mid-index, Time Machine) the exec latency can climb past a second. + # Retry up to ~2s (20 × 100ms) so a perfectly-healthy install doesn't + # produce a misleading warn under load. + local seen attempt + for attempt in $(seq 1 20); do + seen="$(launchctl asuser "$uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$seen" ]] && break + sleep 0.1 + done + + if [[ -z "$seen" ]]; then + # The env var IS set in the plist on disk and the agent IS loaded. + # We just couldn't observe the launchctl-setenv side-effect inside + # our retry window. Surface that without suggesting a logout/login + # dance the user almost certainly doesn't need. + echo " [Agent] [warn] JAVA_TOOL_OPTIONS not observable via launchctl getenv within 2s." >&2 + echo " Plist is loaded; re-run validate_certs_jvm_macos.sh in a few seconds" >&2 + echo " to confirm, or open a new Terminal and check \$JAVA_TOOL_OPTIONS." >&2 + else + echo " [Agent] launchctl getenv JAVA_TOOL_OPTIONS confirms the value reached gui/${uid}" + fi +} + +install_for_user() { + local target_user="$1" user_home="$2" + + echo "=== User: $target_user (home: $user_home) ===" + install_truststore_for_user "$target_user" "$user_home" + write_launch_agent_plist "$target_user" "$user_home" + bootstrap_launch_agent "$target_user" "$user_home" + + echo " Truststore: $(jks_path_for_user "$user_home")" + echo " LaunchAgent: $(launch_agent_path_for_user "$user_home")" +} + +# Outputs `username\thome_dir` lines for every /Users/* directory that +# represents a real account with UID >= 501. +# +# Stricter filter than install_certs_macos.sh:256-261 (which skips only +# /Users/Shared and UID < 501): also skips .localized, and rejects +# directories whose owning user no longer exists in dscl (stale home dirs +# left behind by deleted accounts would otherwise crash chown/launchctl). +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + # Reject stale home dirs whose owning user no longer exists in dscl. + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +print_caveats() { + cat < per installed user. New launchd-spawned + processes (Dock-launched IntelliJ, JetBrains Toolbox, 'open -a …') inherit + JAVA_TOOL_OPTIONS automatically. + - Apps that were ALREADY running before the install must be restarted to pick + up the new env var (LaunchServices does not re-poll the launchd domain on + Cmd-Tab). + - Run 'gradle --stop' to refresh the Gradle Daemon if one was already running. + - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected. +EOF +} + +main() { + require_root + parse_args "$@" + check_os + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + echo + install_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + + if [[ "$iter_count" -eq 0 ]]; then + echo "Error: --all-users found no eligible accounts under /Users/* (UID >= 501)." >&2 + exit 1 + fi + + echo + echo "Installed for $iter_count user(s)." + print_caveats + return 0 + fi + + local target_user user_home + target_user="$(get_single_target_user)" + if [[ -z "$target_user" ]]; then + echo "Error: could not determine non-root target user." >&2 + echo " Set SUDO_USER or invoke via sudo from the developer account," >&2 + echo " or pass --all-users to iterate every eligible account." >&2 + exit 1 + fi + user_home="$(get_user_home "$target_user")" + if [[ -z "$user_home" || ! -d "$user_home" ]]; then + echo "Error: home directory not found for $target_user." >&2 + exit 1 + fi + + echo + install_for_user "$target_user" "$user_home" + print_caveats +} + +main "$@" diff --git a/install_certs_jvm_rhel.sh b/install_certs_jvm_rhel.sh new file mode 100755 index 0000000..2edbee1 --- /dev/null +++ b/install_certs_jvm_rhel.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Install a custom CA certificate into RHEL-family system trust for JVM clients. +# +# This script is for Red Hat OpenJDK-style hosts where Java cacerts is managed +# by update-ca-trust at /etc/pki/ca-trust/extracted/java/cacerts. +# +# Run: +# sudo bash install_certs_jvm_rhel.sh --use-cert /path/to/cert.pem [--cert-name ] + +set -euo pipefail + +DEFAULT_CERT_BASENAME="package-route-custom-ca" +RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" +RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" +JKS_PASSWORD="changeit" +EXPIRY_WARN_SECONDS=2592000 + +USE_CERT="" +CERT_BASENAME="$DEFAULT_CERT_BASENAME" + +usage() { + cat < [--cert-name ] + +Options: + --use-cert Path to an existing PEM/CRT certificate file (required). + --cert-name Base name for the installed anchor file + (default: ${DEFAULT_CERT_BASENAME}). + -h, --help Show this help. + +Examples: + sudo $0 --use-cert /tmp/ZscalerRoot0.pem + sudo $0 --use-cert /tmp/ca.pem --cert-name zscaler-root +EOF +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + echo "Use: sudo $0 --use-cert [--cert-name ]" >&2 + exit 1 + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --use-cert) + USE_CERT="${2:?Error: --use-cert requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$USE_CERT" ]]; then + echo "Error: --use-cert is required." >&2 + usage >&2 + exit 1 + fi + if [[ ! -f "$USE_CERT" ]]; then + echo "Error: certificate file not found: $USE_CERT" >&2 + exit 1 + fi + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +check_os() { + if [[ ! -r /etc/os-release ]]; then + echo "Error: cannot determine OS." >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + . /etc/os-release + local id="${ID:-}" id_like="${ID_LIKE:-}" + case "$id" in + rhel|fedora|centos|rocky|almalinux|amzn) return 0 ;; + esac + case "$id_like" in + *rhel*|*fedora*|*centos*) return 0 ;; + esac + + echo "Error: this script supports RHEL/Fedora/CentOS/Amazon-Linux families." >&2 + echo "Detected ID=${id:-unknown}, ID_LIKE=${id_like:-unknown}" >&2 + exit 1 +} + +check_dependencies() { + if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found." >&2 + exit 1 + fi + if ! command -v update-ca-trust >/dev/null 2>&1; then + echo "Error: update-ca-trust is required but not found." >&2 + exit 1 + fi +} + +validate_pem() { + local path="$1" + + if ! grep -q -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null; then + echo "Error: certificate is not PEM-encoded: $path" >&2 + echo " If it's DER, convert first:" >&2 + echo " openssl x509 -inform der -in $path -out $path.pem" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + echo "Error: invalid PEM/CRT certificate file: $path" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -checkend 0 -noout >/dev/null 2>&1; then + echo "Error: certificate has already expired: $path" >&2 + exit 1 + fi + if ! openssl x509 -in "$path" -checkend "$EXPIRY_WARN_SECONDS" -noout >/dev/null 2>&1; then + echo "[warn] certificate expires within 30 days: $path" >&2 + fi + + local bc + bc="$(openssl x509 -in "$path" -noout -ext basicConstraints 2>/dev/null || true)" + if ! grep -qi 'CA:TRUE' <<<"$bc"; then + echo "Error: certificate is not a CA (basicConstraints missing CA:TRUE): $path" >&2 + echo " Java trust stores reject non-CA trust anchors during PKIX path building." >&2 + exit 1 + fi + + local count + count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$path" 2>/dev/null || echo 0)" + if [[ "$count" -gt 1 ]]; then + echo "[warn] PEM file contains $count certificates; update-ca-trust will ingest the bundle as one anchor file." >&2 + fi +} + +install_anchor() { + local anchor_path="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + + echo "[1/3] Installing CA into system anchor: $anchor_path" + mkdir -p "$RHEL_ANCHOR_DIR" + + if [[ -e "$anchor_path" ]]; then + local existing_fp installer_fp + existing_fp="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)" + installer_fp="$(openssl x509 -in "$USE_CERT" -noout -fingerprint -sha256 | sed 's/.*=//')" + if [[ "$existing_fp" == "$installer_fp" ]]; then + echo " Anchor already present with matching fingerprint; skipping copy." + else + echo " [warn] Replacing existing anchor at $anchor_path" >&2 + echo " existing fingerprint: $existing_fp" >&2 + echo " new fingerprint: $installer_fp" >&2 + cp "$USE_CERT" "$anchor_path" + fi + else + cp "$USE_CERT" "$anchor_path" + fi + chmod 0644 "$anchor_path" + + echo "[2/3] Running update-ca-trust extract..." + update-ca-trust extract + + echo "[3/3] Verifying Java cacerts contains the CA..." + if ! command -v keytool >/dev/null 2>&1; then + echo " [warn] keytool not on PATH; skipping Java-side verification. Install a JDK and re-run validate_certs_jvm_rhel.sh." >&2 + else + local fingerprint listing + fingerprint="$(openssl x509 -in "$anchor_path" -noout -fingerprint -sha256 | sed 's/.*=//')" + if ! listing="$(keytool -list -keystore "$RHEL_JAVA_CACERTS" -storepass "$JKS_PASSWORD" 2>&1)"; then + echo " [warn] keytool could not read $RHEL_JAVA_CACERTS. Output:" >&2 + printf '%s\n' "$listing" | head -n5 >&2 + elif grep -qiF "$fingerprint" <<<"$listing"; then + echo " OK: CA fingerprint visible in $RHEL_JAVA_CACERTS" + else + echo " [warn] CA fingerprint not visible in $RHEL_JAVA_CACERTS." >&2 + fi + fi + + echo + echo "Installed certificate:" + echo " $anchor_path" + echo "Java trust path:" + echo " $RHEL_JAVA_CACERTS" +} + +main() { + require_root + parse_args "$@" + check_os + check_dependencies + validate_pem "$USE_CERT" + install_anchor +} + +main "$@" diff --git a/install_certs_jvm_windows.ps1 b/install_certs_jvm_windows.ps1 new file mode 100644 index 0000000..920b6f4 --- /dev/null +++ b/install_certs_jvm_windows.ps1 @@ -0,0 +1,220 @@ +# (c) JFrog Ltd. (2026) +# Install a bundled JVM truststore on Windows for JVM clients (Maven, Gradle, +# sbt, Apache Ivy). +# +# Single path: copy a supplied JKS truststore to +# %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks +# then set JAVA_TOOL_OPTIONS at User scope +# (HKCU\Environment + WM_SETTINGCHANGE broadcast) so every new JVM startup +# inherits the trustStore path. +# +# Run: +# powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore C:\path\to\truststore.jks +# +# Notes: +# - Windows only. +# - Requires PowerShell 5.1+ (Windows PowerShell or PowerShell 7). +# - User scope only -- does NOT require Administrator. No Machine-scope +# option in v1 (intentional; the Wiki recommends user-scope for +# developer machines). +# - JVM trust only -- does not configure Node/npm/Python and does not +# touch Docker credentials. Pair with install_certs_windows.ps1 for those. +# - Existing processes need a logoff/logon (or to handle WM_SETTINGCHANGE) +# before they see the new env var. Most daemons don't; restart Gradle +# Daemon via `gradle --stop` and restart your IDE after install. +# - The "use the OS trust store" alternative (-Djavax.net.ssl.trustStoreType= +# Windows-ROOT) is deliberately not exposed in v1. The Gradle daemon +# stale-cache issue (gradle/gradle#6584) was fixed in Gradle 8.3 via +# gradle/gradle#25106, but the JKS+JAVA_TOOL_OPTIONS recipe stays uniform +# across Linux/macOS/Windows and works for developers on older Gradle. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# install_certs_jvm_linux.sh - bundled JKS + JAVA_TOOL_OPTIONS +# install_certs_jvm_rhel.sh - RHEL update-ca-trust +# install_certs_jvm_macos.sh - LaunchAgent + per-user JKS +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$UseTruststore +) + +$ErrorActionPreference = 'Stop' + +# Keep this installer self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +$JvmWindowsJksPassword = 'changeit' +$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' + +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) +} + +function Show-Usage { + @' +Usage: + powershell -ExecutionPolicy Bypass -File install_certs_jvm_windows.ps1 -UseTruststore + +Parameters: + -UseTruststore Path to an existing JVM truststore (JKS/PKCS12-compatible) + to copy into the current user's fixed JKS location. + The truststore must be readable by JVMs with password + 'changeit'. + +Notes: + No -AllUsers flag -- User-scope env var is per-user by construction; each + developer runs the installer in their own session. There is no -Mode + flag (no OS-trust fallback by design: Windows-ROOT is not exposed in v1 + -- the daemon stale-cache issue gradle/gradle#6584 is fixed in Gradle 8.3, + but the JKS recipe stays uniform across platforms). + +Examples: + powershell -File install_certs_jvm_windows.ps1 -UseTruststore C:\tmp\package-route-truststore.jks +'@ +} + +function Test-Truststore { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-Error "Error: truststore file not found: $Path" + exit 1 + } + + $item = Get-Item -LiteralPath $Path + if ($item.Length -le 0) { + Write-Error "Error: truststore file is empty: $Path" + exit 1 + } + + $stream = $null + try { + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + } catch { + Write-Error "Error: truststore file is not readable: $Path ($($_.Exception.Message))" + exit 1 + } finally { + if ($stream) { $stream.Dispose() } + } +} + +function Install-JksTruststore { + param( + [string]$JksPath, + [string]$SourceTruststore + ) + + # Precondition: %LOCALAPPDATA% must exist and be writable. OneDrive + # Known-Folder-Move, roaming-profile misconfiguration, and IT GPO + # restrictions are the common failure modes; without this check the + # New-Item below would throw a generic .NET path message that doesn't + # hint at profile redirection. + if ([string]::IsNullOrEmpty($env:LOCALAPPDATA)) { + Write-Error 'Error: %LOCALAPPDATA% is empty. Cannot place the JKS truststore. Are you running under a service account or a profile that has not been provisioned?' + exit 1 + } + if (-not (Test-Path -LiteralPath $env:LOCALAPPDATA)) { + Write-Error ("Error: %LOCALAPPDATA% ({0}) does not exist on this filesystem. OneDrive Known-Folder-Move or roaming-profile failure?" -f $env:LOCALAPPDATA) + exit 1 + } + + $jksDir = Split-Path -Parent $JksPath + if (-not (Test-Path -LiteralPath $jksDir)) { + New-Item -ItemType Directory -Path $jksDir -Force | Out-Null + } + + Write-Host (" [JKS] Installing truststore at {0}" -f $JksPath) + $sourcePath = (Resolve-Path -LiteralPath $SourceTruststore).Path + if (Test-Path -LiteralPath $JksPath -PathType Leaf) { + $destPath = (Resolve-Path -LiteralPath $JksPath).Path + if ([string]::Equals($sourcePath, $destPath, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-Host " [JKS] Source already matches destination; leaving truststore in place." + return + } + } + Copy-Item -LiteralPath $sourcePath -Destination $JksPath -Force + Write-Host " [JKS] OK" +} + +function Set-JavaToolOptions { + param( + [string]$JksPath, + [string]$Password + ) + + # User scope = HKCU\Environment. [Environment]::SetEnvironmentVariable + # also broadcasts WM_SETTINGCHANGE, so processes that handle the message + # (Explorer, some shells) pick up the value without a logoff. Most JVM + # toolchains do not -- daemons and IDE processes still need a fresh + # session before the env var reaches a new java -version. + # + # Both trustStore and trustStorePassword values are quoted so a future + # password change to one containing spaces doesn't tokenize wrongly when + # the JVM splits JAVA_TOOL_OPTIONS. + $jtoValue = '-Djavax.net.ssl.trustStore="{0}" -Djavax.net.ssl.trustStorePassword="{1}"' -f $JksPath, $Password + + Write-Host (" [Env] Setting User-scope {0}" -f $JvmWindowsEnvVarName) + [Environment]::SetEnvironmentVariable($JvmWindowsEnvVarName, $jtoValue, [EnvironmentVariableTarget]::User) + + # Round-trip verify: on Windows 10 1607+ a SetEnvironmentVariable value + # exceeding 2047 chars is silently truncated rather than throwing. A + # future feature that lengthens JAVA_TOOL_OPTIONS (e.g. adds -Dhttps + # proxy flags) would leave the user with a half-written value and the + # validator complaining that JKS path doesn't match -- exactly the kind + # of silent failure the project bans. + $readBack = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::User) + if ($readBack -ne $jtoValue) { + Write-Error ("Error: HKCU\Environment round-trip verify failed for {0}.`n Wrote ({1} chars): {2}`n Read ({3} chars): {4}" ` + -f $JvmWindowsEnvVarName, $jtoValue.Length, $jtoValue, ($readBack.Length), $readBack) + exit 1 + } + Write-Host " [Env] OK" + + return $jtoValue +} + +function Show-DoneSummary { + param( + [string]$JksPath, + [string]$JtoValue + ) + + Write-Host "" + Write-Host "Truststore:" + Write-Host (" {0}" -f $JksPath) + Write-Host ("{0}:" -f $JvmWindowsEnvVarName) + Write-Host (" {0}" -f $JtoValue) + Write-Host "" + Write-Host "Notes:" + Write-Host " - The User-scope env var is written to HKCU\Environment and broadcast" + Write-Host " via WM_SETTINGCHANGE. NEW processes started after this point inherit" + Write-Host " JAVA_TOOL_OPTIONS automatically." + Write-Host " - Existing PowerShell/cmd sessions did NOT see the value; open a new" + Write-Host " Terminal (or log off/on) so daemons and IDEs read it on startup." + Write-Host " - Run 'gradle --stop' to refresh the Gradle Daemon if one was already" + Write-Host " running -- daemons cache the env at startup." + Write-Host " - The 'Picked up JAVA_TOOL_OPTIONS:' banner on stderr is expected and" + Write-Host " indicates the JVM read the var correctly." +} + +function Main { + Test-Truststore -Path $UseTruststore + + $jksPath = Get-JvmWindowsJksPath + Install-JksTruststore ` + -JksPath $jksPath ` + -SourceTruststore $UseTruststore + + $jtoValue = Set-JavaToolOptions ` + -JksPath $jksPath ` + -Password $JvmWindowsJksPassword + + Show-DoneSummary -JksPath $jksPath -JtoValue $jtoValue +} + +Main diff --git a/testing/test_install_certs_jvm_linux.sh b/testing/test_install_certs_jvm_linux.sh new file mode 100755 index 0000000..5d6d63b --- /dev/null +++ b/testing/test_install_certs_jvm_linux.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Docker-driven smoke test matrix for Linux JVM trust installers. +# +# Runs: +# - generic Linux bundled-JKS flow on Debian/Ubuntu/Amazon Linux, using +# build_jvm_truststore_linux.sh to create the installer input +# - RHEL update-ca-trust PEM flow on UBI/RHEL + +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker is required to run this test matrix." >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PROBE="$(mktemp)" +trap 'rm -f "$PROBE"' EXIT +cat > "$PROBE" <<'PROBE_EOF' +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:?mode required: generic|rhel}" +fail() { echo "BUG: $1" >&2; exit 1; } + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/k.pem -out /tmp/ca.pem -days 7 \ + -subj "/CN=Lab JVM CA Final/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + +useradd -m -s /bin/bash devx >/dev/null 2>&1 || true + +build_bundle_truststore() { + ./build_jvm_truststore_linux.sh \ + --use-cert /tmp/ca.pem \ + --output /tmp/bundled-truststore.jks >/dev/null +} + +run_generic() { + build_bundle_truststore + + echo "=== generic: positive install + validate ===" + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null + ./validate_certs_jvm_linux.sh --expected-subject "Lab JVM CA Final" >/dev/null + echo " ok" + + echo "=== generic: subject mismatch must exit 1 ===" + if ./validate_certs_jvm_linux.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then + fail "validator should have exited 1 on subject mismatch" + fi + echo " ok" + + echo "=== generic: idempotent re-install preserves bundled JKS ===" + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null + bundle_sha="$(sha256sum /tmp/bundled-truststore.jks | awk '{print $1}')" + installed_sha="$(sha256sum /etc/ssl/package-route-jvm/truststore.jks | awk '{print $1}')" + [[ "$installed_sha" == "$bundle_sha" ]] || fail "installed JKS checksum differs from bundle" + env_lines="$(grep -c '^JAVA_TOOL_OPTIONS=' /etc/environment 2>/dev/null || true)" + rc_lines="$(grep -c '^export JAVA_TOOL_OPTIONS=' /home/devx/.bashrc 2>/dev/null || true)" + [[ "${env_lines:-0}" -eq 1 ]] || fail "/etc/environment has $env_lines JAVA_TOOL_OPTIONS lines (expected 1)" + [[ "${rc_lines:-0}" -eq 1 ]] || fail "/home/devx/.bashrc has $rc_lines export lines (expected 1)" + echo " ok" + + echo "=== generic: JTO env var REPLACES (not appends) on re-install ===" + sed -i '/^JAVA_TOOL_OPTIONS=/d' /etc/environment + echo 'JAVA_TOOL_OPTIONS="-Dpackage-reroute-test-sentinel=must-be-replaced"' >> /etc/environment + SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/bundled-truststore.jks >/dev/null + if grep -q 'package-reroute-test-sentinel' /etc/environment; then + fail "JTO env var was APPENDED to (sentinel survived). Re-install must replace." + fi + echo " ok" + + echo "=== generic: missing and empty truststores rejected ===" + if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/no-such-truststore.jks >/dev/null 2>&1; then + fail "installer should have rejected missing truststore" + fi + : > /tmp/empty-truststore.jks + if SUDO_USER=devx ./install_certs_jvm_linux.sh --use-truststore /tmp/empty-truststore.jks >/dev/null 2>&1; then + fail "installer should have rejected empty truststore" + fi + echo " ok" + + echo "=== generic: JKS preserves bundled public roots ===" + alias_count="$(keytool -list -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" + [[ "${alias_count:-0}" -ge 100 ]] || fail "truststore has $alias_count aliases; expected >= 100" + keytool -list -v -keystore /etc/ssl/package-route-jvm/truststore.jks -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail "truststore is missing the DigiCert family of public roots" + echo " ok ($alias_count aliases)" +} + +run_rhel() { + echo "=== rhel: positive install + validate ===" + ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem >/dev/null + ./validate_certs_jvm_rhel.sh --expected-subject "Lab JVM CA Final" >/dev/null + [[ -f /etc/pki/ca-trust/source/anchors/package-route-custom-ca.crt ]] || fail "RHEL anchor file not created" + echo " ok" + + echo "=== rhel: subject mismatch must exit 1 ===" + if ./validate_certs_jvm_rhel.sh --expected-subject "Microsoft Root CA NoMatch" >/dev/null 2>&1; then + fail "validator should have exited 1 on subject mismatch" + fi + echo " ok" + + echo "=== rhel: custom --cert-name round-trips ===" + ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem --cert-name zscaler-root >/dev/null + ./validate_certs_jvm_rhel.sh --expected-subject "Lab JVM CA Final" --cert-name zscaler-root >/dev/null + [[ -f /etc/pki/ca-trust/source/anchors/zscaler-root.crt ]] || fail "custom RHEL anchor file not created" + echo " ok" + + echo "=== rhel: path traversal cert-name rejected ===" + if ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.pem --cert-name '../etc/pwned' >/dev/null 2>&1; then + fail "installer should have rejected path-traversal --cert-name" + fi + echo " ok" + + echo "=== rhel: malformed PEM rejected ===" + echo "not a certificate" > /tmp/bad.pem + if ./install_certs_jvm_rhel.sh --use-cert /tmp/bad.pem >/dev/null 2>&1; then + fail "installer should have rejected malformed PEM" + fi + echo " ok" + + echo "=== rhel: leaf cert rejected ===" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/leaf-k.pem -out /tmp/leaf.pem -days 7 \ + -subj "/CN=Leaf Not CA" \ + -addext "basicConstraints=critical,CA:FALSE" 2>/dev/null + if ./install_certs_jvm_rhel.sh --use-cert /tmp/leaf.pem >/dev/null 2>&1; then + fail "installer should have rejected leaf cert" + fi + echo " ok" + + echo "=== rhel: DER cert rejected ===" + openssl x509 -in /tmp/ca.pem -outform DER -out /tmp/ca.der 2>/dev/null + if ./install_certs_jvm_rhel.sh --use-cert /tmp/ca.der >/dev/null 2>&1; then + fail "installer should have rejected DER-encoded cert" + fi + echo " ok" + + echo "=== rhel: no JAVA_TOOL_OPTIONS written ===" + if [[ -f /etc/environment ]] && grep -q '^JAVA_TOOL_OPTIONS=' /etc/environment; then + fail "RHEL flow should not write JAVA_TOOL_OPTIONS" + fi + echo " ok" +} + +case "$MODE" in + generic) run_generic ;; + rhel) run_rhel ;; + *) fail "unknown mode: $MODE" ;; +esac + +echo "=== ALL SMOKE TESTS PASSED ($MODE) ===" +PROBE_EOF +chmod +x "$PROBE" + +# distro_id|mode|image|setup_command +MATRIX=( + "ubuntu|generic|ubuntu:22.04|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "debian|generic|debian:12|export DEBIAN_FRONTEND=noninteractive; apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl ca-certificates default-jdk-headless >/dev/null" + "amazonlinux|generic|amazonlinux:2023|dnf install -y -q java-21-amazon-corretto-headless openssl shadow-utils ca-certificates >/dev/null" + "rhel|rhel|redhat/ubi9:latest|dnf install -y -q java-21-openjdk-headless openssl shadow-utils >/dev/null" +) + +LOG_DIR="$(mktemp -d)" +trap 'rm -f "$PROBE"; rm -rf "$LOG_DIR"' EXIT + +pids=() +labels=() +log_names=() + +for entry in "${MATRIX[@]}"; do + distro="${entry%%|*}" + rest="${entry#*|}" + mode="${rest%%|*}" + rest="${rest#*|}" + image="${rest%%|*}" + setup="${rest#*|}" + + log="${LOG_DIR}/${distro}.log" + ( + docker run --rm \ + -v "${REPO_ROOT}":/lab \ + -v "${PROBE}":/probe.sh:ro \ + -w /lab "$image" bash -c "set -e; ${setup}; bash /probe.sh ${mode}" + ) >"$log" 2>&1 & + pids+=("$!") + labels+=("$distro/$mode ($image)") + log_names+=("$log") + echo "[launched] $distro/$mode ($image) -> $log" +done + +overall_rc=0 +for i in "${!pids[@]}"; do + if wait "${pids[$i]}"; then + echo "[PASS] ${labels[$i]}" + else + echo "[FAIL] ${labels[$i]} — last 30 lines:" + tail -30 "${log_names[$i]}" | sed 's/^/ /' + overall_rc=1 + fi +done + +if [[ "$overall_rc" -eq 0 ]]; then + echo "All distros passed." +else + echo "One or more distros failed." +fi + +exit "$overall_rc" diff --git a/testing/test_install_certs_jvm_macos.sh b/testing/test_install_certs_jvm_macos.sh new file mode 100755 index 0000000..87509d3 --- /dev/null +++ b/testing/test_install_certs_jvm_macos.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Smoke matrix for install_certs_jvm_macos.sh + validate_certs_jvm_macos.sh. +# +# Run as root from the repo root (matches macos-latest CI usage): +# sudo ./testing/test_install_certs_jvm_macos.sh +# +# Targets the SUDO_USER's per-user files. `cleanup` runs at the start of +# fresh-state cases and via `trap EXIT`. The test runner builds a bundled +# truststore fixture from macOS system certificates plus a lab CA, then verifies +# the installer only copies that ready-made JKS into place and configures launchd. +# +# Invariants exercised: +# 1. Positive install + validate (subject substring match) +# 2. Subject mismatch -> exit 1 +# 3. Idempotent re-install (copied JKS checksum stable; plist replaced) +# 4. Missing --use-truststore is rejected +# 5. Missing / empty truststore paths are rejected +# 6. After bootstrap, launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns +# the JKS path (skip on CI runners with no GUI session) +# 7. Plist content is well-formed XML and points at the expected JKS path +# (covers the install path even when launchctl can't be verified) +# 8. --all-users iterates /Users/* and installs into every eligible account +# (covers the iter_all_users filter + per-user chown contract) +# 9. Installed JKS preserves public roots from the bundled truststore +# 10. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer + +set -euo pipefail +fail_msg() { echo "BUG: $1" >&2; exit 1; } + +if [[ "$(id -u)" -ne 0 ]]; then + echo "Error: this test runner must be run as root. Use: sudo $0" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Identify the test user. CI calls `sudo ./test_install_certs_jvm_macos.sh` +# from an interactive account, so SUDO_USER is set; locally same. Fall back +# to the GUI console user for JAMF-style flows. +if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + TEST_USER="$SUDO_USER" +else + TEST_USER="$(stat -f '%Su' /dev/console 2>/dev/null || true)" +fi +if [[ -z "$TEST_USER" || "$TEST_USER" == "root" ]]; then + fail_msg "cannot determine non-root test user (no SUDO_USER, no console user)" +fi + +TEST_HOME="$(dscl . -read "/Users/${TEST_USER}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" +[[ -d "$TEST_HOME" ]] || fail_msg "home directory not found for $TEST_USER" +TEST_UID="$(id -u "$TEST_USER")" + +JKS="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm/truststore.jks" +JKS_DIR="${TEST_HOME}/Library/Application Support/JFrog/package-route-jvm" +PLIST="${TEST_HOME}/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist" +LABEL="com.jfrog.package-reroute.jto-env" +BUNDLE_JKS="/tmp/jvm-mac-bundled-truststore.jks" + +echo "Test user: $TEST_USER (uid=$TEST_UID, home=$TEST_HOME)" + +cleanup() { + launchctl bootout "gui/${TEST_UID}/${LABEL}" 2>/dev/null || true + rm -f "$PLIST" + rm -rf "$JKS_DIR" + launchctl asuser "${TEST_UID}" launchctl unsetenv JAVA_TOOL_OPTIONS 2>/dev/null || true + rm -f /tmp/jvm-mac-empty-truststore.jks +} + +final_cleanup() { + cleanup + rm -f "$BUNDLE_JKS" +} +trap final_cleanup EXIT + +# Pick an OpenSSL implementation that supports `-addext` reliably. +# macos-latest CI's default `openssl` is LibreSSL, which silently mis-handles +# -addext and emits PEM bytes keytool then rejects with "Input not an X.509 +# certificate". Homebrew's openssl@3 is preinstalled on GHA macos-latest. +OPENSSL="" +for cand in /opt/homebrew/opt/openssl@3/bin/openssl /usr/local/opt/openssl@3/bin/openssl openssl; do + if command -v "$cand" >/dev/null 2>&1 && "$cand" version 2>/dev/null | grep -q '^OpenSSL '; then + OPENSSL="$cand" + break + fi +done +[[ -n "$OPENSSL" ]] || fail_msg "no real OpenSSL on PATH (need OpenSSL 3.x; LibreSSL does not support -addext)" +echo "Using openssl: $OPENSSL ($("$OPENSSL" version))" + +require_keytool() { + command -v keytool >/dev/null 2>&1 || fail_msg "keytool not on PATH (validator/test fixture requires a JDK)" +} + +build_bundle_truststore() { + local ca_path="$1" + rm -f "$BUNDLE_JKS" + OPENSSL="$OPENSSL" ./build_jvm_truststore_macos.sh \ + --use-cert "$ca_path" \ + --output "$BUNDLE_JKS" >/dev/null + echo "Bundled truststore fixture: $BUNDLE_JKS" +} + +# Generate the lab CA used by all positive cases. +"$OPENSSL" req -x509 -newkey rsa:2048 -nodes \ + -keyout /tmp/jvm-mac-test-k.pem -out /tmp/jvm-mac-test-ca.pem -days 7 \ + -subj "/CN=Lab JVM mac CA Test/O=JFrog" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + +require_keytool +build_bundle_truststore /tmp/jvm-mac-test-ca.pem + +# Capture combined stdout/stderr to a tempfile and only dump it on an +# *unexpected* exit. Negative tests need silence on the expected-fail path +# but diagnostic output when the installer surprises us. Keeps CI logs +# tight in the green-run case (the iteration-1 debug cycle showed how +# painful the silent-on-failure pattern is). +install_as_test_user() { + # The `if cmd; then …; fi` pattern reports $?=0 of the if-statement, not + # the command. `if ! cmd` also doesn't help (the `!` operator itself + # returns 0). Reliable capture: pre-set rc=0 and use `cmd || rc=$?`. + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +validate_as_test_user() { + local log rc=0 + log="$(mktemp)" + SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh "$@" >"$log" 2>&1 || rc=$? + if [[ "$rc" -eq 0 ]]; then + rm -f "$log" + else + _LAST_LOG="$log" + fi + return "$rc" +} + +dump_last_log() { + [[ -n "${_LAST_LOG:-}" && -f "$_LAST_LOG" ]] || return 0 + echo "--- captured output ---" + cat "$_LAST_LOG" + echo "--- end captured output ---" + rm -f "$_LAST_LOG" + unset _LAST_LOG +} + +#----------------------------------------------------------------------------- +echo +echo "=== 1. positive: install + validate ===" +cleanup +# Positive cases let stdout through so a CI failure shows a useful log; only +# negative cases (where we *expect* exit 1) silence both streams. +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" +SUDO_USER="$TEST_USER" ./validate_certs_jvm_macos.sh --expected-subject "Lab JVM mac CA Test" +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 2. negative: subject mismatch must exit 1 ===" +if validate_as_test_user --expected-subject "Microsoft Root CA NoMatch"; then + fail_msg "validator should have exited 1 on subject mismatch" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 3. idempotency: 2nd install preserves bundled JKS / single plist ===" +install_as_test_user --use-truststore "$BUNDLE_JKS" +validate_as_test_user --expected-subject "Lab JVM mac CA Test" + +bundle_sha="$(shasum -a 256 "$BUNDLE_JKS" | awk '{print $1}')" +installed_sha="$(shasum -a 256 "$JKS" | awk '{print $1}')" +[[ "$installed_sha" == "$bundle_sha" ]] || fail_msg "installed JKS checksum differs from bundled truststore" +alias_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -c "trustedCertEntry" || true) +alias_count=${alias_count:-0} +corp_count=$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -cE "^package-route-custom-ca[,[:space:]]" || true) +corp_count=${corp_count:-0} +[[ "$corp_count" -eq 1 ]] || fail_msg "expected exactly 1 corporate-CA alias after 2 installs, got $corp_count" +[[ "$alias_count" -ge 100 ]] || fail_msg "expected JKS to include macOS system roots (>=100 aliases), got $alias_count" +[[ -f "$PLIST" ]] || fail_msg "plist missing after 2nd install" +echo " ok (alias_count=$alias_count, sha=$installed_sha)" + +#----------------------------------------------------------------------------- +echo +echo "=== 4. negative: missing --use-truststore rejected ===" +if install_as_test_user; then + dump_last_log + fail_msg "installer should have rejected missing --use-truststore" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 5. negative: missing truststore path rejected ===" +if install_as_test_user --use-truststore /tmp/no-such-jvm-truststore.jks; then + dump_last_log + fail_msg "installer should have rejected missing truststore path" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 6. negative: empty truststore rejected ===" +: > /tmp/jvm-mac-empty-truststore.jks +if install_as_test_user --use-truststore /tmp/jvm-mac-empty-truststore.jks; then + dump_last_log + fail_msg "installer should have rejected empty truststore" +fi +echo " ok" + +#----------------------------------------------------------------------------- +echo +echo "=== 7. launchctl getenv JAVA_TOOL_OPTIONS in gui/ ===" +cleanup +install_as_test_user --use-truststore "$BUNDLE_JKS" +if launchctl print "gui/${TEST_UID}" >/dev/null 2>&1; then + # Mirror the installer's 20x100ms retry: bootstrap returns the moment + # the agent is loaded, but the launchctl-setenv ProgramArguments runs + # asynchronously. Without a retry the assertion would flake on GUI + # runners under load. + jto="" + # 20 retries × 100ms = ~2s total — mirrors installer's bootstrap_launch_agent + # to cover the async-setenv window even under EDR load. + # shellcheck disable=SC2034 + for _i in $(seq 1 20); do + jto="$(launchctl asuser "${TEST_UID}" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + [[ -n "$jto" ]] && break + sleep 0.1 + done + # Accept both quoted and unquoted forms. The post-fix installer writes + # the quoted form so JVM tokenization handles the "Application Support" + # space; older installs left it unquoted. + case "$jto" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "launchctl getenv mismatch (got: $jto)" ;; + esac +else + echo " SKIP: gui/${TEST_UID} not active (CI runner with no GUI session is fine — plist will load at login)" +fi + +#----------------------------------------------------------------------------- +echo +echo "=== 8. plist is well-formed XML and points at the expected JKS ===" +# Reuses the install from step 7 (no cleanup). Independent of whether +# launchctl bootstrap succeeded — validates the write_launch_agent_plist +# code path even on headless CI runners. +plutil -lint "$PLIST" >/dev/null +# Confirm the JTO value inside the plist actually references the expected +# JKS path. plutil -extract pulls the 4th element of ProgramArguments +# (index 3) which is the `-Djavax.net.ssl.trustStore=… …` arg string. +jto_in_plist="$(plutil -extract ProgramArguments.3 raw "$PLIST" 2>/dev/null)" +case "$jto_in_plist" in + *"trustStore=\"${JKS}\""*|*"trustStore=${JKS}"*) echo " ok" ;; + *) fail_msg "plist JTO arg doesn't point at $JKS (got: $jto_in_plist)" ;; +esac + +#----------------------------------------------------------------------------- +echo +echo "=== 9. --all-users iterates eligible accounts ===" +cleanup +# CI runners only have a single user (`runner`, uid 501). That's enough to +# verify iter_all_users does at least one iteration through the filter + +# per-user-chown path; multi-user is covered by the local dev Mac smoke. +# Run without SUDO_USER set so the installer takes the --all-users branch. +out="$(./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" --all-users 2>&1)" +echo "$out" | grep -q "=== User: ${TEST_USER}" \ + || { echo "$out" | tail -20; fail_msg "--all-users did not iterate ${TEST_USER}"; } +echo "$out" | grep -qE "Installed for [0-9]+ user\(s\)" \ + || { echo "$out" | tail -20; fail_msg "--all-users summary line missing"; } +# Per-user files should be owned by the target user (not root). +plist_owner="$(stat -f '%Su' "$PLIST")" +[[ "$plist_owner" == "$TEST_USER" ]] \ + || fail_msg "plist owner=$plist_owner, expected $TEST_USER (chown failed silently?)" +echo " ok" + +#----------------------------------------------------------------------------- +# Re-install once so the next three invariants observe the final end state. +cleanup +SUDO_USER="$TEST_USER" ./install_certs_jvm_macos.sh --use-truststore "$BUNDLE_JKS" >/dev/null + +echo +echo "=== 10. JKS extends bundled public roots ===" +# Regression guard for the "trustStore replaces, not extends" footgun. +# -Djavax.net.ssl.trustStore in OpenJDK swaps the JVM's trust source; a JKS +# holding only the corporate CA would break every public-CA TLS handshake +# (Maven Central, Gradle plugin portal, Let's Encrypt-fronted mirrors). +# The shipped bundle must therefore include public roots before install. +alias_count="$(keytool -list -keystore "$JKS" -storepass changeit 2>/dev/null | grep -c 'trustedCertEntry' || true)" +alias_count="${alias_count:-0}" +[[ "$alias_count" -ge 100 ]] \ + || fail_msg "JKS has $alias_count aliases; expected >= 100 (macOS system roots + corporate CA)" +echo " ok ($alias_count aliases)" + +echo +echo "=== 11. JKS contains a well-known public root (DigiCert family) ===" +# Spot-check the merge actually happened. DigiCert root certs ship in every +# macOS system trust bundle under several names; case-insensitive substring +# match catches the family. +keytool -list -v -keystore "$JKS" -storepass changeit 2>/dev/null \ + | grep -qi 'digicert' \ + || fail_msg "JKS missing the DigiCert family of public roots; the system-root bundle is incomplete" +echo " ok" + +echo +echo "=== 12. JAVA_TOOL_OPTIONS round-trips through JVM tokenizer ===" +# Direct repro of the "Application Support" space-tokenisation bug. Spawn a +# child java -version with the LaunchAgent's env var and assert the JVM +# does NOT print "Unrecognized option" — that's what an unquoted trustStore +# path produced before the fix. +jto_seen="$(launchctl asuser "$TEST_UID" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" +if [[ -z "$jto_seen" ]]; then + echo " SKIP: gui/$TEST_UID is not active (no logged-in GUI session) — cannot exercise the tokenizer round-trip" +else + java_out="$(JAVA_TOOL_OPTIONS="$jto_seen" java -version 2>&1 || true)" + if grep -q 'Unrecognized option' <<<"$java_out"; then + printf '%s\n' "$java_out" | head -10 + echo "JTO seen: $jto_seen" + fail_msg "java -version reported 'Unrecognized option' — JAVA_TOOL_OPTIONS tokenization is broken (likely missing inner quotes around the JKS path)" + fi + echo " ok (java -version accepted JTO=$jto_seen)" +fi + +echo +echo "=================================================================" +echo "ALL SMOKE TESTS PASSED" +echo "=================================================================" diff --git a/testing/test_install_certs_jvm_windows.ps1 b/testing/test_install_certs_jvm_windows.ps1 new file mode 100644 index 0000000..675f19e --- /dev/null +++ b/testing/test_install_certs_jvm_windows.ps1 @@ -0,0 +1,316 @@ +# (c) JFrog Ltd. (2026) +# Smoke matrix for install_certs_jvm_windows.ps1 + validate_certs_jvm_windows.ps1. +# +# Run from the repo root: +# powershell -ExecutionPolicy Bypass -File testing/test_install_certs_jvm_windows.ps1 +# +# No Administrator required -- User-scope env vars and %LOCALAPPDATA% paths +# are per-user. The runner builds a bundled truststore fixture from Windows +# LocalMachine root certificates plus a lab CA, then verifies the installer only +# copies that ready-made JKS into place and configures HKCU\Environment. +# +# Invariants exercised: +# 1. Positive install + validate (subject substring match) +# 2. Subject mismatch -> exit 1 +# 3. Idempotent re-install (copied JKS checksum stable; env var replaced) +# 4. Missing / empty truststore paths are rejected +# 5. User-scope JAVA_TOOL_OPTIONS references the expected JKS +# 6. JTO env var REPLACES (not appends) on re-install +# 7. Mandatory -UseTruststore: no-args invocation fails non-interactively +# 8. Installed JKS preserves public roots from the bundled truststore +# 9. Installed JKS contains a well-known public root (DigiCert family) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Fail-Test { param([string]$Msg) Write-Host "BUG: $Msg" -ForegroundColor Red; exit 1 } + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +Set-Location $RepoRoot + +# Locate openssl. windows-latest GHA runners have Git for Windows preinstalled, +# which bundles openssl at C:\Program Files\Git\usr\bin\openssl.exe. Also try +# Strawberry Perl's openssl. The test needs openssl only to mint the lab CA +# that goes into the bundled truststore fixture; the installer itself does not. +$OpenSsl = $null +$candidates = @( + 'C:\Program Files\Git\usr\bin\openssl.exe', + 'C:\Strawberry\c\bin\openssl.exe' +) +foreach ($cand in $candidates) { + if (Test-Path -LiteralPath $cand -PathType Leaf) { + $OpenSsl = $cand; break + } +} +if (-not $OpenSsl) { + $cmd = Get-Command openssl.exe -ErrorAction SilentlyContinue + if ($cmd) { $OpenSsl = $cmd.Source } +} +if (-not $OpenSsl) { + Fail-Test 'no openssl.exe found (need Git for Windows or similar to build the test fixture)' +} +Write-Host ("Using openssl: {0}" -f $OpenSsl) +Write-Host ((& $OpenSsl version) 2>&1) + +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) +} + +$JksPath = Get-JvmWindowsJksPath +$JksDir = Split-Path -Parent $JksPath +$LabSubj = 'Lab JVM Win CA Test' +$BundleJks = 'C:\Windows\Temp\jvm-win-bundled-truststore.jks' + +function Cleanup { + # Reset JAVA_TOOL_OPTIONS regardless of prior state. Setting to $null + # via SetEnvironmentVariable deletes the value. + [Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', $null, [EnvironmentVariableTarget]::User) + if (Test-Path -LiteralPath $JksDir) { + try { + Remove-Item -LiteralPath $JksDir -Recurse -Force -ErrorAction Stop + } catch { + Write-Warning "Cleanup: could not remove $JksDir ($($_.Exception.Message)). A previous process may still hold a file handle; subsequent tests will likely fail." + } + } + Remove-Item -LiteralPath 'C:\Windows\Temp\jvm-win-empty-truststore.jks' -ErrorAction SilentlyContinue +} + +function Final-Cleanup { + Cleanup + Remove-Item -LiteralPath $BundleJks -ErrorAction SilentlyContinue +} + +try { + +Cleanup + +# --- Generate the lab CA used by the bundled truststore fixture --- +$labKey = 'C:\Windows\Temp\jvm-win-test-k.pem' +$labCa = 'C:\Windows\Temp\jvm-win-test-ca.pem' +Remove-Item -LiteralPath $labKey, $labCa -ErrorAction SilentlyContinue +& $OpenSsl req -x509 -newkey rsa:2048 -nodes ` + -keyout $labKey -out $labCa -days 7 ` + -subj "/CN=$LabSubj/O=JFrog" ` + -addext 'basicConstraints=critical,CA:TRUE' 2>&1 | Out-Null +if (-not (Test-Path -LiteralPath $labCa)) { + Fail-Test "openssl req failed to produce $labCa" +} + +function Invoke-Installer { + param([string[]]$ScriptArgs, [switch]$ExpectFail) + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\install_certs_jvm_windows.ps1' @ScriptArgs 2>&1 + $rc = $LASTEXITCODE + $out = $raw | Out-String + if ($ExpectFail) { + if ($rc -eq 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "installer was expected to exit non-zero, got 0 (args: $($ScriptArgs -join ' '))" + } + } else { + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "installer exited $rc unexpectedly (args: $($ScriptArgs -join ' '))" + } + } + return $out +} + +function Invoke-Validator { + param([string[]]$ScriptArgs, [switch]$ExpectFail) + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\validate_certs_jvm_windows.ps1' @ScriptArgs 2>&1 + $rc = $LASTEXITCODE + $out = $raw | Out-String + if ($ExpectFail) { + if ($rc -eq 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "validator was expected to exit non-zero, got 0 (args: $($ScriptArgs -join ' '))" + } + } else { + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host $out + Write-Host "--- end captured output ---" + Fail-Test "validator exited $rc unexpectedly (args: $($ScriptArgs -join ' '))" + } + } + return $out +} + +function Get-Keytool { + if ($env:JAVA_HOME) { + $kt = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $kt) { return $kt } + } + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + Fail-Test 'keytool.exe not on PATH (need JAVA_HOME set or actions/setup-java for validator/test fixture)' +} +$Keytool = Get-Keytool + +function Invoke-Keytool { + param([string[]]$KeytoolArgs) + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $rc = 0 + try { + $out = & $Keytool @KeytoolArgs 2>&1 + $rc = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEAP + } + if ($rc -ne 0) { + Fail-Test ("keytool exited {0}: {1}" -f $rc, (($out | Select-Object -First 5) -join '; ')) + } + return $out +} + +function Build-BundledTruststore { + param([string]$CaPath) + Remove-Item -LiteralPath $BundleJks -ErrorAction SilentlyContinue + $raw = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File '.\build_jvm_truststore_windows.ps1' ` + -UseCert $CaPath ` + -Output $BundleJks 2>&1 + $rc = $LASTEXITCODE + if ($rc -ne 0) { + Write-Host "--- captured output ---" + Write-Host ($raw | Out-String) + Write-Host "--- end captured output ---" + Fail-Test "build_jvm_truststore_windows.ps1 exited $rc" + } + Write-Host ("Bundled truststore fixture: {0}" -f $BundleJks) +} + +Build-BundledTruststore -CaPath $labCa + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 1. positive: install + validate ===" +Cleanup +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null +Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 2. negative: subject mismatch must exit 1 ===" +Invoke-Validator -ScriptArgs @('-ExpectedSubject', 'Microsoft Root CA NoMatch') -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 3. idempotency: 2nd install preserves bundled JKS / single env value ===" +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null +Invoke-Validator -ScriptArgs @('-ExpectedSubject', $LabSubj) | Out-Null + +$bundleHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $BundleJks).Hash +$installedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $JksPath).Hash +if ($installedHash -ne $bundleHash) { + Fail-Test "installed JKS checksum differs from bundled truststore" +} + +$listOut = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$aliasCount = (($listOut | Select-String 'trustedCertEntry').Matches.Count) +$corpCount = (($listOut | Select-String '^package-route-custom-ca[,\s]').Matches.Count) +if ($corpCount -ne 1) { + Fail-Test "expected exactly 1 corporate-CA alias after 2 installs, got $corpCount" +} +if ($aliasCount -lt 100) { + Fail-Test "expected JKS to preserve bundled public roots (>=100 aliases), got $aliasCount" +} +Write-Host (" ok (alias_count={0}, corp_alias_count={1}, sha={2})" -f $aliasCount, $corpCount, $installedHash) + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 4. negative: missing truststore path rejected ===" +Invoke-Installer -ScriptArgs @('-UseTruststore', 'C:\Windows\Temp\no-such-jvm-truststore.jks') -ExpectFail | Out-Null +Write-Host " ok" + +Write-Host "" +Write-Host "=== 5. negative: empty truststore rejected ===" +'' | Set-Content -LiteralPath 'C:\Windows\Temp\jvm-win-empty-truststore.jks' -NoNewline +Invoke-Installer -ScriptArgs @('-UseTruststore', 'C:\Windows\Temp\jvm-win-empty-truststore.jks') -ExpectFail | Out-Null +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 6. User-scope JAVA_TOOL_OPTIONS references the expected JKS ===" +Cleanup +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null +$jto = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) +if (-not $jto) { + Fail-Test 'User-scope JAVA_TOOL_OPTIONS not set' +} +if (-not ($jto -like "*trustStore=*$JksPath*")) { + Fail-Test ("JAVA_TOOL_OPTIONS doesn't reference expected JKS path. got: {0}" -f $jto) +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 7. JTO env var REPLACES (not appends) on re-install ===" +[Environment]::SetEnvironmentVariable('JAVA_TOOL_OPTIONS', '-Dpackage-reroute-test-sentinel=must-be-replaced', [EnvironmentVariableTarget]::User) +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null +$post = [Environment]::GetEnvironmentVariable('JAVA_TOOL_OPTIONS', [EnvironmentVariableTarget]::User) +if ($post -match 'package-reroute-test-sentinel') { + Fail-Test "JTO env var was APPENDED to (sentinel survived). Re-install must replace. got: $post" +} +Write-Host " ok" + +#----------------------------------------------------------------------------- +Write-Host "" +Write-Host "=== 8. -UseTruststore mandatory: no-args invocation fails ===" +$out = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File '.\install_certs_jvm_windows.ps1' 2>&1 +$rc8 = $LASTEXITCODE +if ($rc8 -eq 0) { + Write-Host $out + Fail-Test 'installer should have rejected no-args invocation (rc=0)' +} +Write-Host " ok (rc=$rc8)" + +#----------------------------------------------------------------------------- +Cleanup +Invoke-Installer -ScriptArgs @('-UseTruststore', $BundleJks) | Out-Null + +Write-Host "" +Write-Host "=== 9. JKS preserves bundled public roots ===" +$listOut9 = Invoke-Keytool -KeytoolArgs @('-list', '-keystore', $JksPath, '-storepass', 'changeit') +$aliasCount = (($listOut9 | Select-String 'trustedCertEntry').Matches.Count) +if ($aliasCount -lt 100) { + Fail-Test "JKS has $aliasCount aliases; expected >= 100 (bundled public roots + corporate CA)" +} +Write-Host (" ok ({0} aliases)" -f $aliasCount) + +Write-Host "" +Write-Host "=== 10. JKS contains a well-known public root (DigiCert family) ===" +$listOut10 = Invoke-Keytool -KeytoolArgs @('-list', '-v', '-keystore', $JksPath, '-storepass', 'changeit') +if (-not ($listOut10 | Select-String -Pattern 'digicert' -SimpleMatch -Quiet)) { + Fail-Test "JKS missing the DigiCert family of public roots; the bundled truststore fixture is incomplete" +} +Write-Host " ok" + +Write-Host "" +Write-Host "=================================================================" +Write-Host "ALL SMOKE TESTS PASSED" +Write-Host "=================================================================" + +# Test #8 deliberately spawns a child powershell.exe invocation that exits +# non-zero. Reset LASTEXITCODE so the outer shell sees the aggregate result. +$global:LASTEXITCODE = 0 + +} finally { + Final-Cleanup +} + +exit 0 diff --git a/validate_certs_jvm_linux.sh b/validate_certs_jvm_linux.sh new file mode 100755 index 0000000..3262775 --- /dev/null +++ b/validate_certs_jvm_linux.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_linux.sh. +# +# Asserts: +# 1. JKS truststore exists at /etc/ssl/package-route-jvm/truststore.jks +# 2. JKS contains a cert whose subject matches --expected-subject +# 3. /etc/environment contains JAVA_TOOL_OPTIONS pointing at the JKS +# 4. Current user's shell rc files reference the same value (WARN if missing) +# 5. Current process inherited JAVA_TOOL_OPTIONS (HINT if missing) + +set -euo pipefail + +JKS_DIR="/etc/ssl/package-route-jvm" +JKS_PATH="${JKS_DIR}/truststore.jks" +JKS_PASSWORD="changeit" +ENVIRONMENT_FILE="/etc/environment" + +ALL_USERS=0 +EXPECTED_SUBJECT="" + +usage() { + cat < [--all-users] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --all-users Validate /home/* users' rc files (requires root). + -h, --help Show this help. + +Exits 0 if all checks pass, 1 if any check fails. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all-users) + ALL_USERS=1 + shift + ;; + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + + if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then + echo "Error: --all-users requires root. Use: sudo $0 --all-users --expected-subject ..." >&2 + exit 1 + fi +} + +FAIL=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } +warn() { echo " WARN: $1"; } +skip() { echo " SKIP: $1"; } + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if [[ ! -f "$keystore" ]]; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + local keytool_output + if ! keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)"; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +validate_export_in_file() { + local file="$1" label="$2" + if [[ ! -f "$file" ]]; then + skip "$label not present: $file" + return 0 + fi + if [[ ! -r "$file" ]]; then + warn "$label not readable by current user; re-run as that user or with sudo --all-users." + return 0 + fi + if grep -qE "^export JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$file" 2>/dev/null; then + ok "$label contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 0 + fi + if grep -qE '^export JAVA_TOOL_OPTIONS=' "$file" 2>/dev/null; then + fail "$label has JAVA_TOOL_OPTIONS but it does not point at $JKS_PATH" + return 1 + fi + warn "$label has no JAVA_TOOL_OPTIONS export (current session may need re-login or 'source $file')" + return 0 +} + +validate_environment_file() { + if [[ ! -f "$ENVIRONMENT_FILE" ]]; then + fail "$ENVIRONMENT_FILE does not exist" + return 1 + fi + if grep -qE "^JAVA_TOOL_OPTIONS=.*trustStore=\"?${JKS_PATH}\"?" "$ENVIRONMENT_FILE" 2>/dev/null; then + ok "$ENVIRONMENT_FILE contains JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 0 + fi + fail "$ENVIRONMENT_FILE has no JAVA_TOOL_OPTIONS pointing at $JKS_PATH" + return 1 +} + +validate_shell_rc_files() { + if [[ "$ALL_USERS" -eq 1 ]]; then + local homedir user + for homedir in /home/*; do + [[ -d "$homedir" ]] || continue + user="$(basename "$homedir")" + echo " Checking user $user ..." + validate_export_in_file "$homedir/.bashrc" "$user .bashrc" || true + validate_export_in_file "$homedir/.zshrc" "$user .zshrc" || true + done + else + echo " Checking current user ..." + [[ -z "${HOME:-}" ]] && HOME=$(eval echo "~") + validate_export_in_file "$HOME/.bashrc" "current user .bashrc" || true + validate_export_in_file "$HOME/.zshrc" "current user .zshrc" || true + fi +} + +main() { + parse_args "$@" + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + + validate_keystore_contains_subject "$JKS_PATH" "$JKS_PASSWORD" "Truststore $JKS_PATH" || true + validate_environment_file || true + validate_shell_rc_files + + if [[ -z "${JAVA_TOOL_OPTIONS:-}" ]]; then + echo " HINT: JAVA_TOOL_OPTIONS is NOT set in this shell. Open a new" + echo " login shell, or 'source $ENVIRONMENT_FILE', then re-run" + echo " the build. The next 'mvn'/'gradle' won't see the trust" + echo " store until then." + fi + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + echo "Result: All checks passed." + exit 0 + else + echo "Result: $FAIL check(s) failed." + exit 1 + fi +} + +main "$@" diff --git a/validate_certs_jvm_macos.sh b/validate_certs_jvm_macos.sh new file mode 100755 index 0000000..3c32580 --- /dev/null +++ b/validate_certs_jvm_macos.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_macos.sh. +# +# Asserts, per user: +# 1. JKS file exists at ~/Library/Application Support/JFrog/package-route-jvm/truststore.jks +# 2. JKS contains a cert whose subject matches --expected-subject +# 3. LaunchAgent plist exists at ~/Library/LaunchAgents/com.jfrog.package-reroute.jto-env.plist +# 4. launchctl getenv JAVA_TOOL_OPTIONS in gui/ returns the JKS path +# (only verifiable when the user is in an active GUI session; warn-not-fail +# otherwise — the plist still loads on next login). +# +# Run: +# bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" +# sudo bash validate_certs_jvm_macos.sh --expected-subject "O=Zscaler" --all-users +# +# Exit 0 = all checks passed; 1 = at least one failure. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_linux.sh — system anchor OR JKS+JTO check +# validate_certs_jvm_windows.ps1 — HKCU\Environment JTO check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +set -euo pipefail + +# Keep this validator self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +JKS_RELATIVE_DIR="Library/Application Support/JFrog/package-route-jvm" +JKS_BASENAME="truststore.jks" +LAUNCH_AGENT_RELATIVE_DIR="Library/LaunchAgents" +LAUNCH_AGENT_LABEL="com.jfrog.package-reroute.jto-env" +LAUNCH_AGENT_BASENAME="${LAUNCH_AGENT_LABEL}.plist" +JKS_PASSWORD="changeit" + +ALL_USERS=0 +EXPECTED_SUBJECT="" + +usage() { + cat < [--all-users] [--cert-name ] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --all-users Iterate /Users/* (UID >= 501). Requires root. + --cert-name Accepted for cross-platform CLI parity with the Linux validator. + Ignored here: macOS matches by subject substring, and the JKS path / + LaunchAgent label are fixed per-user regardless of cert-name. + -h, --help Show this help + +Exits 0 if all checks pass, 1 if any check fails. Result line is qualified +with a count of any non-fatal warnings (e.g. gui/ domain absent on +headless / non-active accounts). +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --all-users) + ALL_USERS=1 + shift + ;; + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + # Cross-platform CLI parity (see usage). A fleet wrapper that + # passes --cert-name to all three validators must not fail on + # macOS. We accept and silently ignore: macOS matches by + # subject substring, not by alias name. + : "${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + + if [[ "$ALL_USERS" -eq 1 && "$(id -u)" -ne 0 ]]; then + echo "Error: --all-users requires root (other users' ~/Library is 0700)." >&2 + echo "Use: sudo $0 --all-users --expected-subject ..." >&2 + exit 1 + fi +} + +check_os() { + local os + os="$(uname -s)" + if [[ "$os" != "Darwin" ]]; then + echo "Error: this script supports macOS only (detected: $os)." >&2 + exit 1 + fi +} + +FAIL=0 +WARN=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } +warn() { echo " WARN: $1"; WARN=$((WARN + 1)); } + +# When iterating --all-users, reads of /Users//Library need root. +# Wrap file tests so the same call works in both single-user and --all-users mode. +file_exists() { + local path="$1" + if [[ -f "$path" ]]; then + return 0 + fi + [[ "$ALL_USERS" -eq 1 ]] && sudo test -f "$path" +} + +get_user_home() { + local user="$1" + local home + home="$(dscl . -read "/Users/${user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" + if [[ -z "$home" ]]; then + home="$(eval echo "~${user}")" + fi + echo "$home" +} + +iter_all_users() { + local dir base uid + for dir in /Users/*; do + [[ -d "$dir" ]] || continue + base="$(basename "$dir")" + [[ "$base" == "Shared" || "$base" == ".localized" ]] && continue + uid="$(stat -f '%u' "$dir" 2>/dev/null || true)" + [[ -n "$uid" && "$uid" -ge 501 ]] || continue + id -u "$base" >/dev/null 2>&1 || continue + printf '%s\t%s\n' "$base" "$dir" + done +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if ! file_exists "$keystore"; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + # JKS exists but we can't open it. Don't silently pass the core invariant. + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + # Capture keytool output explicitly. Reading another user's keystore under + # --all-users requires sudo; the single-user case reads as the current user. + local keytool_output rc + if [[ "$ALL_USERS" -eq 1 ]]; then + keytool_output="$(sudo keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + else + keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)" + rc=$? + fi + if [[ "$rc" -ne 0 ]]; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + # Two-stage filter via a variable, not a pipe-pair: under set -o pipefail + # the second grep -qi exits early on match and SIGPIPEs the first, which + # poisons the pipeline status and turns positive matches into false negatives. + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + # I8 cross-platform parity (see validate_certs_jvm_linux.sh): refuse to + # validate stores that contain key material. The installer only writes + # trustedCertEntry records, so any PrivateKeyEntry here indicates drift + # — likely a future installer change or hand-edited store. The well-known + # `changeit` password is unsuitable for actual private-key protection. + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +validate_launch_agent_plist() { + local plist_path="$1" label="$2" + if file_exists "$plist_path"; then + if command -v plutil >/dev/null 2>&1; then + if [[ "$ALL_USERS" -eq 1 ]]; then + if sudo plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + else + if plutil -lint "$plist_path" >/dev/null 2>&1; then + ok "$label plist exists and is well-formed: $plist_path" + return 0 + fi + fi + fail "$label plist exists but plutil -lint reports it as malformed: $plist_path" + return 1 + fi + ok "$label plist exists: $plist_path" + return 0 + fi + fail "$label LaunchAgent plist not found: $plist_path" + return 1 +} + +validate_launchctl_getenv() { + local target_uid="$1" jks_path="$2" label="$3" + local domain="gui/${target_uid}" + + if ! launchctl print "$domain" >/dev/null 2>&1; then + warn "$label is not in an active GUI session (no $domain); plist will activate at next login." + return 0 + fi + + local seen + seen="$(launchctl asuser "$target_uid" launchctl getenv JAVA_TOOL_OPTIONS 2>/dev/null || true)" + if [[ -z "$seen" ]]; then + fail "$label: $domain is active but launchctl getenv JAVA_TOOL_OPTIONS is empty." + return 1 + fi + + # Accept both quoted and unquoted forms. The installer writes the quoted + # form so the JVM tokenizer respects the space in "Application Support"; + # older installs (pre-bug-fix) wrote the unquoted form and we still want + # the validator to recognise their JKS pointer as valid for diagnosis. + case "$seen" in + *"trustStore=\"${jks_path}\""*|*"trustStore=${jks_path} "*|*"trustStore=${jks_path}") + ok "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS points at $jks_path" + return 0 + ;; + *) + fail "$label: $domain launchctl getenv JAVA_TOOL_OPTIONS does not point at $jks_path (got: $seen)" + return 1 + ;; + esac +} + +validate_for_user() { + local user="$1" home="$2" + local uid + uid="$(id -u "$user")" + local jks="${home}/${JKS_RELATIVE_DIR}/${JKS_BASENAME}" + local plist="${home}/${LAUNCH_AGENT_RELATIVE_DIR}/${LAUNCH_AGENT_BASENAME}" + + echo "Checking user $user (uid=$uid)..." + validate_keystore_contains_subject "$jks" "$JKS_PASSWORD" "$user truststore" || true + validate_launch_agent_plist "$plist" "$user" || true + validate_launchctl_getenv "$uid" "$jks" "$user" || true +} + +main() { + parse_args "$@" + check_os + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + + if [[ "$ALL_USERS" -eq 1 ]]; then + local iter_count=0 user home + while IFS=$'\t' read -r user home; do + validate_for_user "$user" "$home" + iter_count=$((iter_count + 1)) + done < <(iter_all_users) + if [[ "$iter_count" -eq 0 ]]; then + fail "no eligible users found under /Users/* (UID >= 501)" + fi + else + # Default: validate the invoking user. If invoked via sudo, use SUDO_USER; + # otherwise the current $USER. + local user + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + user="$SUDO_USER" + else + user="$(id -un)" + fi + if [[ "$user" == "root" ]]; then + fail "cannot validate as root without --all-users (no home to inspect)" + else + local home + home="$(get_user_home "$user")" + if [[ -z "$home" || ! -d "$home" ]]; then + fail "home directory not found for $user" + else + validate_for_user "$user" "$home" + fi + fi + fi + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + if [[ "$WARN" -eq 0 ]]; then + echo "Result: All checks passed." + else + # Qualify so a green exit doesn't over-promise. The common case + # is "gui/ domain absent" (validator can't verify launchctl + # getenv without an active GUI session — plist will activate at + # next login). + echo "Result: All checks passed (with $WARN warning(s) — see above)." + fi + exit 0 + else + echo "Result: $FAIL check(s) failed (and $WARN warning(s))." + exit 1 + fi +} + +main "$@" diff --git a/validate_certs_jvm_rhel.sh b/validate_certs_jvm_rhel.sh new file mode 100755 index 0000000..f14bbdc --- /dev/null +++ b/validate_certs_jvm_rhel.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# (c) JFrog Ltd. (2026) +# Validate JVM trust installation done by install_certs_jvm_rhel.sh. + +set -euo pipefail + +DEFAULT_CERT_BASENAME="package-route-custom-ca" +RHEL_ANCHOR_DIR="/etc/pki/ca-trust/source/anchors" +RHEL_JAVA_CACERTS="/etc/pki/ca-trust/extracted/java/cacerts" +JKS_PASSWORD="changeit" + +EXPECTED_SUBJECT="" +CERT_BASENAME="$DEFAULT_CERT_BASENAME" + +usage() { + cat < [--cert-name ] + +Options: + --expected-subject Required. Case-insensitive substring match against the cert subject. + --cert-name Base name used at install (default: ${DEFAULT_CERT_BASENAME}). + -h, --help Show this help. + +Exits 0 if all checks pass, 1 if any check fails. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --expected-subject) + EXPECTED_SUBJECT="${2:?Error: --expected-subject requires a value}" + shift 2 + ;; + --cert-name) + CERT_BASENAME="${2:?Error: --cert-name requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + if [[ -z "$EXPECTED_SUBJECT" ]]; then + echo "Error: --expected-subject is required." >&2 + usage >&2 + exit 1 + fi + if [[ -z "$CERT_BASENAME" ]]; then + echo "Error: --cert-name cannot be empty." >&2 + exit 1 + fi + if [[ ! "$CERT_BASENAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: --cert-name must match [A-Za-z0-9._-]+ (got: $CERT_BASENAME)." >&2 + exit 1 + fi +} + +FAIL=0 +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +ok() { echo " OK: $1"; } + +validate_pem_subject() { + local path="$1" + if [[ ! -f "$path" ]]; then + fail "file does not exist: $path" + return 1 + fi + if ! openssl x509 -in "$path" -noout >/dev/null 2>&1; then + fail "not a valid PEM certificate: $path" + return 1 + fi + local subject + subject="$(openssl x509 -in "$path" -noout -subject 2>/dev/null)" + if echo "$subject" | grep -qi "$EXPECTED_SUBJECT"; then + ok "PEM at $path has subject matching: $EXPECTED_SUBJECT" + return 0 + fi + fail "PEM at $path subject does not match '$EXPECTED_SUBJECT' (got: $subject)" + return 1 +} + +validate_keystore_contains_subject() { + local keystore="$1" storepass="$2" label="$3" + if [[ ! -f "$keystore" ]]; then + fail "$label keystore does not exist: $keystore" + return 1 + fi + if ! command -v keytool >/dev/null 2>&1; then + fail "$label keystore present but keytool is not on PATH; cannot verify subject. Install a JDK." + return 1 + fi + + local keytool_output + if ! keytool_output="$(keytool -list -v -keystore "$keystore" -storepass "$storepass" 2>&1)"; then + fail "$label: keytool could not read the keystore. Output (first 3 lines):" + printf '%s\n' "$keytool_output" | head -n3 | sed 's/^/ /' + return 1 + fi + + local owners + owners="$(printf '%s\n' "$keytool_output" | grep -E '^Owner:' || true)" + if ! grep -qi "$EXPECTED_SUBJECT" <<<"$owners"; then + fail "$label has no cert with subject matching: $EXPECTED_SUBJECT" + return 1 + fi + if printf '%s\n' "$keytool_output" | grep -qE '^Entry type: PrivateKeyEntry'; then + fail "$label contains a PrivateKeyEntry — this truststore must hold only trustedCertEntry records." + return 1 + fi + ok "$label contains cert with subject matching: $EXPECTED_SUBJECT" + return 0 +} + +main() { + parse_args "$@" + + local anchor="${RHEL_ANCHOR_DIR}/${CERT_BASENAME}.crt" + + echo "Expected subject (case-insensitive substring): $EXPECTED_SUBJECT" + echo + echo "Validating RHEL update-ca-trust JVM install..." + + validate_pem_subject "$anchor" || true + validate_keystore_contains_subject "$RHEL_JAVA_CACERTS" "$JKS_PASSWORD" "Java cacerts" || true + + echo "---------------------------------------------------" + if [[ "$FAIL" -eq 0 ]]; then + echo "Result: All checks passed." + exit 0 + else + echo "Result: $FAIL check(s) failed." + exit 1 + fi +} + +main "$@" diff --git a/validate_certs_jvm_windows.ps1 b/validate_certs_jvm_windows.ps1 new file mode 100644 index 0000000..5885fae --- /dev/null +++ b/validate_certs_jvm_windows.ps1 @@ -0,0 +1,187 @@ +# (c) JFrog Ltd. (2026) +# Validate JVM truststore installation done by install_certs_jvm_windows.ps1. +# +# Asserts: +# 1. JKS file exists at %LOCALAPPDATA%\JFrog\package-route-jvm\truststore.jks +# 2. JKS contains a cert whose subject (Owner: in keytool -list -v) matches +# -ExpectedSubject (case-insensitive substring). +# 3. User-scope JAVA_TOOL_OPTIONS env var returns a value referencing the +# expected JKS path. +# +# Run: +# powershell -ExecutionPolicy Bypass -File validate_certs_jvm_windows.ps1 -ExpectedSubject "O=Zscaler" +# +# Exits 0 if all checks pass, 1 if any check fails. Result line is qualified +# with a count of any non-fatal warnings. +# +# Cross-platform siblings (keep CLI shapes and contracts in sync): +# validate_certs_jvm_linux.sh - system anchor OR JKS+JTO check +# validate_certs_jvm_macos.sh - LaunchAgent + launchctl getenv check +# +# Research / rationale: see the JVM client-onboarding wiki page +# https://jfrog-int.atlassian.net/wiki/spaces/RTFACT/pages/2440101931/ + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExpectedSubject, + + # Accepted for cross-platform CLI parity with the Linux validator. Ignored + # here: Windows matches by subject substring, and the JKS path / HKCU env + # var name are fixed regardless of cert-name. A fleet wrapper that passes + # -CertName to all three validators must not fail on Windows. + [Parameter(Mandatory = $false)] + [string]$CertName +) + +$ErrorActionPreference = 'Stop' + +# Keep this validator self-contained: it is often copied/run as a standalone +# script during onboarding, so avoid requiring sibling files for constants. +$JvmWindowsJksRelativeDir = 'JFrog\package-route-jvm' +$JvmWindowsJksBasename = 'truststore.jks' +$JvmWindowsJksPassword = 'changeit' +$JvmWindowsEnvVarName = 'JAVA_TOOL_OPTIONS' + +function Get-JvmWindowsJksPath { + Join-Path $env:LOCALAPPDATA (Join-Path $JvmWindowsJksRelativeDir $JvmWindowsJksBasename) +} + +$script:FailCount = 0 +$script:WarnCount = 0 + +function Write-Fail { param([string]$Msg) Write-Host " FAIL: $Msg"; $script:FailCount++ } +function Write-Ok { param([string]$Msg) Write-Host " OK: $Msg" } +function Write-Warn { param([string]$Msg) Write-Host " WARN: $Msg"; $script:WarnCount++ } + +# Locate keytool. Same logic as the installer's Resolve-Keytool. +function Resolve-Keytool { + if ($env:JAVA_HOME) { + $candidate = Join-Path $env:JAVA_HOME 'bin\keytool.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + $cmd = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return $null +} + +function Test-KeystoreContainsSubject { + param( + [string]$Keystore, + [string]$Password, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Keystore -PathType Leaf)) { + Write-Fail "$Label keystore does not exist: $Keystore" + return + } + + $keytool = Resolve-Keytool + if (-not $keytool) { + # Promoting to FAIL: the keystore-subject check is the validator's + # core assertion; silently passing here would mean CI is green even + # though we never verified the cert is in the store. + Write-Fail "$Label keystore present but keytool.exe is not on PATH and JAVA_HOME is not set; cannot verify subject." + return + } + + # Capture combined output. PowerShell's 2>&1 merges stderr into the + # pipeline so a real keytool error (corrupt store, wrong password, etc.) + # is visible instead of being silently dropped. Switch ErrorActionPreference + # to Continue around the call: under Stop, PowerShell promotes any + # native-command stderr to a terminating error before 2>&1 captures it. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $output = & $keytool -list -v -keystore $Keystore -storepass $Password 2>&1 + } finally { + $ErrorActionPreference = $prevEAP + } + if ($LASTEXITCODE -ne 0) { + Write-Fail ("{0}: keytool could not read the keystore. Output (first 3 lines):`n {1}" -f $Label, (($output | Select-Object -First 3) -join "`n ")) + return + } + + # `Owner:` lines are how `keytool -list -v` prints each cert's subject. + # Case-insensitive substring match against the expected subject. + $owners = $output | Where-Object { $_ -match '^Owner:' } + $found = $false + foreach ($owner in $owners) { + if ($owner -match [regex]::Escape($ExpectedSubject)) { + $found = $true + break + } + } + if (-not $found) { + Write-Fail "$Label has no cert with subject matching: $ExpectedSubject" + return + } + # I8 cross-platform parity (see validate_certs_jvm_linux.sh): refuse to + # validate stores that contain key material. The installer only writes + # trustedCertEntry records, so any PrivateKeyEntry here indicates drift + # -- likely a future installer change or hand-edited store. The well-known + # `changeit` password is unsuitable for actual private-key protection. + if ($output | Where-Object { $_ -match '^Entry type: PrivateKeyEntry' }) { + Write-Fail "$Label contains a PrivateKeyEntry -- this truststore must hold only trustedCertEntry records." + return + } + Write-Ok "$Label contains cert with subject matching: $ExpectedSubject" +} + +function Test-UserEnvVar { + param([string]$JksPath) + + $envValue = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::User) + if (-not $envValue) { + Write-Fail "User-scope $JvmWindowsEnvVarName is not set in HKCU\Environment" + return + } + + # Match either quoted or unquoted trustStore=...path against the expected JKS. + # Both branches anchor the end so a path like `$JksPath.bak.pkcs12` doesn't + # false-positive as a prefix-match of `$JksPath`. + $quotedPattern = 'trustStore="' + [regex]::Escape($JksPath) + '"' + $unquotedPattern = 'trustStore=' + [regex]::Escape($JksPath) + '(\s|$)' + if ($envValue -match $quotedPattern -or $envValue -match $unquotedPattern) { + Write-Ok ("User-scope {0} points at {1}" -f $JvmWindowsEnvVarName, $JksPath) + } else { + Write-Fail "User-scope $JvmWindowsEnvVarName does not reference $JksPath (got: $envValue)" + } + + # The JVM resolution order is process > User > Machine. A Machine-scope + # value would not override the User-scope one for the current process, + # but mixed scopes confuse onboarding ("I see two different paths in + # `setx /M JAVA_TOOL_OPTIONS`!"). Surface it as a warning rather than + # let it lurk silently. + $machineValue = [Environment]::GetEnvironmentVariable($JvmWindowsEnvVarName, [EnvironmentVariableTarget]::Machine) + if ($machineValue) { + Write-Warn ("Machine-scope {0} is ALSO set (value: {1}). v1 only manages User-scope; consider clearing the Machine-scope value if it's stale." -f $JvmWindowsEnvVarName, $machineValue) + } +} + +function Main { + Write-Host ("Expected subject (case-insensitive substring): {0}" -f $ExpectedSubject) + Write-Host "" + + $jksPath = Get-JvmWindowsJksPath + Test-KeystoreContainsSubject -Keystore $jksPath -Password $JvmWindowsJksPassword -Label ("Truststore {0}" -f $jksPath) + Test-UserEnvVar -JksPath $jksPath + + Write-Host "---------------------------------------------------" + if ($script:FailCount -eq 0) { + if ($script:WarnCount -eq 0) { + Write-Host "Result: All checks passed." + } else { + Write-Host ("Result: All checks passed (with {0} warning(s) -- see above)." -f $script:WarnCount) + } + exit 0 + } else { + Write-Host ("Result: {0} check(s) failed (and {1} warning(s))." -f $script:FailCount, $script:WarnCount) + exit 1 + } +} + +Main