TT Lab
Get started
Learn Learning paths Courses

Air-Gapped Mirrors and a Private CA

An HTTPS internal Maven mirror the JVM trusts

Continue in TT Lab

Goal

Collect dependencies and plugins on the connected side, stand up an internal Maven repository over HTTPS, and redirect every request with the mirror in settings.xml. Make the JVM trust the private CA, build with a new local repository on the air-gapped side, and finish the procedure for additionally importing a plugin that was missing from the import list.

Why it matters

Air-gapped failures of Java builds come in three layers — the repository address, the certificate, and a hole in the import list. The address is handled by a single mirror entry, the certificate by the JVM trust store, and the hole by the habit of "making the list with the goals you will actually run". This Pod can reach the internet (80/443), so it plays the download side, and on the air-gapped side mirrorOf * redirects every request to the internal repository so that the outside cannot be used. The grader checks where files were downloaded from by the _remote.repositories of the local repository and the access log of the internal server.

Steps

  1. In /root/mvn/app, create pom.xml (gson 2.11.0, plugin versions pinned) and src/main/java/demo/App.java, and run package with a new local repository /root/mvn/outside-repo.
  2. Move /root/mvn/outside-repo to /srv/maven, but strip _remote.repositories, *.lastUpdated, and resolver-status.properties.
  3. In /root/pki, create a root with CN Airgap Internal Root CA (ca.crt and ca.key) and a maven.airgap.internal server certificate (maven.crt and maven.key, with a SAN), and add the name to /etc/hosts.
  4. Start nginx with /root/mvn/nginx.conf so that https://maven.airgap.internal:8443/ serves /srv/maven. The access log is /root/mvn/access.log.
  5. In ~/.m2/settings.xml, write a mirror with id airgap-internal and mirrorOf *. Also create an empty configuration /root/mvn/outside-settings.xml to use when playing the connected side.
  6. Run package with the air-gapped side's new local repository /root/mvn/inside-repo and save the failing output in /root/mvn/pkix.log.
  7. Make the JVM trust the private root (do not use options that disable verification).
  8. With the same command, get the air-gapped side's build to pass.
  9. Write the coordinates of the artifact that makes mvn clean package fail in /root/mvn/missing.txt (groupId:artifactId:version), download it on the connected side and add it to the internal repository, and then get clean package to pass on the air-gapped side.

Notes

Download side: build with a new local repository to collect

Create pom.xml and App.java in /root/mvn/app and run package with a new local repository /root/mvn/outside-repo.

You can change the location of the local repository with a system property. If you give a new directory, only what this build actually downloaded piles up. If you do not pin the plugin versions in the POM, this Maven's default compiler does not know the JDK 21 settings.

Strip the tracking files and make the internal repository

Move /root/mvn/outside-repo to /srv/maven, but strip the tracking files (_remote.repositories, *.lastUpdated, and resolver-status.properties).

The internal repository only needs to be the file layout as it is. The tracking files are the record in which the downloading side's local repository writes "where it came from", so they must not remain in the repository. You can pick names with find and delete them.

Issue the mirror certificate with a private CA

In /root/pki, create a root (ca.crt and ca.key, CN Airgap Internal Root CA) and a maven.airgap.internal certificate (maven.crt and maven.key, with a SAN), and add the name to /etc/hosts.

The root is self-signed with CA:TRUE, and the server certificate is signed with the root while giving the SAN through an extension file. It is the same procedure as in the private CA module.

Serve the HTTPS internal repository with nginx

Start nginx with /root/mvn/nginx.conf so that https://maven.airgap.internal:8443/ serves /srv/maven, and keep the access log in /root/mvn/access.log.

A Maven repository is static files, so a single root is enough. In this Pod you cannot open ports below 1024, and the pid and log paths must be moved to places you can write to. Check first with -t.

Redirect every request with mirrorOf *

In ~/.m2/settings.xml, write a mirror with id airgap-internal, mirrorOf *, and url https://maven.airgap.internal:8443/, and create an empty configuration /root/mvn/outside-settings.xml.

The user settings file is under .m2 in the home directory. If you give mirrorOf an asterisk, it intercepts even the repositories declared by the POM. The configuration for the outside role only needs a minimal settings element with no mirror.

A CA the JVM does not know — record the PKIX error

Run package with the new local repository /root/mvn/inside-repo and save the failing output in /root/mvn/pkix.log.

Telling curl about the root and the JVM trusting the root are separate things. Save the whole failing output, and look in the error lines for the wording about the certificate path.

Put the root in the JVM trust store

Make the JVM trust the private root (do not use options that disable verification).

The JDK has a tool for handling trust stores, with an option that points directly at the default store (cacerts). There is an initial password that the documentation gives. On Ubuntu, there is also a route where updating the operating system store updates the JVM store as well.

The air-gapped side's build passes

Get the build to pass with the same command as in step 6 (/root/mvn/inside-repo, package).

Once the JVM trusts the root, the same command should pass. After it passes, look at which repository id the tracking files in the air-gapped side's local repository recorded.

The additional import that the word clean caused

Write the coordinates that caused the mvn clean package failure in /root/mvn/missing.txt as groupId:artifactId:version, download it on the connected side and add it to the internal repository, and then get clean package to pass on the air-gapped side.

The error line tells you which plugin could not be found. For the outside role, run the same goal with the empty settings file and the outside's local repository, and the plugin is collected. If it still fails after you move it again, read the error sentence to the end — the earlier failure is recorded.