com.github.alexdlaird.ngrok.process.NgrokProcess Maven / Gradle / Ivy
/*
* Copyright (c) 2021-2024 Alex Laird
*
* SPDX-License-Identifier: MIT
*/
package com.github.alexdlaird.ngrok.process;
import com.github.alexdlaird.exception.JavaNgrokSecurityException;
import com.github.alexdlaird.exception.NgrokException;
import com.github.alexdlaird.http.DefaultHttpClient;
import com.github.alexdlaird.http.HttpClient;
import com.github.alexdlaird.http.Response;
import com.github.alexdlaird.ngrok.NgrokClient;
import com.github.alexdlaird.ngrok.conf.JavaNgrokConfig;
import com.github.alexdlaird.ngrok.installer.NgrokInstaller;
import com.github.alexdlaird.ngrok.installer.NgrokVersion;
import com.github.alexdlaird.ngrok.protocol.Tunnels;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import static com.github.alexdlaird.util.StringUtils.isBlank;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static java.util.logging.Level.SEVERE;
/**
* An object containing information about the ngrok
process. Can be configured with
* {@link JavaNgrokConfig}.
*
* Basic Usage
* Opening a tunnel will start the ngrok
process. This process will remain alive, and the tunnels open,
* until {@link NgrokProcess#stop()} is invoked, or until the Java process terminates.
*
* Event Logs
* When ngrok
emits logs, java-ngrok
can surface them to a callback function. To register
* this callback, use {@link JavaNgrokConfig.Builder#withLogEventCallback}.
*
* If these events aren’t necessary for our use case, some resources can be freed up by turning them off.
* {@link JavaNgrokConfig.Builder#withoutMonitoring} will disable logging, or we can call
* {@link NgrokProcess.ProcessMonitor#stop()} to stop monitoring on a running process.
*/
public class NgrokProcess {
private static final Logger LOGGER = Logger.getLogger(String.valueOf(NgrokProcess.class));
private final JavaNgrokConfig javaNgrokConfig;
private final NgrokInstaller ngrokInstaller;
private Process process;
private ProcessMonitor processMonitor;
/**
* If ngrok
is not already installed at {@link JavaNgrokConfig#getNgrokPath()}, the given
* {@link NgrokInstaller} will install it. This will also provision a default ngrok
config at
* {@link JavaNgrokConfig#getConfigPath()}, if none exists.
*
* @param javaNgrokConfig The config to use when interacting with the ngrok
binary.
* @param ngrokInstaller The class used to download and install ngrok
.
*/
public NgrokProcess(final JavaNgrokConfig javaNgrokConfig,
final NgrokInstaller ngrokInstaller) {
this.javaNgrokConfig = javaNgrokConfig;
this.ngrokInstaller = ngrokInstaller;
if (!Files.exists(javaNgrokConfig.getNgrokPath())) {
ngrokInstaller.installNgrok(javaNgrokConfig.getNgrokPath(), javaNgrokConfig.getNgrokVersion());
}
if (!Files.exists(javaNgrokConfig.getConfigPath())) {
ngrokInstaller.installDefaultConfig(javaNgrokConfig.getConfigPath(), Map.of(),
javaNgrokConfig.getNgrokVersion());
}
}
/**
* Get the class used to download and install ngrok
.
*/
public NgrokInstaller getNgrokInstaller() {
return ngrokInstaller;
}
/**
* Get the Runnable that is monitoring the ngrok
thread.
*/
public ProcessMonitor getProcessMonitor() {
return processMonitor;
}
/**
* If not already running, start a ngrok
process with no tunnels. This will start the
* ngrok
web interface, against which HTTP requests can be made to create, interact with, and
* destroy tunnels.
*
* @throws NgrokException ngrok
could not start.
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public void start() {
if (isRunning()) {
return;
}
if (!Files.exists(javaNgrokConfig.getNgrokPath())) {
throw new NgrokException(String.format("ngrok binary was not found. "
+ "Be sure to call \"NgrokInstaller.installNgrok()\" first for "
+ "\"ngrokPath\": %s",
javaNgrokConfig.getNgrokPath()));
}
ngrokInstaller.validateConfig(javaNgrokConfig.getConfigPath());
final ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.redirectErrorStream(true);
processBuilder.inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE);
final List command = new ArrayList<>();
command.add(javaNgrokConfig.getNgrokPath().toString());
command.add("start");
command.add("--none");
command.add("--log=stdout");
if (nonNull(javaNgrokConfig.getConfigPath())) {
LOGGER.info(String.format("Starting ngrok with config file: %s", javaNgrokConfig.getConfigPath()));
command.add(String.format("--config=%s", javaNgrokConfig.getConfigPath().toString()));
}
if (nonNull(javaNgrokConfig.getAuthToken())) {
LOGGER.info("Overriding default auth token");
command.add(String.format("--authtoken=%s", javaNgrokConfig.getAuthToken()));
}
if (nonNull(javaNgrokConfig.getRegion())) {
LOGGER.info(String.format("Starting ngrok in region: %s", javaNgrokConfig.getRegion()));
command.add(String.format("--region=%s", javaNgrokConfig.getRegion()));
}
processBuilder.command(command);
try {
process = processBuilder.start();
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
LOGGER.fine(String.format("ngrok process starting with PID: %s", process.pid()));
processMonitor = new ProcessMonitor(process, javaNgrokConfig);
new Thread(processMonitor).start();
final Calendar timeout = Calendar.getInstance();
timeout.add(Calendar.SECOND, javaNgrokConfig.getStartupTimeout());
while (Calendar.getInstance().before(timeout)) {
if (processMonitor.isHealthy()) {
LOGGER.info(String.format("ngrok process has started with API URL: %s", processMonitor.apiUrl));
processMonitor.startupError = null;
break;
} else if (!process.isAlive()) {
break;
}
}
if (!processMonitor.isHealthy()) {
// If the process did not come up in a healthy state, clean up the state
stop();
if (nonNull(processMonitor.startupError)) {
throw new NgrokException(String.format("The ngrok process errored on start: %s.",
processMonitor.startupError), processMonitor.logs, processMonitor.startupError);
} else {
throw new NgrokException("The ngrok process was unable to start.", processMonitor.logs);
}
}
} catch (final IOException e) {
throw new NgrokException("An error occurred while starting ngrok.", e);
}
}
/**
* Check if this object is currently managing a running ngrok
process.
*/
public boolean isRunning() {
return nonNull(process) && process.isAlive();
}
/**
* Terminate the ngrok
processes, if running. This method will not block, it will just issue a kill
* request.
*/
public void stop() {
if (!isRunning()) {
LOGGER.info(String.format("\"ngrokPath\" %s is not running a process", javaNgrokConfig.getNgrokPath()));
return;
}
LOGGER.info(String.format("Killing ngrok process: %s", process.pid()));
processMonitor.stop();
process.descendants().forEach(ProcessHandle::destroy);
process.destroy();
try {
if (nonNull(processMonitor.reader)) {
processMonitor.reader.close();
}
} catch (final IOException e) {
LOGGER.log(Level.WARNING, "An error occurred when closing \"ProcessMonitor.reader\"", e);
}
process = null;
processMonitor = null;
}
/**
* Set the ngrok
auth token in the config file, enabling authenticated features (for instance, more
* concurrent tunnels, custom subdomains, etc.).
*
*
* // Setting an auth token allows us to do things like open multiple tunnels at the same time
* final NgrokClient ngrokClient = new NgrokClient.Builder().build();
* ngrokClient.setAuthToken("<NGROK_AUTHTOKEN>")
*
* // <NgrokTunnel: "http://<public_sub1>.ngrok.io" -> "http://localhost:80">
* final Tunnel ngrokTunnel1 = ngrokClient.connect();
* // <NgrokTunnel: "http://<public_sub2>.ngrok.io" -> "http://localhost:8000">
* final CreateTunnel sshCreateTunnel = new CreateTunnel.Builder()
* .withAddr(8000)
* .build();
* final Tunnel ngrokTunnel2 = ngrokClient.connect(createTunnel);
*
*
* The auth token can also be set in the {@link JavaNgrokConfig} that is passed to the
* {@link NgrokClient.Builder}.
*
* @param authToken The auth token.
* @throws NgrokException ngrok
could not start.
*/
public void setAuthToken(final String authToken) {
final ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.redirectErrorStream(true);
processBuilder.inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE);
final List command = new ArrayList<>();
command.add(javaNgrokConfig.getNgrokPath().toString());
if (javaNgrokConfig.getNgrokVersion() == NgrokVersion.V2) {
command.add("authtoken");
command.add(authToken);
} else {
command.add("config");
command.add("add-authtoken");
command.add(authToken);
}
command.add("--log=stdout");
if (nonNull(javaNgrokConfig.getConfigPath())) {
command.add(String.format("--config=%s", javaNgrokConfig.getConfigPath().toString()));
}
LOGGER.info(String.format("Updating authtoken for \"configPath\": %s", javaNgrokConfig.getConfigPath()));
processBuilder.command(command);
try {
final Process process = processBuilder.start();
process.waitFor();
final BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
final String result = captureOutput(reader);
reader.close();
if (!result.contains("Authtoken saved")) {
throw new NgrokException(String.format("An error occurred while setting the auth token: %s", result));
}
} catch (final IOException | InterruptedException e) {
throw new NgrokException("An error occurred while setting the auth token for ngrok.", e);
}
}
/**
* Update ngrok
, if an update is available.
*
* @throws NgrokException ngrok
could not start.
*/
public void update() {
final ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.redirectErrorStream(true);
processBuilder.inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE);
final List command = List.of(javaNgrokConfig.getNgrokPath().toString(), "update", "--log=stdout");
processBuilder.command(command);
try {
final Process process = processBuilder.start();
process.waitFor();
} catch (final IOException | InterruptedException e) {
throw new NgrokException("An error occurred while trying to update ngrok.", e);
}
}
/**
* Get the ngrok
version.
*
* @return The version.
* @throws NgrokException ngrok
could not start.
*/
public String getVersion() {
final ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.redirectErrorStream(true);
processBuilder.inheritIO().redirectOutput(ProcessBuilder.Redirect.PIPE);
final List command = List.of(javaNgrokConfig.getNgrokPath().toString(), "--version");
processBuilder.command(command);
try {
final Process process = processBuilder.start();
process.waitFor();
final BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
final String result = captureOutput(reader);
reader.close();
return result.split("version ")[1];
} catch (final IOException | InterruptedException | ArrayIndexOutOfBoundsException e) {
throw new NgrokException("An error occurred while trying to update ngrok.", e);
}
}
/**
* Get the API URL for the ngrok
web interface.
*
* @throws JavaNgrokSecurityException The URL was not supported.
*/
public String getApiUrl() {
if (!isRunning() || !processMonitor.isHealthy()) {
return null;
}
return processMonitor.apiUrl;
}
private String captureOutput(final BufferedReader reader)
throws IOException {
final StringBuilder builder = new StringBuilder();
String line;
while (nonNull(line = reader.readLine())) {
builder.append(line).append("\n");
}
return builder.toString().trim();
}
/**
* A Runnable that monitors the ngrok
thread.
*/
public static class ProcessMonitor implements Runnable {
private final List logs = new ArrayList<>();
private final Process process;
private final JavaNgrokConfig javaNgrokConfig;
private final HttpClient httpClient;
private boolean alive = true;
private String apiUrl;
private boolean tunnelStarted;
private boolean clientConnected;
private String startupError;
private BufferedReader reader;
/**
* Construct to monitor a {link @Process} monitor.
*
* @param process The Process to monitor.
* @param javaNgrokConfig The config to use when monitoring the Process.
*/
public ProcessMonitor(final Process process,
final JavaNgrokConfig javaNgrokConfig) {
this(process, javaNgrokConfig, new DefaultHttpClient.Builder().build());
}
/**
* Construct to monitor a {@link Process} monitor with a custom {@link HttpClient}.
*
* @param process The Process to monitor.
* @param javaNgrokConfig The config to use when monitoring the Process.
* @param httpClient The custom HTTP client.
*/
protected ProcessMonitor(final Process process,
final JavaNgrokConfig javaNgrokConfig,
final HttpClient httpClient) {
this.process = process;
this.javaNgrokConfig = javaNgrokConfig;
this.httpClient = httpClient;
}
@Override
public void run() {
try {
reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
String line;
while (nonNull(line = reader.readLine())) {
logStartupLine(line);
if (isHealthy()) {
break;
} else if (nonNull(startupError)) {
alive = false;
break;
}
}
while (alive && process.isAlive()
&& javaNgrokConfig.isKeepMonitoring()
&& nonNull(line = reader.readLine())) {
logLine(line);
}
alive = false;
} catch (final IOException e) {
throw new NgrokException("An error occurred in the ngrok process.", e);
}
}
/**
* Get the ngrok
logs.
*/
public List getLogs() {
return List.of(logs.toArray(new NgrokLog[]{}));
}
/**
* Get whether the thread is continuing to monitor ngrok
logs.
*/
public boolean isMonitoring() {
return alive;
}
/**
* Set the monitor thread to stop monitoring the ngrok process after the next log event. This will not
* necessarily terminate the process immediately, as the process may currently be idle, rather it sets a flag
* on the thread telling it to terminate the next time it wakes up.
*
* This has no impact on the ngrok process itself, only java-ngrok
’s monitor of the process and
* its logs.
*/
public void stop() {
this.alive = false;
}
private boolean isHealthy() {
if (isNull(apiUrl) || !tunnelStarted || !clientConnected) {
return false;
}
if (!apiUrl.toLowerCase().startsWith("http")) {
throw new JavaNgrokSecurityException(String.format("URL must start with \"http\": %s", apiUrl));
}
final Response tunnelsResponse = httpClient.get(String.format("%s/api/tunnels", apiUrl),
Tunnels.class);
if (tunnelsResponse.getStatusCode() != HTTP_OK) {
return false;
}
return process.isAlive();
}
private void logStartupLine(final String line) {
final NgrokLog ngrokLog = logLine(line);
if (isNull(ngrokLog)) {
return;
}
if (nonNull(ngrokLog.getLvl()) && ngrokLog.getLvl().equals(SEVERE.getName())) {
this.startupError = ngrokLog.getErr();
} else if (nonNull(ngrokLog.getMsg())) {
// Log ngrok startup states as they come in
if (ngrokLog.getMsg().contains("starting web service") && nonNull(ngrokLog.getAddr())) {
this.apiUrl = String.format("http://%s", ngrokLog.getAddr());
} else if (ngrokLog.getMsg().contains("tunnel session started")) {
this.tunnelStarted = true;
} else if (ngrokLog.getMsg().contains("client session established")) {
this.clientConnected = true;
}
}
}
private NgrokLog logLine(final String line) {
final NgrokLog ngrokLog = new NgrokLog(line);
if (isBlank(ngrokLog.getLine())) {
return null;
}
LOGGER.log(Level.parse(ngrokLog.getLvl()), ngrokLog.getLine());
logs.add(ngrokLog);
if (logs.size() > javaNgrokConfig.getMaxLogs()) {
logs.remove(0);
}
if (nonNull(javaNgrokConfig.getLogEventCallback())) {
javaNgrokConfig.getLogEventCallback().apply(ngrokLog);
}
return ngrokLog;
}
}
}