com.github.alexdlaird.ngrok.installer.NgrokInstaller Maven / Gradle / Ivy
The newest version!
/*
* Copyright (c) 2021-2024 Alex Laird
*
* SPDX-License-Identifier: MIT
*/
package com.github.alexdlaird.ngrok.installer;
import com.github.alexdlaird.exception.JavaNgrokException;
import com.github.alexdlaird.exception.JavaNgrokInstallerException;
import com.github.alexdlaird.exception.JavaNgrokSecurityException;
import com.github.alexdlaird.http.DefaultHttpClient;
import com.github.alexdlaird.http.HttpClient;
import com.github.alexdlaird.http.HttpClientException;
import com.github.alexdlaird.ngrok.NgrokClient;
import com.github.alexdlaird.ngrok.conf.JavaNgrokConfig;
import com.google.gson.JsonParseException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.yaml.snakeyaml.Yaml;
import static com.github.alexdlaird.util.StringUtils.isBlank;
import static java.util.Objects.nonNull;
/**
* A helper for downloading and installing the ngrok
for the current system.
*
* Config File
* By default, ngrok
will look for its config file in
* the default location.
* We can override this behavior with {@link JavaNgrokConfig.Builder#withConfigPath(Path)}.
*
* Binary Path
* The java-ngrok
package manages its own ngrok
binary. We can use our ngrok
* binary if we want by setting it with {@link JavaNgrokConfig.Builder#withNgrokPath(Path)} and passing that config to
* {@link NgrokClient}.
*/
public class NgrokInstaller {
public static final String MAC = "DARWIN";
public static final String WINDOWS = "WINDOWS";
public static final String LINUX = "LINUX";
public static final String FREEBSD = "FREEBSD";
public static final List UNIX_BINARIES = List.of(MAC, LINUX, FREEBSD);
public static final Path DEFAULT_NGROK_PATH = Paths.get(getDefaultNgrokDir().toString(),
NgrokInstaller.getNgrokBin());
public static final Path DEFAULT_CONFIG_PATH = Paths.get(getDefaultNgrokDir().toString(), "ngrok.yml");
private static final Logger LOGGER = Logger.getLogger(String.valueOf(NgrokInstaller.class));
private final List validLogLevels = List.of("info", "debug");
private final Yaml yaml = new Yaml();
private final Map> configCache = new HashMap<>();
private final HttpClient httpClient;
/**
* Construct with the {@link DefaultHttpClient}.
*/
public NgrokInstaller() {
this(new DefaultHttpClient.Builder()
.withTimeout(6000)
.build());
}
/**
* Construct with a custom {@link HttpClient}.
*
* @param httpClient The HTTP client.
*/
public NgrokInstaller(final HttpClient httpClient) {
this.httpClient = httpClient;
}
/**
* Get the ngrok
executable for the current system.
*
* @return The name of the ngrok
executable.
*/
public static String getNgrokBin() {
final String system = getSystem();
if (UNIX_BINARIES.contains(system)) {
return "ngrok";
} else {
return "ngrok.exe";
}
}
/**
* Parse the name fo the OS from system properties and return a friendly name.
*
* @return The friendly name of the OS.
* @throws JavaNgrokInstallerException The OS is not supported.
*/
public static String getSystem() {
final String os = System.getProperty("os.name").replaceAll(" ", "").toLowerCase();
if (os.startsWith("mac")) {
return MAC;
} else if (os.startsWith("windows") || os.contains("cygwin")) {
return WINDOWS;
} else if (os.startsWith("linux")) {
return LINUX;
} else if (os.startsWith("freebsd")) {
return FREEBSD;
} else {
throw new JavaNgrokInstallerException(String.format("Unknown os.name: %s", os));
}
}
private static Path getDefaultNgrokDir() {
final String system = getSystem();
final String userHome = System.getProperty("user.home");
if (system.equals(MAC)) {
return Paths.get(userHome, "Library", "Application Support", "ngrok");
} else if (system.equals(WINDOWS)) {
return Paths.get(userHome, "AppData", "Local", "ngrok");
} else {
return Paths.get(userHome, ".config", "ngrok");
}
}
/**
* See {@link #installDefaultConfig(Path, Map, NgrokVersion)}.
*/
public void installDefaultConfig(final Path configPath, final Map data) {
installDefaultConfig(configPath, data, NgrokVersion.V3);
}
/**
* Install the default ngrok
config. If a config is not already present for the given path, create
* one.
*
* @param configPath The path to where the ngrok
config should be installed.
* @param data A map of things to add to the default config.
* @throws JavaNgrokInstallerException An error occurred downloading ngrok
.
*/
public void installDefaultConfig(final Path configPath, final Map data,
final NgrokVersion ngrokVersion) {
try {
Files.createDirectories(configPath.getParent());
if (!Files.exists(configPath)) {
Files.createFile(configPath);
}
final Map config = getNgrokConfig(configPath, false, ngrokVersion);
config.putAll(getDefaultConfig(ngrokVersion));
config.putAll(data);
validateConfig(config);
LOGGER.fine(String.format("Installing default config to %s ...", configPath));
final FileOutputStream out = new FileOutputStream(configPath.toFile());
final StringWriter writer = new StringWriter();
yaml.dump(config, writer);
out.write(writer.toString().getBytes(StandardCharsets.UTF_8));
out.close();
} catch (final IOException e) {
throw new JavaNgrokInstallerException(String.format("An error while installing the default "
+ "ngrok config to %s.", configPath), e);
}
}
/**
* See {@link #installNgrok(Path, NgrokVersion)}.
*/
public void installNgrok(final Path ngrokPath) {
installNgrok(ngrokPath, NgrokVersion.V3);
}
/**
* Download and install the latest ngrok
for the current system, overwriting any existing contents at
* the given path.
*
* @param ngrokPath The path to where the ngrok
binary will be downloaded.
* @param ngrokVersion The major ngrok
version to install.
* @throws JavaNgrokInstallerException An error occurred installing ngrok
.
* @throws JavaNgrokSecurityException An error occurred unzipping the download.
*/
public void installNgrok(final Path ngrokPath, final NgrokVersion ngrokVersion) {
final NgrokCDNUrl ngrokCDNUrl = getNgrokCDNUrl(ngrokVersion);
LOGGER.fine(String.format("Installing ngrok %s to %s%s ...", ngrokVersion, ngrokPath,
Files.exists(ngrokPath) ? ", overwriting" : ""));
final Path ngrokZip = Paths.get(ngrokPath.getParent().toString(), "ngrok.zip");
downloadFile(ngrokCDNUrl.getUrl(), ngrokZip);
installNgrokZip(ngrokZip, ngrokPath);
}
/**
* See {@link #getNgrokCDNUrl(NgrokVersion)}.
*/
public NgrokCDNUrl getNgrokCDNUrl() {
return getNgrokCDNUrl(NgrokVersion.V3);
}
/**
* Determine the ngrok
CDN URL for the current OS and architecture.
*
* @param ngrokVersion The major version of ngrok
to install.
* @return The ngrok
CDN URL.
*/
public NgrokCDNUrl getNgrokCDNUrl(final NgrokVersion ngrokVersion) {
final String arch = getArch();
final String system = getSystem();
final String plat = String.format("%s_%s", system, arch);
LOGGER.fine(String.format("Platform to download: %s", plat));
if (ngrokVersion == NgrokVersion.V2) {
return NgrokV2CDNUrl.valueOf(plat);
} else {
return NgrokV3CDNUrl.valueOf(plat);
}
}
/**
* Validate that the config file at the given path is valid for ngrok
and java-ngrok
.
*
* @param configPath The config path to validate.
*/
public void validateConfig(final Path configPath) {
final Map config = getNgrokConfig(configPath);
validateConfig(config);
}
/**
* Validate that the given map of config items are valid for ngrok
and java-ngrok
.
*
* @param data A map of things to be validated as config items.
* @throws JavaNgrokException A key or value failed validation.
*/
public void validateConfig(final Map data) {
if (data.getOrDefault("web_addr", "127.0.0.1:4040").equals("false")) {
throw new JavaNgrokException("\"web_addr\" cannot be false, as the ngrok API is a "
+ "dependency for java-ngrok");
}
if (data.getOrDefault("log_format", "term").equals("json")) {
throw new JavaNgrokException("\"log_format\" must be \"term\" to be compatible with java-ngrok");
}
if (!validLogLevels.contains((String) data.getOrDefault("log_level", "info"))) {
throw new JavaNgrokException("\"log_level\" must be \"info\" to be compatible with java-ngrok");
}
}
/**
* Get the ngrok
config from the given path.
*
* @param configPath The ngrok
config path to read.
* @param useCache Use the cached version of the config (if populated).
* @return A map of the ngrok
config.
* @throws JavaNgrokInstallerException The config could not be parsed.
*/
public Map getNgrokConfig(final Path configPath, final boolean useCache,
final NgrokVersion ngrokVersion) {
final String key = configPath.toString();
if (!configCache.containsKey(key) || !useCache) {
try {
final String config = Files.readString(configPath);
if (isBlank(config)) {
configCache.put(key, getDefaultConfig(ngrokVersion));
} else {
configCache.put(key, yaml.load(config));
}
} catch (final IOException | JsonParseException e) {
throw new JavaNgrokInstallerException(String.format("An error occurred while parsing "
+ "the config file: %s", configPath), e);
}
}
return configCache.get(key);
}
/**
* See {@link #getNgrokConfig(Path, boolean, NgrokVersion)}.
*/
public Map getNgrokConfig(final Path configPath, final boolean useCache) {
return getNgrokConfig(configPath, useCache, NgrokVersion.V3);
}
/**
* See {@link #getNgrokConfig(Path, boolean, NgrokVersion)}.
*/
public Map getNgrokConfig(final Path configPath) {
return getNgrokConfig(configPath, true);
}
/**
* Get the default config params for the given major version of ngrok
.
*
* @param ngrokVersion The major version of ngrok
installed.
* @return The default config.
*/
public Map getDefaultConfig(final NgrokVersion ngrokVersion) {
if (ngrokVersion == NgrokVersion.V2) {
return new HashMap<>();
} else {
final HashMap config = new HashMap<>();
config.put("version", "2");
config.put("region", "us");
return config;
}
}
private void installNgrokZip(final Path zipPath, final Path ngrokPath) {
try {
final Path dir = ngrokPath.getParent();
LOGGER.fine(String.format("Extracting ngrok binary from %s to %s ...", zipPath, ngrokPath));
Files.createDirectories(dir);
final byte[] buffer = new byte[1024];
final ZipInputStream in = new ZipInputStream(new FileInputStream(zipPath.toFile()));
ZipEntry zipEntry;
while (nonNull(zipEntry = in.getNextEntry())) {
final Path file = Paths.get(dir.toString(), zipEntry.getName());
if (!file.normalize().startsWith(dir)) {
throw new JavaNgrokSecurityException("Bad zip entry, paths don't match");
}
if (zipEntry.isDirectory()) {
if (!Files.isDirectory(file)) {
Files.createDirectories(file);
}
} else {
final Path parent = file.getParent();
if (!Files.isDirectory(parent)) {
Files.createDirectories(parent);
}
final FileOutputStream out = new FileOutputStream(file.toFile());
int len;
while ((len = in.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
out.close();
}
}
in.closeEntry();
in.close();
if (ngrokPath.getFileSystem().supportedFileAttributeViews().contains("posix")) {
final Set perms = Files.readAttributes(ngrokPath, PosixFileAttributes.class)
.permissions();
perms.add(PosixFilePermission.OWNER_EXECUTE);
perms.add(PosixFilePermission.GROUP_EXECUTE);
perms.add(PosixFilePermission.OTHERS_EXECUTE);
Files.setPosixFilePermissions(ngrokPath, perms);
}
} catch (final IOException e) {
throw new JavaNgrokInstallerException("An error occurred while unzipping ngrok.", e);
}
}
private void downloadFile(final String url, final Path dest) {
try {
Files.createDirectories(dest.getParent());
LOGGER.fine(String.format("Download ngrok from %s ...", url));
httpClient.get(url, List.of(), Map.of(), dest);
} catch (final IOException | HttpClientException | InterruptedException e) {
throw new JavaNgrokInstallerException(String.format("An error occurred while downloading "
+ "ngrok from %s.", url), e);
}
}
private String getArch() {
final String archProperty = System.getProperty("os.arch");
final StringBuilder arch = new StringBuilder();
if (archProperty.contains("x86_64")) {
arch.append("x86_64");
} else {
arch.append("i386");
}
if (archProperty.startsWith("arm") || archProperty.startsWith("aarch64")) {
arch.append("_arm");
}
return arch.toString();
}
}