Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/src/main/java/dev/aikido/agent/Agent.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public static void premain(String agentArgs, Instrumentation inst) {
}
logger.info("Zen by Aikido v%s starting.", Config.pkgVersion);
setAikidoSysProperties();
PackageObserver.install(inst);

// Test loading of zen binaries :
loadLibrary();
Expand All @@ -59,6 +60,7 @@ public static void premain(String agentArgs, Instrumentation inst) {

startDaemon(agentArgs);
}

private static class AikidoTransformer {
public static AgentBuilder.Transformer get() {
var adviceAgentBuilder = new AgentBuilder.Transformer.ForAdvice()
Expand Down
40 changes: 40 additions & 0 deletions agent/src/main/java/dev/aikido/agent/PackageObserver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package dev.aikido.agent;

import dev.aikido.agent_api.helpers.packages.RuntimePackageCollector;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

final class PackageObserver implements ClassFileTransformer {
static void install(Instrumentation instrumentation) {
RuntimePackageCollector.start();
instrumentation.addTransformer(new PackageObserver(), false);
for (Class<?> loadedClass : instrumentation.getAllLoadedClasses()) {
observe(loadedClass);
}
}

private static void observe(Class<?> loadedClass) {
try {
RuntimePackageCollector.observeClass(
loadedClass.getName(),
loadedClass.getProtectionDomain()
);
} catch (Throwable ignored) {
// Package reporting must never interfere with agent startup.
}
}

@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer
) {
RuntimePackageCollector.observeClass(className, protectionDomain);
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ public void run() {
Hostnames.HostnameEntry[] hostnames = HostnamesStore.getHostnamesAsList();
RouteEntry[] routes = RoutesStore.getRoutesAsList();
List<User> users = UsersStore.getUsersAsList();
List<RuntimePackage> packages = RuntimePackagesStore.getPackagesAsList();

// Clear data :
StatisticsStore.clear();
HostnamesStore.clear();
RoutesStore.clear();
UsersStore.clear();
RuntimePackagesStore.clear();

// Create and send event :
Heartbeat.HeartbeatEvent event = Heartbeat.get(stats, hostnames, routes, users);
Heartbeat.HeartbeatEvent event = Heartbeat.get(stats, hostnames, routes, users, packages);
Optional<APIResponse> res = api.report(event);
res.ifPresent(ServiceConfigStore::updateFromAPIResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dev.aikido.agent_api.background.cloud.GetManagerInfo;
import dev.aikido.agent_api.storage.Hostnames;
import dev.aikido.agent_api.storage.RuntimePackage;
import dev.aikido.agent_api.storage.ServiceConfigStore;
import dev.aikido.agent_api.storage.statistics.Statistics;
import dev.aikido.agent_api.storage.routes.RouteEntry;
Expand All @@ -22,15 +23,17 @@ public record HeartbeatEvent(
Hostnames.HostnameEntry[] hostnames,
RouteEntry[] routes,
List<User> users,
List<RuntimePackage> packages,
boolean middlewareInstalled
) implements APIEvent {}

public static HeartbeatEvent get(
Statistics.StatsRecord stats, Hostnames.HostnameEntry[] hostnames, RouteEntry[] routes, List<User> users
Statistics.StatsRecord stats, Hostnames.HostnameEntry[] hostnames, RouteEntry[] routes,
List<User> users, List<RuntimePackage> packages
) {
long time = getUnixTimeMS(); // Get current time
GetManagerInfo.ManagerInfo agent = getManagerInfo();
boolean middlewareInstalled = ServiceConfigStore.getConfig().isMiddlewareInstalled();
return new HeartbeatEvent("heartbeat", agent, time, stats, hostnames, routes, users, middlewareInstalled);
return new HeartbeatEvent("heartbeat", agent, time, stats, hostnames, routes, users, packages, middlewareInstalled);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package dev.aikido.agent_api.helpers.packages;

import dev.aikido.agent_api.storage.RuntimePackage;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Pattern;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;

public final class JarPackageScanner {
private static final int MAX_METADATA_BYTES = 1024 * 1024;
private static final Pattern MAVEN_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+");

private JarPackageScanner() {}

public static List<RuntimePackage> findMavenPackages(
String classResourceUrl,
long requiredAt
) {
try {
JarLocation location = JarLocation.parse(classResourceUrl);
if (location == null) {
return List.of();
}
if (location.nestedEntry() == null) {
try (InputStream input = new BufferedInputStream(Files.newInputStream(location.outerJar()))) {
return findMavenPackages(input, requiredAt);
}
}
try (JarFile outerJar = new JarFile(location.outerJar().toFile())) {
JarEntry nestedJar = outerJar.getJarEntry(location.nestedEntry());
if (nestedJar == null) {
return List.of();
}
try (InputStream input = new BufferedInputStream(outerJar.getInputStream(nestedJar))) {
return findMavenPackages(input, requiredAt);
}
}
} catch (IOException | RuntimeException ignored) {
return List.of();
}
}

public static String getJarLocationKey(String classResourceUrl) {
try {
JarLocation location = JarLocation.parse(classResourceUrl);
if (location == null) {
return null;
}
return location.getKey();
} catch (RuntimeException ignored) {
return null;
}
}

private static List<RuntimePackage> findMavenPackages(
InputStream input,
long requiredAt
) throws IOException {
Map<String, RuntimePackage> packages = new LinkedHashMap<>();

try (JarInputStream jar = new JarInputStream(input)) {
JarEntry entry;
while ((entry = jar.getNextJarEntry()) != null) {
if (!entry.isDirectory() && isPomProperties(entry.getName())) {
byte[] metadata = jar.readNBytes(MAX_METADATA_BYTES + 1);
if (metadata.length <= MAX_METADATA_BYTES) {
addMavenPackage(metadata, requiredAt, packages);
}
}
}
}

return packages.values().stream()
.sorted(
Comparator.comparing(RuntimePackage::name)
.thenComparing(RuntimePackage::version)
)
.toList();
}

private static boolean isPomProperties(String name) {
return name.startsWith("META-INF/maven/") && name.endsWith("/pom.properties");
}

private static void addMavenPackage(
byte[] metadata,
long requiredAt,
Map<String, RuntimePackage> packages
) {
Properties properties = new Properties();
try {
properties.load(new ByteArrayInputStream(metadata));
} catch (IOException | IllegalArgumentException ignored) {
return;
}
String groupId = clean(properties.getProperty("groupId"));
String artifactId = clean(properties.getProperty("artifactId"));
String version = clean(properties.getProperty("version"));
if (groupId == null || artifactId == null || version == null) {
return;
}
String packageName = groupId + ":" + artifactId;
if (!MAVEN_PACKAGE_NAME.matcher(packageName).matches()) {
return;
}
add(packageName, version, requiredAt, packages);
}

private static String clean(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}

private static void add(
String name,
String version,
long requiredAt,
Map<String, RuntimePackage> packages
) {
packages.putIfAbsent(name + '\0' + version, new RuntimePackage(name, version, requiredAt));
}

private record JarLocation(Path outerJar, String nestedEntry) {
private static JarLocation parse(String url) {
if (url == null) {
return null;
}
String value = url;
if (value.startsWith("jar:")) {
value = value.substring(4);
}
if (value.startsWith("nested:")) {
value = value.substring(7);
}
String lowerCaseValue = value.toLowerCase(Locale.ROOT);
int outerEnd = lowerCaseValue.indexOf(".jar!/");
int springBootOuterEnd = lowerCaseValue.indexOf(".jar/!");
if (outerEnd < 0 || springBootOuterEnd >= 0 && springBootOuterEnd < outerEnd) {
outerEnd = springBootOuterEnd;
}
if (outerEnd < 0) {
int jarEnd = lowerCaseValue.indexOf(".jar");
if (jarEnd < 0) {
return null;
}
Path jar = toPath(value.substring(0, jarEnd + 4));
return new JarLocation(jar, null);
}

Path outerJar = toPath(value.substring(0, outerEnd + 4));
int nestedStart = outerEnd + 6;
int nestedEnd = lowerCaseValue.indexOf(".jar!/", nestedStart);
if (nestedEnd < 0 && lowerCaseValue.endsWith(".jar")) {
nestedEnd = value.length() - 4;
}
String nestedEntry = null;
if (nestedEnd >= 0) {
nestedEntry = value.substring(nestedStart, nestedEnd + 4);
}
return new JarLocation(outerJar, nestedEntry);
}

private static Path toPath(String value) {
if (value.startsWith("file:")) {
return Path.of(URI.create(value));
}
return Path.of(value);
}

private String getKey() {
String key = outerJar.toAbsolutePath().normalize().toString();
if (nestedEntry != null) {
key += "!/" + nestedEntry;
}
return key;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package dev.aikido.agent_api.helpers.packages;

import dev.aikido.agent_api.storage.RuntimePackagesStore;

import java.security.ProtectionDomain;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;

public final class RuntimePackageCollector {
private static final BlockingQueue<ObservedLocation> PENDING_LOCATIONS = new LinkedBlockingQueue<>();
private static final Set<String> OBSERVED_LOCATIONS = ConcurrentHashMap.newKeySet();

private RuntimePackageCollector() {}

public static void start() {
Thread worker = new Thread(RuntimePackageCollector::processLocations, "aikido-package-scanner");
worker.setDaemon(true);
worker.start();
}

public static void observeClass(String className, ProtectionDomain protectionDomain) {
if (className == null || className.startsWith("dev/aikido/") || className.startsWith("dev.aikido.")) {
return;
}
try {
if (protectionDomain == null || protectionDomain.getCodeSource() == null) {
return;
}
String location = protectionDomain.getCodeSource().getLocation().toString();
if (isAgentLocation(location)) {
return;
}
String locationKey = JarPackageScanner.getJarLocationKey(location);
if (locationKey != null && OBSERVED_LOCATIONS.add(locationKey)) {
PENDING_LOCATIONS.add(new ObservedLocation(location, System.currentTimeMillis()));
}
} catch (Throwable ignored) {
// Package reporting must never interfere with application class loading.
}
}

private static void processLocations() {
while (!Thread.currentThread().isInterrupted()) {
try {
ObservedLocation location = PENDING_LOCATIONS.take();
RuntimePackagesStore.addAll(JarPackageScanner.findMavenPackages(location.url(), location.requiredAt()));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Throwable ignored) {
// A malformed or inaccessible JAR must not stop future package discovery.
}
}
}

private static boolean isAgentLocation(String location) {
String agentDirectory = System.getProperty("AIK_agent_dir");
if (agentDirectory == null) {
return false;
}
String directoryUrl = new java.io.File(agentDirectory).toURI().toString();
return isAgentJar(location, directoryUrl);
}

private static boolean isAgentJar(String url, String directoryUrl) {
return url.startsWith(directoryUrl + "agent.jar") || url.startsWith(directoryUrl + "agent_api.jar");
}

private record ObservedLocation(String url, long requiredAt) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package dev.aikido.agent_api.storage;

public record RuntimePackage(String name, String version, long requiredAt) {}
Loading
Loading