All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.alexdlaird.ngrok.process.NgrokProcess Maven / Gradle / Ivy

The newest version!
/*
 * 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}.
 */
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; } private 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; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy