diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b250fa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +# Ignore emacs swapfiles +\#* +.\#* + +# Ignore vim swapfiles +*\~ + +# Dont commit certs +resources/* + +# Dont commit certs or compiled software +staging/* + +# Test Logs +JobServ-Server-* diff --git a/README.md b/README.md index 1983a34..4f6cf1f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,49 @@ # JobServ Remote Procedure Calls over the protobuf API -# Dependancies +# Requirements +- openssl +- tar # Building - -# Testing +Gradle will manage dependencies, generate code, compile the java, and package the code. +Simply run the folllowing command: +```shell +$ ./buildwrapper.sh +``` +Buildwrapper will ask you for details about the client and server. If you are testing this software both CNs can be set to localhost. +Buildwrapper will then generate CAs for and signed certs for the Client and Server. In addition a seperate, third CA and cert will be generated for testing purposes. +Gradle will then generate protobuf source and compile it with the java source for the client and server. +After gradle is finished compiling and running the junit tests, buildwrapper will organize the sources with their respective certs in the staging folder. +In addition to a server folder and a client folder, there will be a test folder which has a copy of all certs and both server and client functionality. +The test CA is not trusted by the server or the client by default. As such, the test cert can be used to induce a mutual tls authentication failure. # Running +After build, the programs can be found in the staging folder. +After changing directory to the 'staging/client' folder or the 'staging/server' folder, either program can be run as follows: + +``` +$ ./server (port) +$ ./client (hostname) (port) (command) (arguments) +``` +For example: +``` +$ ./buildwrapper.sh + ..... +$ cd staging/server +$ ./server 8448 & +$ cd ../client +$ client localhost 8448 new ping archive.org +``` +alternatively, for guidance: +``` +$ ./server +$ ./client help +``` + +# Distribution +At this point you can copy the staging/client or staging/server folders to any environment in which their Certificate CN's are valid. + +# Testing +Running the gradle test task, or the buildwrapper will run all junit tests. +Currently that includes a test of certificate based authentication (Mutual TLS), tests for the thread safe process control module, and tests ensuring that only one connection can access a processes information at a time. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..ba5b8d2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,111 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java project to get you started. + * For more details take a look at the Java Quickstart chapter in the Gradle + * User Manual available at https://docs.gradle.org/5.2.1/userguide/tutorial_java_projects.html + */ + +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8' + } +} + +plugins { + id 'java' + id 'com.google.protobuf' version '0.8.8' + id 'application' + id 'com.adarshr.test-logger' version '1.6.0' +} + +def grpcVersion = '1.20.0' + +repositories { + //maven{ url "https://maven-central.storage-download.googleapis.com/repos/central/data/" } + //mavenLocal() + mavenCentral() +} + +dependencies { + // This dependency is found on compile classpath of this component and consumers. + implementation 'com.google.guava:guava:27.0.1-jre' + + // Use JUnit test framework + testImplementation "io.grpc:grpc-testing:${grpcVersion}" + testImplementation "junit:junit:4.12" + testImplementation "org.mockito:mockito-core:2.25.1" + + // Used by GRPC generated code + compile 'org.glassfish:javax.annotation:10.0-b28' + + // grpc stuff + compile "io.grpc:grpc-netty:${grpcVersion}" + compile "io.grpc:grpc-protobuf:${grpcVersion}" + compile "io.grpc:grpc-stub:${grpcVersion}" + compile 'io.netty:netty-tcnative-boringssl-static:2.0.22.Final' +} + +test { + testLogging.showStandardStreams = true + testLogging.exceptionFormat = 'full' +} + +testlogger { + theme 'standard' + showExceptions true + slowThreshold 2000 + showSummary true + showPassed true + showSkipped true + showFailed true + showStandardStreams false + showPassedStandardStreams true + showSkippedStandardStreams true + showFailedStandardStreams true +} + +// Define the main class for the application +mainClassName = 'JobServ.JobServClient' + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.7.1" + } + + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}" + } + } + + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} + +task Server(type: CreateStartScripts) { + mainClassName = 'JobServ.JobServServer' + applicationName = 'jobserv-server' + outputDir = new File(project.buildDir, 'tmp') + classpath = startScripts.classpath +} + +task Client(type: CreateStartScripts) { + mainClassName = 'JobServ.JobServClient' + applicationName = 'jobserv-client' + outputDir = new File(project.buildDir, 'tmp') + classpath = startScripts.classpath +} + +applicationDistribution.into('bin') { + from(Server) + from(Client) + fileMode = 0755 +} diff --git a/buildwrapper.sh b/buildwrapper.sh new file mode 100755 index 0000000..d3183f7 --- /dev/null +++ b/buildwrapper.sh @@ -0,0 +1,126 @@ +#!/bin/sh + +read -p "Enter Server CN (localhost or address): " SRVNAME +read -p "Enter Client CN (localhost or address): " CLTNAME + +SERVER_CA_CN=jobserv-server-ca +SERVER_PATH=resources/server +CLIENT_CA_CN=jobserv-client-ca +CLIENT_PATH=resources/client +TEST_CA_CN=jobserv-bad-cert-ca +TEST_CN=localhost +TEST_PATH=resources/test + +# refactor this to test for directory existanc +rm -rf resources +mkdir resources/ +mkdir resources/client +mkdir resources/server +mkdir resources/test +rm -rf staging + + +# Get passwords for CAs +read -p "Enter Server CA Passphrase: " SRVCAPASS +read -p "Enter Client CA Passphrase: " CLTCAPASS + +# Generate CA Keys +echo "[+] Generating Server CA Key" +openssl genrsa -passout pass:$SRVCAPASS -aes256 -out $SERVER_PATH/ca.key 4096 +echo "[+] Generating Client CA Key" +openssl genrsa -passout pass:$CLTCAPASS -aes256 -out $CLIENT_PATH/ca.key 4096 +echo "[+] Generating test CA Key" +openssl genrsa -passout pass:dontusethiskey -aes256 -out $TEST_PATH/ca.key 4096 + +# Generate CA Certs +echo "[+] Generating Server CA Cert" +openssl req -passin pass:$SRVCAPASS -new -x509 -days 365 -key $SERVER_PATH/ca.key -out $SERVER_PATH/ca.crt -subj "/CN=${SERVER_CA_CN}" +echo "[+] Generating Client CA Cert" +openssl req -passin pass:$CLTCAPASS -new -x509 -days 365 -key $CLIENT_PATH/ca.key -out $CLIENT_PATH/ca.crt -subj "/CN=${CLIENT_CA_CN}" +echo "[+] Generating test CA Key" +openssl req -passin pass:dontusethiskey -new -x509 -days 365 -key $TEST_PATH/ca.key -out $TEST_PATH/ca.crt -subj "/CN=${TEST_CA_CN}" + + +# Generate Server Key, Signing request, cert +echo "[+] Generating Server key" +openssl genrsa -passout pass:${SRVCAPASS} -aes256 -out $SERVER_PATH/private.key 4096 +echo "[+] Generating Server signing request" +openssl req -passin pass:${SRVCAPASS} -new -key $SERVER_PATH/private.key -out $SERVER_PATH/request.csr -subj "/CN=${SRVNAME}" +echo "[+] Generating Server certificate " +openssl x509 -req -passin pass:${SRVCAPASS} -days 365 -in $SERVER_PATH/request.csr -CA $SERVER_PATH/ca.crt -CAkey $SERVER_PATH/ca.key -set_serial 01 -out $SERVER_PATH/server.crt +echo "[+] Removing passphrase from server key" +openssl rsa -passin pass:${SRVCAPASS} -in $SERVER_PATH/private.key -out $SERVER_PATH/private.key + +# Generate Client Key, Signing request, cert +echo "[+] Generating Client key" +openssl genrsa -passout pass:${CLTCAPASS} -aes256 -out $CLIENT_PATH/private.key 4096 +echo "[+] Generating Client signing request" +openssl req -passin pass:${CLTCAPASS} -new -key $CLIENT_PATH/private.key -out $CLIENT_PATH/request.csr -subj "/CN=${CLTNAME}" +echo "[+] Generating Client certificate " +openssl x509 -req -passin pass:${CLTCAPASS} -days 365 -in $CLIENT_PATH/request.csr -CA $CLIENT_PATH/ca.crt -CAkey $CLIENT_PATH/ca.key -set_serial 01 -out $CLIENT_PATH/client.crt +echo "[+] Removing passphrase from client key" +openssl rsa -passin pass:${CLTCAPASS} -in $CLIENT_PATH/private.key -out $CLIENT_PATH/private.key + +# Generate Test Key, Signing request, cert +echo "[+] Generating test key" +openssl genrsa -passout pass:dontusethiskey -aes256 -out $TEST_PATH/private.key 4096 +echo "[+] Generating test signing request" +openssl req -passin pass:dontusethiskey -new -key $TEST_PATH/private.key -out $TEST_PATH/request.csr -subj "/CN=${TEST_CN}" +echo "[+] Generating test certificate " +openssl x509 -req -passin pass:dontusethiskey -days 365 -in $TEST_PATH/request.csr -CA $TEST_PATH/ca.crt -CAkey $TEST_PATH/ca.key -set_serial 01 -out $TEST_PATH/test.crt +echo "[+] Removing passphrase from test key" +openssl rsa -passin pass:dontusethiskey -in $TEST_PATH/private.key -out $TEST_PATH/private.key + + +echo "[+] Converting private keys to X.509" +openssl pkcs8 -topk8 -nocrypt -in $CLIENT_PATH/private.key -out $CLIENT_PATH/private.pem +openssl pkcs8 -topk8 -nocrypt -in $SERVER_PATH/private.key -out $SERVER_PATH/private.pem +openssl pkcs8 -topk8 -nocrypt -in $TEST_PATH/private.key -out $TEST_PATH/private.pem + +echo "[+] initiating gradle build" +./gradlew clean build + +# Ideally this next section would be done with gradle +# Unfortunately gradle's protobuf distribution plugin does not seem to have facilities to manually include certs +# Or to specify seperate client and server tarballs for that matter +# Definitely more research on gradle should be done, but after JobServ hits MVP +echo "[+] extracting built code" +mkdir staging +mkdir staging/client +mkdir staging/server +mkdir staging/test +tar -xvf build/distributions/JobServ.tar -C staging/client +tar -xvf build/distributions/JobServ.tar -C staging/server +tar -xvf build/distributions/JobServ.tar -C staging/test + +echo "[+] removing server capabilities from client" +rm staging/client/JobServ/bin/jobserv-server staging/client/JobServ/bin/jobserv-server.bat + +echo "[+] removing client capabilities from server" +rm staging/server/JobServ/bin/jobserv-client staging/server/JobServ/bin/jobserv-client.bat + +echo "[+] populating certificates" +cp resources/server/server.crt staging/server/ +cp resources/server/private.pem staging/server/ +cp resources/client/ca.crt staging/server/ +cp resources/client/client.crt staging/client/ +cp resources/client/private.pem staging/client/ +cp resources/server/ca.crt staging/client/ +cp -r resources/* staging/test/ + +echo "[+] Adding wrapper script for client" +# This could also be a .desktop file without much more work. +cat << EOF > staging/client/client + ./JobServ/bin/jobserv-client private.pem client.crt ca.crt \$@ +EOF +chmod +x staging/client/client + +echo "[+] Adding wrapper script for server" +# This could also be a .desktop file without much more work. +cat << EOF > staging/server/server + ./JobServ/bin/jobserv-server \$1 server.crt private.pem ca.crt +EOF +chmod +x staging/server/server + +echo "[+] removing test logs" +rm JobServ-Server-* diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..44e7c4d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..0f8d593 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/JobServ/JobServClient.java b/src/main/java/JobServ/JobServClient.java new file mode 100644 index 0000000..3c66e53 --- /dev/null +++ b/src/main/java/JobServ/JobServClient.java @@ -0,0 +1,296 @@ +/* + * JobServClient + * + * v1.0 + * + * May 18, 2019 + */ + +package JobServ; + +import io.grpc.netty.GrpcSslContexts; +import io.grpc.ManagedChannel; +import java.util.InputMismatchException; +import io.grpc.ManagedChannelBuilder; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import javax.net.ssl.SSLException; +import io.grpc.netty.NettyChannelBuilder; +import java.util.Scanner; +import java.io.File; + +/* + * The JobServClient class extends the gRPC stub code + * Additionally, it plugs a command line interface into the API code. + */ +public class JobServClient { + private final String serversideTimeoutErrorMessage = "Timeout locking process control on server\n"+ + "Server could be under heavy load\nConsider trying again."; + + private JobServClientAPIConnector api; + private String[] programArgs; + + /* + * Constructor + * takes program arguments and an api connector object + */ + public JobServClient(String[] args, JobServClientAPIConnector api) { + this.programArgs = args; + this.api = api; + } + + /* + * getPidArg() + * reentrant code was found in all commands except newjob + * this function pulls the pid argument and wraps around the integer cast + * returns -1 (an invalid PID) if bad index or unparsable int + */ + private int getPidArg(int index) { + if (this.programArgs.length < index) { + System.out.println("Improper formatting, try client --help"); + return -1; + } + + try { + return Integer.parseInt(this.programArgs[index]); + + } catch (NumberFormatException e) { + System.out.println(this.programArgs[index] + " is not a valid integer"); + return -1; + } + + } + + /* + * outputHelp() + * writes help information about all commands in the shell to screen + */ + public static void outputHelp() { + System.out.println("... new (command)\n"+ + "Starts a new process on the server\n"+ + "example: ./client key.pem cert.crt ca.crt localhost 8448 new echo hello world!\n\n"+ + "... output (pid) (lines)\n"+ + "Garners (lines) lines of output from process (pid) on server\n"+ + "example: ./client key.pem cert.crt ca.crt localhost 8448 output 0 5\n\n"+ + "... status (pid)\n"+ + "Returns whether process on server is running\n"+ + "example: ./client key.pem cert.crt ca.crt localhost 8448 status 0\n\n"+ + "... return (pid)\n"+ + "Collects return code from remote process\n"+ + "example: ./client key.pem cert.crt ca.crt localhost 8448 return 0\n\n"+ + "... kill (pid)\n"+ + "Immediately destroys remote process\n"+ + "example: ./client key.pem cert.crt ca.crt localhost 8448 kill 0"); + } + + /* + * makeNewProcess + * makes a new process + */ + public void makeNewProcess() { + String command = ""; + for (int token = 6; token < this.programArgs.length; token++) { + command += " " + this.programArgs[token]; + } + + int newProcess = this.api.sendNewJobMessage(command); + switch(newProcess) { + case -1: + System.out.println("Server failed to spawn process. Bad command."); + break; + + case -2: + // error logged by API Connector + break; + + default: + System.out.printf("Process started, assigned pid is %d\n", newProcess); + break; + } + + return; + } + + /* + * getOutput + * gets output from a process + */ + public void getOutput() { + if (this.programArgs.length < 8) { + System.out.println("Improper formatting, need a lines and a pid argument."); + return; + } + + int candidatePid = this.getPidArg(6); + int lines = this.getPidArg(7); + if (candidatePid < 0) { + return; + } + + String processOutput = this.api.getProcessOutput(candidatePid, lines); + System.out.println(processOutput); + } + + /* + * getStatus + * gets the running status of a process + */ + public void getStatus() { + int candidatePid = this.getPidArg(6); + if (candidatePid < 0) { + return; + } + + int processStatus = this.api.getProcessStatus(candidatePid); + switch(processStatus) { + case 0: + System.out.println("Process is running"); + break; + case 1: + System.out.println("Process is not running"); + break; + case 2: + System.out.println("A client killed the process already"); + break; + case 3: + System.out.println("Process does not exist"); + break; + case 4: + System.out.println(this.serversideTimeoutErrorMessage); + break; + } + } + + /* + * killProcess + * kills a process + */ + public void killProcess() { + int candidatePid = this.getPidArg(6); + if (candidatePid < 0) { + return; + } + + int finalStatus = this.api.killProcess(candidatePid); + switch(finalStatus) { + case 0: + System.out.println("Process is still running"); + break; + + case 1: + System.out.println("Process was killed"); + break; + + case 2: + System.out.println("Process does not exist"); + break; + + case 3: + System.out.println(this.serversideTimeoutErrorMessage); + break; + + case 4: + // error logged in API Connector + break; + } + } + + /* + * getReturn + * gets return code from a process + */ + public void getReturn() { + int candidatePid = this.getPidArg(6); + if (candidatePid < 0) { + return; + } + + int returnCode = this.api.getProcessReturn(candidatePid); + + switch(returnCode){ + case 256: + System.out.println("Process is still running"); + break; + case 257: + System.out.println("Process was killed manually by a client"); + break; + case 258: + System.out.println("Process does not exist"); + break; + case 259: + System.out.println(this.serversideTimeoutErrorMessage); + break; + case 260: + // error logged in getProcesReturn + break; + default: + System.out.println("Process Exit Code: " + Integer.toString(returnCode)); + } + } + + /* + * main() + * Client entrypoint + * Parses arguments, initializes client, and calls the correct functions + */ + public static void main(String[] args) throws Exception { + // check args + if (args.length < 7) { + System.out.println("Usage: $ ./jobserv-client privatekey, cert, truststore, host, port, command, args"); + System.out.println("Or try $ ./jobserv-client help"); + outputHelp(); + return; + } + + JobServClientAPIConnector api; + try { + SslContextBuilder builder = GrpcSslContexts.forClient(); + builder.trustManager(new File(args[2])); + builder.keyManager(new File(args[1]), new File(args[0])); + + ManagedChannel channel = NettyChannelBuilder.forAddress(args[3], Integer.parseInt(args[4])) + .sslContext(builder.build()) + .build(); + api = new JobServClientAPIConnector(channel); + + // Likely bad port + } catch (NumberFormatException e) { + System.out.println("Invalid Port"); + return; + + // bad cert or key format + } catch (SSLException e) { + System.out.println(e.getMessage()); + return; + } + + JobServClient client = new JobServClient(args, api); + + // parse remaining args + switch (args[5]) { + case "new": + client.makeNewProcess(); + break; + + case "output": + client.getOutput(); + break; + + case "status": + client.getStatus(); + break; + + case "kill": + client.killProcess(); + break; + + case "return": + client.getReturn(); + break; + + default: + System.out.println("Improper command, try 'help'"); + break; + } + } +} diff --git a/src/main/java/JobServ/JobServClientAPIConnector.java b/src/main/java/JobServ/JobServClientAPIConnector.java new file mode 100644 index 0000000..2e4a02c --- /dev/null +++ b/src/main/java/JobServ/JobServClientAPIConnector.java @@ -0,0 +1,200 @@ +/* + * JobServClientAPIConnector + * + * v1.0 + * + * May 23, 2019 + */ + +package JobServ; + +import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/* + * JobServClientAPIConnector + * Starts a connection to the API Connector + * implements functions that send and recieve frm the API + * Refactored into its own module to make the Client interface nicer + * and to allow for a veriety of interfaces to be created + */ +class JobServClientAPIConnector { + private final String apiFailureMessage = "Failed while trying to connect to server."; + + /* + * The client should not use the same logging module as the server. + * In a more robust product the server logging module will take advantage of system level + * log aggregators such as journalctl, which the client should not be writing to on the users system + */ + private static final Logger logger = Logger.getLogger(JobServClient.class.getName()); + + private final ManagedChannel channel; + + /* + * blockingStub is used when the client needs to block until the server responds + * the client doesnt nessesarily need to support asynchronously firing off commands + * in this shell-like interface it would be disconcerting to get multiple returns out of order + */ + private final ShellServerGrpc.ShellServerBlockingStub blockingStub; + + /* + * Constructor + * Spawns a new blockingStub for network operations with the server + */ + public JobServClientAPIConnector(ManagedChannel channel) { + this.channel = channel; + blockingStub = ShellServerGrpc.newBlockingStub(this.channel); + } + + /* + * shutdown() + * Gets called when you press cntrl+c + * takes at most 5 seconds to close its connection + */ + public void shutdown() throws InterruptedException { + channel.shutdown().awaitTermination(5, TimeUnit.SECONDS); + } + + /* + * getProcessOutput() + * sends the server a request for output from the process identified by 'pid' + * returns process output as string + */ + public String getProcessOutput(int pid, int lines) { + logger.info("[+] requesting output"); + + OutputRequestMessage request = OutputRequestMessage.newBuilder() + .setPid(pid) + .setLines(lines) + .build(); + OutputMessage response; + + try { + // blocking network operation + response = blockingStub.getOutput(request); + + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, this.apiFailureMessage + ": " + e.getStatus()); + return ""; + } + + return response.getOutput(); + } + + /* + * sendNewJobMessage() + * sends a shell command to the api server + * returns new pid of job + * or -1 if server failed to create job + * or -2 if client fails to connect + */ + public int sendNewJobMessage(String command) { + // thought of escaping this, but the vulnerability is only client side, from client user input. + logger.info("[+] Sending command to server"); + + NewJobMessage request = NewJobMessage.newBuilder() + .setCommand(command) + .build(); + PIDMessage response; + + try { + // blocking network operation + response = blockingStub.newJob(request); + + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, this.apiFailureMessage + ": " + e.getStatus()); + return -3; + } + + return response.getPid(); + } + + /* + * getProcessStatus() + * requests running status of process pid + * 0: running + * 1: not running + * 2: killed manually by a client + * 3: doesnt exist + * 4: couldnt grab lock + */ + public int getProcessStatus(int pid) { + logger.info("[+] Requesting status of a job"); + + PIDMessage request = PIDMessage.newBuilder() + .setPid(pid) + .build(); + StatusMessage response; + + try { + // blocking network operation + response = blockingStub.getStatus(request); + + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, this.apiFailureMessage + ": " + e.getStatus()); + return -1; + } + + return response.getProcessStatus(); + } + + /* + * sends PID to server + * returns process exit code + * 0-255: process exit code + * 256: process still running + * 257: process was killed by a client + * 258: process doesnt exist + * 259: couldnt grab lock in time + * 260: couldnt connect to API + */ + public int getProcessReturn(int pid) { + logger.info("[+] Requesting return code of a job"); + + PIDMessage request = PIDMessage.newBuilder() + .setPid(pid) + .build(); + ReturnMessage response; + + try { + // blocking network operation + response = blockingStub.getReturn(request); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, this.apiFailureMessage + ": " + e.getStatus()); + return 260; + } + + return response.getProcessReturnCode(); + } + + /* + * killProcess() + * send a PID to be killed, function returns process status after kill operation + * returns 0 if still running + * returns 1 if process was killed + * returns 2 if process not found + * returns 3 if couldnt grab lock + * returns 4 on API failure + */ + public int killProcess(int pid) { + logger.info("[+] Killing a job"); + + PIDMessage request = PIDMessage.newBuilder() + .setPid(pid) + .build(); + StatusMessage response; + + try { + // blocking network operation + response = blockingStub.killJob(request); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, this.apiFailureMessage + ": " + e.getStatus()); + return 4; + } + + return response.getProcessStatus(); + } +} diff --git a/src/main/java/JobServ/JobServServer.java b/src/main/java/JobServ/JobServServer.java new file mode 100644 index 0000000..94649c8 --- /dev/null +++ b/src/main/java/JobServ/JobServServer.java @@ -0,0 +1,132 @@ +/* + * JobServServer + * + * v1.0 + * + * May 18, 2019 + */ + +package JobServ; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.NettyServerBuilder; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; +import javax.net.ssl.SSLException; +import java.util.InputMismatchException; +import java.io.File; +import java.io.IOException; +import java.util.logging.Logger; + +/* + * The JobServServer class implements the JobServ protobuf API + * It does this by extending the gRPC stub code. + * Additionally, JobServServer starts and manages a daemon + * Which accepts incoming connections from client. + */ +public class JobServServer { + public static SimpleLogger logger = new SimpleLogger("JobServ-Server-"); + + private Server server; + private ProcessManager manager; + + /* + * Constructor + * builds server object + */ + public JobServServer(SslContext ssl, int port) throws IOException { + this.manager = new ProcessManager(); + this.server = NettyServerBuilder.forPort(port) + .addService(new ShellServerService(manager)) + .sslContext(ssl) + .build() + .start(); + } + + /* + * start() + * this initializes the server + */ + private void start() throws IOException { + // TODO: this should be passed in from a configuration manager + server.start(); + logger.write("Server initialized!"); + + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + logger.write("Shutting down server"); + logger.shutdown(); + manager.shutdown(); + JobServServer.this.stop(); + } + }); + } + + /* + * stop() + * This is called when ctrl+c is pressed + */ + private void stop() { + if (server != null) { + server.shutdown(); + } + } + + /* + * blockUntilShutdown() + * This is more or less the main loop of the server. + * It spins until shutdown is called. + */ + private void blockUntilShutdown() throws InterruptedException { + if (server != null) { + server.awaitTermination(); + } + } + + /* + * main() + * Entrypoint of hte server + * parses args and initializes a server object. + * calls server main loop. + */ + public static void main(String[] args) throws IOException, InterruptedException { + // TODO: port and key/cert files should be handled by a config manager + if(args.length < 4) { + System.out.println("Usage: ./jobserv-server port cert privatekey truststore"); + return; + } + + JobServServer server; + + try { + SslContextBuilder sslContextBuilder = SslContextBuilder.forServer(new File(args[1]), new File(args[2])); + + // Mutual TLS trust store and require client auth + sslContextBuilder.trustManager(new File(args[3])); + sslContextBuilder.clientAuth(ClientAuth.REQUIRE); + + server = new JobServServer(GrpcSslContexts.configure(sslContextBuilder).build(), + Integer.parseInt(args[0])); + + } catch (InputMismatchException e) { + System.out.println("Invalid port!"); + return; + + } catch (SSLException e) { + System.out.println(e.getMessage()); + return; + + } catch (IOException e) { + System.out.println(e.getMessage()); + return; + } + + JobServServer.logger.write("Initialized JobServ Server"); + server.blockUntilShutdown(); + } +} diff --git a/src/main/java/JobServ/ProcessController.java b/src/main/java/JobServ/ProcessController.java new file mode 100644 index 0000000..71e6ec5 --- /dev/null +++ b/src/main/java/JobServ/ProcessController.java @@ -0,0 +1,152 @@ +/* + * ProcessController + * + * v1.0 + * + * May 22, 2019 + */ + +package JobServ; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.BufferedReader; + +/* + * ProcessController + * This class wraps a java Process object with metadata + * such as translated PID that exist for this specific API + * as well as general metadata like IO streams. + */ +class ProcessController { + // incremented in constructor + private static int nextPid = 0; + private int pid; + + // TODO: add an api endpoint for streaming client input into + // interactive processes (out of scope for initial API) + private OutputStream output; + private InputStream input; + private InputStreamReader inputIntermediateStream; + private BufferedReader reader; + + private Process process; + + private Boolean killedManually = false; + + /* + * Constructor + * Takes a command and spawns it in a new process + * Redirects IO streams and assigns a fake PID + */ + public ProcessController(String command) throws IOException { + this.pid = ProcessController.nextPid; + ProcessController.nextPid += 1; + + this.process = Runtime.getRuntime().exec(command); + this.output = this.process.getOutputStream(); + this.input = this.process.getInputStream(); + this.inputIntermediateStream = new InputStreamReader(this.input); + this.reader = new BufferedReader(this.inputIntermediateStream); + + JobServServer.logger.write("Job " + String.valueOf(this.pid) + ": " + command); + } + + /* + * getPid() + * returns translated pid of this process + */ + public int getPid() { + return this.pid; + } + + /* + * getStatus() + * returns whether or not the process is running + * + * TODO: (for future release) return thread state + */ + public int getStatus() { + if (this.killedManually) { + return 2; + } + + try { + process.exitValue(); + return 1; + } catch (IllegalThreadStateException e) { + return 0; + } + } + + /* + * getReturn() + * returns the exit code of the process + * 256 if process is still running + * 257 if process was killed manually and no longer exists + * (unix/posix defines an exit code as a uint8, so 256+ is fair game) + */ + public int getReturn() { + if (this.killedManually) { + return 257; + } + + try { + return process.exitValue(); + } catch (IllegalThreadStateException e) { + return 256; + } + } + + /* + * getOutput() + * gets output from process + */ + public String getOutput(int lines) { + if(this.killedManually) { + return "[-] SERVER: Process has already been killed by a JobServ client!"; + } + + String output = ""; + for (int i = 0; i < lines; i++) { + String newLine = null; + try { + newLine = reader.readLine(); + } catch (IOException e) { + newLine = "[-] SERVER: error reading process output: " + e.getMessage(); + } finally { + if (newLine != null) { + output += newLine + "\n"; + } + } + + } + + return output; + } + + /* + * kill() + * Cleans up resources and destroys process + */ + public void kill() { + if (this.killedManually) { + JobServServer.logger.write("Tried to kill already killed process"); + return; + } + + try { + this.input.close(); + this.output.close(); + this.inputIntermediateStream.close(); + this.reader.close(); + this.process.destroy(); + this.killedManually = true; + } catch (IOException e) { + JobServServer.logger.write("Killing process " + + String.valueOf(this.pid) + " failed: " + e.getMessage()); + } + } +} diff --git a/src/main/java/JobServ/ProcessManager.java b/src/main/java/JobServ/ProcessManager.java new file mode 100644 index 0000000..736fd21 --- /dev/null +++ b/src/main/java/JobServ/ProcessManager.java @@ -0,0 +1,274 @@ +/* + * ProcessManager + * + * v1.0 + * + * May 22, 2019 + */ + +package JobServ; + +import java.util.concurrent.Future; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.HashMap; +import java.util.Iterator; +import java.io.IOException; + +/* + * ProcessManager + * Holds a list of ProcessControllers and controls access to them via mutex + * Mutex Timeout is declared here as well. + */ +class ProcessManager { + // TODO: LOCK_TIMEOUT should be defined in a configuration management system + private final int LOCK_TIMEOUT = 2; // seconds + + /* + * The significance of the concurrent hash map is that an in process + * update will not leave it in an unusable state like it will a normal + * HashMap. It is still up to the programmer in this instance to make + * sure that there are no concurrent operations done to the ProcessControllers + * Themselves. The last thing we want is to throw NPEs or whatnot when + * accessing a process destroyed mid read by another thread. + * Hence getLock(...) and lockMap controlling access to individual entries in + * processMap + */ + protected ConcurrentHashMap processMap; + protected ConcurrentHashMap lockMap; + private ExecutorService threadPool = Executors.newCachedThreadPool(); + + /* + * Constructor + * initializes process queue and start the background process checking daemon + */ + public ProcessManager() { + processMap = new ConcurrentHashMap(); + lockMap = new ConcurrentHashMap(); + /* TODO: In a long running server over a large period of time + * It is possible that the streams used to redirect IO in the + * Processes may become a significant use of resources. + * In this case a background thread should be called to periodically + * remove dead ProcessControllers after calling kill() on them. + * + * (grab lock, iterate over map, remove finished processes, store exit codes, release lock, sleep, repeat) + */ + } + + /* + * newProcess() + * Takes a command and returns the translated pid of a new process + * Returns -1 if controller throws an IOException + */ + public int newProcess(String command) { + + try { + ProcessController newProc = new ProcessController(command); + // we dont need to lock the map yet + this.lockMap.put(newProc.getPid(), true); + this.processMap.put(newProc.getPid(), newProc); + + this.releaseLock(newProc.getPid()); + return newProc.getPid(); + + } catch (IOException e) { + JobServServer.logger.write("Couldnt Spawn New Command: (" + + command + "): " + e.getMessage()); + return -1; + } + } + + /* + * getProcessStatus() + * returns whether or not a process is running. + * 0: running + * 1: not running + * 2: killed manually by a client + * 3: doesnt exist + * 4: couldnt grab lock + */ + public int getProcessStatus(int pid) { + try { + if(!this.getLock(pid)) { + return 3; + } + + } catch (TimeoutException e) { + // lock could not be grabbed before timeout + JobServServer.logger.write("Timeout getting process " + + String.valueOf(pid) + " status: " + e.getMessage()); + return 4; + } + + ProcessController candidate = this.processMap.get(pid); + int status = candidate.getStatus(); + this.releaseLock(pid); + return status; + } + + /* + * getProcessReturn() + * returns: + * 0-255: process exit code + * 256: process still running + * 257: process was killed by a client (TODO: list which client connection killed a process) + * 258: process doesnt exist + * 259: couldnt grab lock in time + */ + public int getProcessReturn(int pid) { + try { + if(!this.getLock(pid)) { + return 258; + } + + } catch (TimeoutException e) { + JobServServer.logger.write("Timeout getting process " + + String.valueOf(pid) + " return: " + e.getMessage()); + return 259; + } + + ProcessController candidate = this.processMap.get(pid); + int ret = candidate.getReturn(); + this.releaseLock(pid); + return ret; + } + + /* + * getProcessOutput() + * returns output of process 'pid' + * or returns description of error + */ + public String getProcessOutput(int pid, int lines) { + try { + if(!this.getLock(pid)) { + return "[-] SERVER: Process not found"; + } + + } catch (TimeoutException e) { + JobServServer.logger.write("Timeout getting process " + + String.valueOf(pid) + " output: " + e.getMessage()); + return "[-] SERVER: Timeout grabbing lock to access process information"; + } + + ProcessController candidate = this.processMap.get(pid); + String output = candidate.getOutput(lines); + this.releaseLock(pid); + return output; + } + + /* + * killProcess() + * returns mirror processStatus + * returns 0 if still running + * returns 1 if process was killed + * returns 2 if process not found + * returns 3 if couldnt grab lock + */ + public int killProcess(int pid) { + try { + if(!this.getLock(pid)) { + return 2; + } + + } catch (TimeoutException e) { + JobServServer.logger.write("Timeout killing process " + + String.valueOf(pid) + ": " + e.getMessage()); + return 3; + } + + ProcessController candidate = this.processMap.get(pid); + candidate.kill(); + this.releaseLock(pid); + return 1; + } + + /* + * getLock() + * Locks access to this.processQueue + * Waits for a predefined timeout period for mutex to be avail. + * Synchronized so two things cannot grab lock at once. + * Throws TimeoutException when it fails to get the lock. + * Alternatively, throws false if lock doesnt exist for PID + * Function is synchronized to prevent multiple threads accessing the same lock at once + * (ConcurrentHashMap will report whatever lock value was last to successfully update) + */ + protected synchronized Boolean getLock(int pid) throws TimeoutException { + if (!lockMap.containsKey(pid)) { + return false; + } + + Future future = this.threadPool.submit( + new Callable() { + public Object call() { + while(lockMap.get(pid)) { + continue; // spin! + } + + lockMap.replace(pid, true); + return 1; + } + }); + + try { + future.get(this.LOCK_TIMEOUT, TimeUnit.SECONDS); + + } catch (InterruptedException e) { + JobServServer.logger.write("[!] Couldnt get lock " + + String.valueOf(pid) + ": "+ e.getMessage()); + future.cancel(true); + return false; + + } catch (ExecutionException e) { + JobServServer.logger.write("[!] Couldnt get lock " + + String.valueOf(pid) + ": "+ e.getMessage()); + future.cancel(true); + return false; + + // cancel the attempt to grab the lock + } + + /* + * TODO: touch of tech debt here + * There should honestly be an + * operation retry queue for ops + * That dont get the lock in time. + * + * This would require a scheduler + * that manages a queue of callbacks + * This scheduler would also likely + * mediate access to the ProcessManager + * object for fresh calls as well. + */ + + return true; + } + + /* + * releaseLock() + * releases mutex so other threads can operate on processqueue + */ + protected void releaseLock(int pid) { + this.lockMap.put(pid, false); + } + + /* + * shutdown() + * called (eventually) by the grpc shutdown hook + * (AKA when user hits control c in the shell) + * releases resources held in the processController objects + */ + public void shutdown() { + Iterator> iterator = this.processMap.entrySet().iterator(); + while (iterator.hasNext()) { + HashMap.Entry entry = iterator.next(); + + entry.getValue().kill(); + iterator.remove(); + } + } +} diff --git a/src/main/java/JobServ/ShellServerService.java b/src/main/java/JobServ/ShellServerService.java new file mode 100644 index 0000000..4ebc3de --- /dev/null +++ b/src/main/java/JobServ/ShellServerService.java @@ -0,0 +1,119 @@ +/* + * ShellServerService + * + * v1.0 + * + * May 18, 2019 + */ + +package JobServ; +import io.grpc.stub.StreamObserver; + +/* + * The ShellServerService wraps around the protobuf API + * Implements API endpoints + */ +class ShellServerService extends ShellServerGrpc.ShellServerImplBase { + + private ProcessManager manager; + + /* + * constructor + * initialized ProcessManager + */ + public ShellServerService(ProcessManager manager) { + this.manager = manager; + } + + /* + * getStatus + * implements api endpoint as defined in jobserv.proto + */ + @Override + public void getStatus(PIDMessage request, + StreamObserver responder) { + + JobServServer.logger.write("New status request for pid: " + String.valueOf(request.getPid())); + int status = manager.getProcessStatus(request.getPid()); + + StatusMessage reply = StatusMessage.newBuilder() + .setProcessStatus(status) + .build(); + responder.onNext(reply); + responder.onCompleted(); + } + + /* + * getOutput + * implements api endpoint as defined in jobserv.proto + */ + @Override + public void getOutput(OutputRequestMessage request, + StreamObserver responder) { + + JobServServer.logger.write("New Output request for pid: " + String.valueOf(request.getPid())); + String output = manager.getProcessOutput(request.getPid(), + request.getLines()); + + OutputMessage reply = OutputMessage.newBuilder() + .setOutput(output) + .build(); + responder.onNext(reply); + responder.onCompleted(); + } + + /* + * newJob + * implements api endpoint as defined in jobserv.proto + */ + @Override + public void newJob(NewJobMessage request, + StreamObserver responder) { + + String command = request.getCommand(); + JobServServer.logger.write("New job request: " + command); + int newPid = manager.newProcess(command); + + PIDMessage reply = PIDMessage.newBuilder() + .setPid(newPid) + .build(); + responder.onNext(reply); + responder.onCompleted(); + } + + /* + * getReturn + * implements api endpoint as defined in jobserv.proto + */ + @Override + public void getReturn(PIDMessage request, + StreamObserver responder) { + + JobServServer.logger.write("New request for return from job: " + String.valueOf(request.getPid())); + int retVal = manager.getProcessReturn(request.getPid()); + + ReturnMessage reply = ReturnMessage.newBuilder() + .setProcessReturnCode(retVal) + .build(); + responder.onNext(reply); + responder.onCompleted(); + } + + /* + * killJob + * implements api endpoint as defined in jobserv.proto + */ + @Override + public void killJob(PIDMessage request, + StreamObserver responder) { + + JobServServer.logger.write("New Request to kill job: " + String.valueOf(request.getPid())); + int status = manager.killProcess(request.getPid()); + + StatusMessage reply = StatusMessage.newBuilder() + .setProcessStatus(status) + .build(); + responder.onNext(reply); + responder.onCompleted(); + } +} diff --git a/src/main/java/JobServ/SimpleLogger.java b/src/main/java/JobServ/SimpleLogger.java new file mode 100644 index 0000000..a07e48b --- /dev/null +++ b/src/main/java/JobServ/SimpleLogger.java @@ -0,0 +1,85 @@ +/* + * SimpleLogger + * + * v1.0 + * + * May 26, 2019 + */ + +package JobServ; + +import java.io.File; +import java.sql.Timestamp; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; + +/* + * SimpleLogger + * Automatically manages the creation of and output to a log file + * TODO: Log Levels, decorations for entries of different severity + */ +class SimpleLogger { + private static final SimpleDateFormat dateTimeFormat = new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss"); + private Timestamp programStart; + private FileWriter logWriter; + private Boolean writable = true; + + /* + * Constructor + * Initializes timestamp and opens new file for logging + */ + public SimpleLogger(String filePrefix) { + this.programStart = new Timestamp(System.currentTimeMillis()); + File currentLog = new File(filePrefix + this.dateTimeFormat.format(this.programStart)); + + try{ + this.logWriter = new FileWriter(currentLog, true); + + } catch (IOException e) { + System.out.println("Error creating LogWriter!"); + this.writable = false; + } + + this.write(this.programStart.toString() + ": JobServ Logging Started"); + } + + /* + * write + * appends a line of information to the log + */ + public void write(String message) { + Timestamp currentTime = new Timestamp(System.currentTimeMillis()); + message = currentTime.toString() + "> " + message; + + if (this.writable) { + try { + this.logWriter.write(message + "\n"); + this.logWriter.flush(); + + } catch (IOException e) { + System.out.println(e.getMessage()); + this.writable = false; + } + } + + System.out.println(message); + } + + /* + * shutdown() + * called on server exit, closes the FileWriter and frees its resources + */ + public void shutdown() { + Timestamp exitTime = new Timestamp(System.currentTimeMillis()); + this.write(exitTime.toString() + ": JobServ Logging Stopped"); + + try { + this.logWriter.close(); + + } catch (IOException e) { + // not sure what would be appropriate to do here + System.out.println(e.getMessage()); + } + } +} diff --git a/src/main/proto/jobserv.proto b/src/main/proto/jobserv.proto new file mode 100644 index 0000000..9f1812b --- /dev/null +++ b/src/main/proto/jobserv.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "JobServ"; +option java_outer_classname = "JobServGrpc"; +option objc_class_prefix = "JSV"; + +package JobServ; + +service ShellServer { + rpc getStatus (PIDMessage) returns (StatusMessage) {} + rpc getReturn (PIDMessage) returns (ReturnMessage) {} + rpc getOutput (OutputRequestMessage) returns (OutputMessage) {} + rpc killJob (PIDMessage) returns (StatusMessage) {} + rpc newJob (NewJobMessage) returns (PIDMessage) {} +} + +message StatusMessage { + int32 ProcessStatus = 1; +} + +message ReturnMessage { + int32 ProcessReturnCode = 1; +} + +message OutputRequestMessage { + int32 Pid = 1; + int32 Lines = 2; +} + +message OutputMessage { + string Output = 1; +} + +message NewJobMessage { + string Command = 1; +} + +message PIDMessage { + int32 Pid = 1; +} diff --git a/src/test/java/JobServ/JobServerAuthenticationTest.java b/src/test/java/JobServ/JobServerAuthenticationTest.java new file mode 100644 index 0000000..781e07b --- /dev/null +++ b/src/test/java/JobServ/JobServerAuthenticationTest.java @@ -0,0 +1,154 @@ +/* + * JobServerAuthenticationTest + * + * v1.0 + * + * May 21, 2019 + */ + +package JobServ; + +import java.io.File; +import javax.net.ssl.SSLException; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.grpc.ManagedChannel; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import io.grpc.netty.GrpcSslContexts; + +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; + +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; + +/* + * JobServerAuthenticationTest + * Creates a client using authorized certs and another one using unauthorized certs + * Ensures only the client with authorized certs can connect to the server. + * For more information on the hardcoded paths check buildwrapper.sh + */ + +@RunWith(JUnit4.class) +public class JobServerAuthenticationTest { + + private final String projectRoot = ""; + + // Authorized client key/cert/ca + private final String clientCa = projectRoot + "resources/client/ca.crt"; + private final String clientKey = projectRoot + "resources/client/private.pem"; + private final String clientCert = projectRoot + "resources/client/client.crt"; + + // Authorized server key/cert/ca + private final String serverCa = projectRoot + "resources/server/ca.crt"; + private final String serverKey = projectRoot + "resources/server/private.pem"; + private final String serverCert = projectRoot + "resources/server/server.crt"; + + // controlled failure key/cert/ca + private final String badCa = projectRoot + "resources/test/ca.crt"; + private final String badKey = projectRoot + "resources/test/private.pem"; + private final String badCert = projectRoot + "resources/test/test.crt"; + + // badClient uses unauthorized certs + private JobServClientAPIConnector goodClient; + private JobServClientAPIConnector badClient; + private JobServServer server; + + // was setUp able to use SSL Certs + private Boolean serverSslInitialized = true; + private Boolean clientSslInitialized = true; + + /* + * test constructor + * generates both clients and the server + */ + public JobServerAuthenticationTest() throws Exception { + + try { + // generate SSL contexts + SslContextBuilder serverContextBuilder = SslContextBuilder.forServer(new File(serverCert), + new File(serverKey)); + serverContextBuilder.trustManager(new File(clientCa)); + serverContextBuilder.clientAuth(ClientAuth.REQUIRE); + + this.server = new JobServServer(GrpcSslContexts.configure(serverContextBuilder).build(), 8448); + this.serverSslInitialized = true; + + } catch (SSLException e) { + this.serverSslInitialized = false; + System.err.println(e.getMessage()); + + } catch (IOException e) { + this.serverSslInitialized = false; + System.err.println(e.getMessage()); + } + + // generate ssl for clients + if (this.serverSslInitialized) { + try { + SslContextBuilder goodClientBuilder = GrpcSslContexts.forClient(); + goodClientBuilder.trustManager(new File(serverCa)); + goodClientBuilder.keyManager(new File(clientCert), new File(clientKey)); + + SslContextBuilder badClientBuilder = GrpcSslContexts.forClient(); + badClientBuilder.trustManager(new File(serverCa)); + badClientBuilder.keyManager(new File(badCert), new File(badKey)); + + ManagedChannel goodChannel = NettyChannelBuilder.forAddress("localhost", 8448) + .sslContext(goodClientBuilder.build()) + .directExecutor() + .build(); + + ManagedChannel badChannel = NettyChannelBuilder.forAddress("localhost", 8448) + .sslContext(badClientBuilder.build()) + .directExecutor() + .build(); + + goodClient = new JobServClientAPIConnector(goodChannel); + badClient = new JobServClientAPIConnector(badChannel); + this.clientSslInitialized = true; + + } catch (SSLException e) { + this.clientSslInitialized = false; + System.err.println(e.getMessage()); + } + + } else { + this.clientSslInitialized = false; + } + } + + /* + * TLS Cert Auth Test + * this needed to be one test because running multiple tests at the same time + * fails as the server tries to rebind to the same port. + */ + @Test + public void certificateAuthenticationTest() { + assertEquals(true, serverSslInitialized); + assertEquals(true, clientSslInitialized); + + int result = badClient.sendNewJobMessage("test command"); + assertEquals(-3, result); + + result = goodClient.sendNewJobMessage("test command"); + Boolean assertCondition = result == -3; + assertEquals(assertCondition, false); + } +} diff --git a/src/test/java/JobServ/ProcessManagerTest.java b/src/test/java/JobServ/ProcessManagerTest.java new file mode 100644 index 0000000..5a1e3a7 --- /dev/null +++ b/src/test/java/JobServ/ProcessManagerTest.java @@ -0,0 +1,228 @@ +/* + * ProcessManagerTest + * + * v1.0 + * + * May 22, 2019 + */ + +package JobServ; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import java.util.concurrent.Future; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; + +/* + * ProcessManagerTest + * Class that performs positive and negative unit tests + * of every public method in ProcessManager. This not + * only unit tests ProcessManager but also integration + * tests it with ProcessController. + */ +public class ProcessManagerTest { + private ProcessManagerTestImplementation manager = new ProcessManagerTestImplementation(); + private ExecutorService threadPool = Executors.newCachedThreadPool(); + + private int asyncTestPid; + + // calls a test function that simulates load by holding the lock for a long time + private Callable holdLockFourSeconds = new Callable() { + public Object call() { + manager.longCallHoldsLock(asyncTestPid); + return true; + } + }; + + /* + * addProcessTest() + * positive unit test for newProcess + */ + @Test + public void addProcessesTest() { + int pid = manager.newProcess("sleep 1"); + assertNotEquals(-1, pid); + + manager.shutdown(); + } + + /* + * getStatusTest + * unit test for getStatus + */ + @Test + public void getStatusTest() { + int pid = manager.newProcess("sleep 1"); + int status = manager.getProcessStatus(pid); + assertEquals(0, status); + + manager.shutdown(); + } + + /* + * getOldStatusTest + * do finished processes return 1 + */ + @Test + public void getOldStatusTest() { + int pid = manager.newProcess("echo 'test'"); + + try{ + Thread.sleep(200); + } catch (InterruptedException e) { + // + } + + int status = manager.getProcessStatus(pid); + assertEquals(1, status); + + manager.shutdown(); + } + + /* + * getUnknownStatusTest() + * ensures 2 is returned when a status is not known + */ + @Test + public void getUnknownStatusTest() { + int status = manager.getProcessStatus(400); + assertEquals(3, status); + } + + /* + * getReturnTest() + * test of process returns + */ + @Test + public void getReturnTest() { + int pid = manager.newProcess("sleep .5"); + int ret = manager.getProcessReturn(pid); + assertEquals(256, ret); + + try { + Thread.sleep(550); + } catch (InterruptedException e) { + // + } + + ret = manager.getProcessReturn(pid); + assertNotEquals(ret, 256); + assertNotEquals(ret, 257); + assertNotEquals(ret, 258); + + manager.shutdown(); + } + + /* + * getUNknownProcessReturn + * tests process return for unknown processes + */ + @Test + public void getUnknownProcessReturnTest() { + int ret = manager.getProcessReturn(502); + assertEquals(258, ret); + manager.shutdown(); + } + + /* + * getProcessOutputTest() + * verifies output is grabbed correctly from processes + */ + @Test + public void getProcessOutputTest() { + int pid = manager.newProcess("echo test"); + + try { + Thread.sleep(100); + } catch (InterruptedException e) { + // + } + + String out = manager.getProcessOutput(pid, 2); + assertEquals("test\n", out); // calls string.equals() + + manager.shutdown(); + } + + + /* + * getUnknownOutputTest() + * verifies correct information is returned when + * output is requested of an unknown process + */ + @Test + public void getUnknownOutputTest() { + String out = manager.getProcessOutput(532, 10); + assertEquals("[-] SERVER: Process not found", out); + manager.shutdown(); + } + + /* + * killProcessTest() + * ensures killing a process works + * also tests if getProcessStatus returns 2 + */ + @Test + public void killProcessTest() { + int pid = manager.newProcess("sleep 10"); + int ret = manager.killProcess(pid); + + assertEquals(1, ret); + + int status = manager.getProcessStatus(pid); + + assertEquals(2, status); + + manager.shutdown(); + } + + /* + * asyncLockTimeoutTest + * ensures that two things cannot grab the lock at the same time + */ + @Test + public void asyncLockTimeoutTest() { + // start new process that will last the whole test + asyncTestPid = this.manager.newProcess("sleep 7"); + int secondProcess = this.manager.newProcess("sleep 10"); + + // grab that processes lock for 4 seconds + Future future = this.threadPool.submit(this.holdLockFourSeconds); + + try { + Thread.sleep(100); + } catch (InterruptedException e) { + System.err.println("[!!] Thread for async test interrupted!"); + } + + // Try to grab a held lock + System.err.println("[2] attempting to grab (held) lock"); + int status = this.manager.getProcessStatus(this.asyncTestPid); + assertEquals(4, status); // should time out after 2 secs + + // try to grab unrelated lock (not nessesary, but important it works) + int statusTertiary = this.manager.getProcessStatus(secondProcess); + assertNotEquals(4, statusTertiary); + + // give lockMap small time to update + try { + Thread.sleep(200); + } catch (InterruptedException e) { + System.err.println("[!!] Thread for async test interrupted!"); + } + + // should be grabbable now + int statusSecondTry = this.manager.getProcessStatus(this.asyncTestPid); + assertNotEquals(4, statusSecondTry); + + manager.shutdown(); + } +} + diff --git a/src/test/java/JobServ/ProcessManagerTestImplementation.java b/src/test/java/JobServ/ProcessManagerTestImplementation.java new file mode 100644 index 0000000..58594b5 --- /dev/null +++ b/src/test/java/JobServ/ProcessManagerTestImplementation.java @@ -0,0 +1,44 @@ +/* + * ProcessManagerTestImplementation + * + * v1.0 + * + * May 23, 2019 + */ + + +package JobServ; + +import java.util.concurrent.TimeoutException; + +/* + * ProcessManagerTestImplementation + * inherits ProcessManager and adds useful functions for testing + */ +class ProcessManagerTestImplementation extends ProcessManager { + + public void longCallHoldsLock(int pid) { + try { + super.getLock(pid); + System.err.println("[1] Long Call Has Lock"); + + // hold lock for 3.5 seconds, more than double normal timeout. + Thread.sleep(3500); + + super.releaseLock(pid); + + } catch (TimeoutException e) { + System.err.println("[!!] Long Call wasnt able to grab lock!"); + return; + + } catch (InterruptedException e) { + super.releaseLock(pid); // this doesnt happen, dont cancel this task + System.err.println("[3] Released lock: interrupted"); + return; + } + } + + public Boolean reportLockState(int pid) { + return super.lockMap.get(pid); + } +}