commit 3dda31efb721590f8b57afe0071cf1933d8ce45a Author: LamGC Date: Fri Aug 13 00:29:19 2021 +0800 [Initial] Initial Commit; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d04144 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# test runs directory +/run/ + +# gradle +/.gradle +/build + +# Idea Editor +/.idea diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..aa629e0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.5.3' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' +} + +group 'net.lamgc.oracle' +version '0.0.1-SNAPSHOT' +compileJava.sourceCompatibility = '16' + +repositories { + mavenCentral() +} + +dependencies { + def ociSdkVer = '2.3.2' + def sshdVer = '2.7.0' + def bouncyCastleVer = '1.69' + + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + implementation 'org.slf4j:slf4j-api:1.7.31' + // implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.14.1' + + + implementation "com.oracle.oci.sdk:oci-java-sdk-core:${ociSdkVer}" + implementation "com.oracle.oci.sdk:oci-java-sdk-identity:${ociSdkVer}" + + implementation "org.apache.sshd:sshd-core:${sshdVer}" + implementation "org.apache.sshd:sshd-sftp:${sshdVer}" + + implementation "org.bouncycastle:bcpg-jdk15on:${bouncyCastleVer}" + implementation "org.bouncycastle:bcpkix-jdk15on:${bouncyCastleVer}" + + implementation 'org.codehaus.groovy:groovy-all:3.0.7' + + implementation 'com.google.code.gson:gson:2.8.7' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c 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..f371643 --- /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-7.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## 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" "-Xms64m"' + +# 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 or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; 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=`expr $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" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@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 Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@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" "-Xms64m" + +@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 execute + +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 execute + +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 + +: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 %* + +: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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..433abbd --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'oracle-sentry' + diff --git a/src/main/java/net/lamgc/oracle/sentry/ApplicationMain.java b/src/main/java/net/lamgc/oracle/sentry/ApplicationMain.java new file mode 100644 index 0000000..b0eeefd --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/ApplicationMain.java @@ -0,0 +1,101 @@ +package net.lamgc.oracle.sentry; + +import com.oracle.bmc.auth.AuthenticationDetailsProvider; +import net.lamgc.oracle.sentry.script.ScriptComponent; +import net.lamgc.oracle.sentry.script.ScriptManager; +import net.lamgc.oracle.sentry.script.tools.http.ScriptHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.File; +import java.io.IOException; + +/** + *

程序入口 + * @author LamGC + */ +@SpringBootApplication +@Configuration +public class ApplicationMain { + + private final static Logger log = LoggerFactory.getLogger(ApplicationMain.class); + + @SuppressWarnings("AlibabaConstantFieldShouldBeUpperCase") + private final static Object mainThreadWaiter = new Object(); + + @Value("${oracle.identity.location:./identity/}") + private String identityDirectory; + @Value("${oracle.identity.pattern:.*\\.oracle.ini$}") + private String identityFilePattern; + @Value("${sentry.script.location:./scripts/}") + private String scriptsLocation; + @Value("${oracle.script.ssh.identityPath:./ssh.config.json}") + private String sshIdentityPath; + + public static void main(String[] args) { + SpringApplication.run(ApplicationMain.class, args); + Runtime.getRuntime().addShutdownHook(new Thread(mainThreadWaiter::notifyAll, "ShutdownMainThread")); + synchronized (mainThreadWaiter) { + try { + mainThreadWaiter.wait(); + } catch (InterruptedException e) { + log.warn("", e); + } + } + } + + @Bean("oracle.identity.manager") + public OracleIdentityManager initialOracleIdentityManager() throws IOException { + OracleIdentityManager oracleUserManager = new OracleIdentityManager(); + log.info("正在加载 Oracle API 身份配置..."); + log.debug("Oracle API 身份配置查找路径: \"{}\", 匹配表达式: {}", identityDirectory, identityFilePattern); + int loadedCount = oracleUserManager.loadFromDirectory(new File(identityDirectory), identityFilePattern); + log.info("已加载 {} 个身份配置.", loadedCount); + return oracleUserManager; + } + + @Bean("oracle.compute.instance.manager") + @Autowired + public ComputeInstanceManager initialComputeInstanceManager(OracleIdentityManager identityManager) throws IOException { + ComputeInstanceManager instanceManager = new ComputeInstanceManager(); + int addTotal = 0; + for (AuthenticationDetailsProvider provider : identityManager.getProviders()) { + String identityName = identityManager.getIdentityName(provider.getUserId()); + log.info("正在加载用户 {} 所拥有的所有实例...", identityName); + int addCount; + try { + addCount = instanceManager.addComputeInstanceFromUser(provider); + } catch (Exception e) { + log.error("加载实例时发生异常.", e); + continue; + } + log.info("用户 {} 已添加 {} 个计算实例.", identityName, addCount); + addTotal += addCount; + } + + log.info("正在初始化 SSH 认证配置提供器..."); + instanceManager.initialSshIdentityProvider(new File(sshIdentityPath)); + log.info("已完成 ComputeInstanceManager 初始化, 共加载了 {} 个计算实例.", addTotal); + return instanceManager; + } + + @Bean("sentry.script.manager") + @Autowired + public ScriptManager initialScriptManager(ComputeInstanceManager instanceManager) { + ScriptComponent context = new ScriptComponent(new ScriptHttpClient(HttpClientBuilder.create() + .build()), + instanceManager); + + ScriptManager manager = new ScriptManager(new File(scriptsLocation), context); + manager.loadScripts(); + return manager; + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/ComputeInstanceManager.java b/src/main/java/net/lamgc/oracle/sentry/ComputeInstanceManager.java new file mode 100644 index 0000000..7ced275 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/ComputeInstanceManager.java @@ -0,0 +1,127 @@ +package net.lamgc.oracle.sentry; + +import com.oracle.bmc.auth.AuthenticationDetailsProvider; +import com.oracle.bmc.core.ComputeClient; +import com.oracle.bmc.core.model.Instance; +import com.oracle.bmc.core.requests.ListInstancesRequest; +import com.oracle.bmc.core.responses.ListInstancesResponse; +import com.oracle.bmc.identity.IdentityClient; +import com.oracle.bmc.identity.model.Compartment; +import com.oracle.bmc.identity.requests.ListCompartmentsRequest; +import com.oracle.bmc.identity.responses.ListCompartmentsResponse; +import net.lamgc.oracle.sentry.oci.compute.ComputeInstance; +import net.lamgc.oracle.sentry.oci.compute.ssh.SshAuthIdentityProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 计算实例管理器. + * @author LamGC + */ +public class ComputeInstanceManager { + + private final static Logger log = LoggerFactory.getLogger(ComputeInstanceManager.class); + + private final Map instanceMap = new ConcurrentHashMap<>(); + private SshAuthIdentityProvider sshIdentityProvider; + + /** + * 初始化 SSH 认证配置提供器. + * @param sshIdentityJson SSH 认证配置文件. + * @throws IOException 加载时如有异常将直接抛出. + */ + public void initialSshIdentityProvider(File sshIdentityJson) throws IOException { + sshIdentityProvider = new SshAuthIdentityProvider(this, sshIdentityJson); + sshIdentityProvider.loadAuthInfo(); + } + + public SshAuthIdentityProvider getSshIdentityProvider() { + return sshIdentityProvider; + } + + /** + * 单独添加一个计算实例对象. + * @param instance 计算实例对象. + * @throws NullPointerException 当 instance 为 {@code null} 时抛出异常. + */ + public void addComputeInstance(ComputeInstance instance) { + Objects.requireNonNull(instance); + instanceMap.put(instance.getInstanceId(), instance); + } + + /** + * 获取某一用户的所有已添加实例. + * @param userId 用户 Id. + * @return 返回该用户所拥有的的所有已添加实例. + * @throws NullPointerException 当 userId 为 {@code null} 时抛出异常. + */ + public Set getInstancesByUserId(String userId) { + Objects.requireNonNull(userId); + return instanceMap.values().stream() + .filter(computeInstance -> computeInstance.getUserId().equals(userId)) + .collect(Collectors.toSet()); + } + + /** + * 添加某一用户的所有计算实例. + * @param provider 用户身份提供器. + * @return 返回已成功添加的实例数量. + * @throws NullPointerException 如果 provider 为 {@code null} 则抛出异常. + */ + public int addComputeInstanceFromUser(AuthenticationDetailsProvider provider) { + Objects.requireNonNull(provider); + IdentityClient identityClient = new IdentityClient(provider); + ComputeClient computeClient = new ComputeClient(provider); + ListCompartmentsResponse listCompartments = identityClient.listCompartments(ListCompartmentsRequest.builder() + .compartmentId(provider.getTenantId()) + .build()); + int addCount = 0; + Set compartmentIds = listCompartments.getItems().stream() + .map(Compartment::getId).collect(Collectors.toSet()); + compartmentIds.add(provider.getTenantId()); + for (String compartmentId : compartmentIds) { + ListInstancesResponse listInstances = computeClient.listInstances(ListInstancesRequest.builder() + .compartmentId(compartmentId) + .build()); + for (Instance instance : listInstances.getItems()) { + ComputeInstance computeInstance = new ComputeInstance(this, instance.getId(), + provider.getUserId(), compartmentId, instance.getImageId(), provider); + + addComputeInstance(computeInstance); + addCount ++; + } + } + + return addCount; + } + + /** + * 通过实例 Id 获取计算实例对象. + * @param instanceId 实例 Id. + * @return 返回计算实例对象. + * @throws NullPointerException 当 instanceId 为 {@code null} 时抛出异常. + * @throws NoSuchElementException 当未找到指定计算实例时抛出该异常. + */ + public ComputeInstance getComputeInstanceById(String instanceId) { + Objects.requireNonNull(instanceId); + if (!instanceMap.containsKey(instanceId)) { + throw new NoSuchElementException(instanceId); + } + return instanceMap.get(instanceId); + } + + /** + * 获取所有计算实例. + * @return 返回所有已添加的计算实例. + */ + public Set getComputeInstances() { + return instanceMap.values().stream().collect(Collectors.toUnmodifiableSet()); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/OracleIdentityManager.java b/src/main/java/net/lamgc/oracle/sentry/OracleIdentityManager.java new file mode 100644 index 0000000..031477d --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/OracleIdentityManager.java @@ -0,0 +1,189 @@ +package net.lamgc.oracle.sentry; + +import com.google.common.base.Throwables; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.oracle.bmc.ConfigFileReader; +import com.oracle.bmc.Region; +import com.oracle.bmc.auth.AuthenticationDetailsProvider; +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; +import com.oracle.bmc.auth.SimplePrivateKeySupplier; +import com.oracle.bmc.identity.IdentityClient; +import com.oracle.bmc.identity.requests.GetUserRequest; +import com.oracle.bmc.identity.responses.GetUserResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Oracle 身份管理器. + * @author LamGC + */ +public final class OracleIdentityManager { + + private final static Logger log = LoggerFactory.getLogger(OracleIdentityManager.class); + + /** + * 认证身份 Map. + * Key: Identity Id + * Value {@link AuthenticationDetailsProvider} + */ + private final Map identityMap = new ConcurrentHashMap<>(); + /** + * 用户名 Map. + * key Identity Id + * Value: Username + */ + private final Map identityNameMap = new ConcurrentHashMap<>(); + + /** + * 从目录扫描匹配的配置文件并加载. + * @param directory 待扫描的目录. + * @param pattern 文件匹配规则(正则表达式). + * @throws IOException 当加载发生异常时将抛出该异常. + * @return 返回成功加载的身份配置数量. + */ + public int loadFromDirectory(File directory, String pattern) throws IOException { + if (!directory.exists()) { + throw new FileNotFoundException(directory.getCanonicalPath()); + } else if (!directory.isDirectory()) { + throw new IOException("The specified path is not a folder"); + } + + File[] configFiles = directory.listFiles(file -> file.isFile() && file.getName().matches(pattern)); + if (configFiles == null) { + throw new IOException("Unable to access the specified directory: " + directory.getCanonicalPath()); + } + int loadedCount = 0; + for (File configFile : configFiles) { + try { + AuthenticationDetailsProvider provider = loadFromConfigFile(configFile); + loadedCount ++; + log.info("已成功加载身份配置文件.\nUserId: {}\nUsername: {}\nPath: {})", + provider.getUserId(), + getIdentityName(provider.getUserId()), + configFile.getCanonicalPath()); + } catch (Exception e) { + log.error("加载身份配置文件时发生异常.(Path: {})\n{}", configFile.getCanonicalPath(), Throwables.getStackTraceAsString(e)); + } + } + return loadedCount; + } + + /** + * 通过配置文件加载身份信息. + * @param identityConfig 身份信息文件. + * @throws IOException 如果读取文件发生问题时将抛出该异常. + */ + public AuthenticationDetailsProvider loadFromConfigFile(File identityConfig) throws IOException { + if (!identityConfig.exists()) { + throw new FileNotFoundException(identityConfig.getAbsolutePath()); + } + + ConfigFileReader.ConfigFile config + = ConfigFileReader.parse(identityConfig.getAbsolutePath()); + + String keyFilePath = config.get("key_file"); + if (keyFilePath.startsWith(".")) { + keyFilePath = new File(identityConfig.getParent(), config.get("key_file")).getCanonicalPath(); + } + Supplier privateKeySupplier + = new SimplePrivateKeySupplier(keyFilePath); + + AuthenticationDetailsProvider provider + = SimpleAuthenticationDetailsProvider.builder() + .region(Region.fromRegionCode(config.get("region"))) + .tenantId(config.get("tenancy")) + .userId(config.get("user")) + .fingerprint(config.get("fingerprint")) + .privateKeySupplier(privateKeySupplier::get) + .build(); + + // 尝试获取身份所属用户名, 以此检查该身份配置是否正确. + String identityName = getIdentityName0(provider); + identityNameMap.put(provider.getUserId(), identityName); + identityMap.put(provider.getUserId(), provider); + return provider; + } + + /** + * 获取身份所属用户的名称. + * @param provider 身份提供器. + * @return 返回用户名. + */ + private String getIdentityName0(AuthenticationDetailsProvider provider) { + IdentityClient identityClient = new IdentityClient(provider); + GetUserResponse user = identityClient.getUser(GetUserRequest.builder() + .userId(provider.getUserId()) + .build()); + return user.getUser().getName(); + } + + /** + * 获取身份信息所属的用户名. + * @param userId 身份信息所属的用户 Id. + * @return 返回用户名. + * @throws NullPointerException 当 userId 为 {@code null} 时抛出该异常. + * @throws NoSuchElementException 指定的 UserId 未找到对应用户名时抛出该异常. + */ + public String getIdentityName(String userId) { + Objects.requireNonNull(userId); + if (!identityMap.containsKey(userId)) { + throw new NoSuchElementException(userId); + } + return identityNameMap.get(userId); + } + + /** + * 通过 UserId 获取指定身份提供器. + * @param userId 用户 Id. + * @return 返回身份提供器. + * @throws NullPointerException 当 userId 为 {@code null} 时抛出该异常. + * @throws NoSuchElementException 指定的 UserId 未找到对应 Provider 时抛出该异常. + */ + public AuthenticationDetailsProvider getProviderByUserId(String userId) { + Objects.requireNonNull(userId); + if (!identityMap.containsKey(userId)) { + throw new NoSuchElementException(userId); + } + return identityMap.get(userId); + } + + /** + * 导出身份信息. + *

不包含私钥. + * @return 返回 Json 形式的身份信息数组. + */ + public JsonArray exportIdentityInfo() { + JsonArray identityInfoArray = new JsonArray(identityMap.size()); + for (AuthenticationDetailsProvider provider : identityMap.values()) { + JsonObject identity = new JsonObject(); + identity.addProperty("UserId", provider.getUserId()); + identity.addProperty("TenantId", provider.getTenantId()); + identity.addProperty("Fingerprint", provider.getFingerprint()); + identityInfoArray.add(identity); + } + return identityInfoArray; + } + + /** + * 获取所有身份提供器. + * @return 返回包含所有身份提供器的集合对象. + */ + public Set getProviders() { + return identityMap.values().stream().collect(Collectors.toUnmodifiableSet()); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/common/InputStreamWrapper.java b/src/main/java/net/lamgc/oracle/sentry/common/InputStreamWrapper.java new file mode 100644 index 0000000..c2c8af4 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/common/InputStreamWrapper.java @@ -0,0 +1,79 @@ +package net.lamgc.oracle.sentry.common; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class InputStreamWrapper extends InputStream { + + private final InputStream source; + + public InputStreamWrapper(InputStream source) { + this.source = source; + } + + @Override + public int read() throws IOException { + return this.source.read(); + } + + @Override + public int read(byte[] b) throws IOException { + return this.source.read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return this.source.read(b, off, len); + } + + @Override + public byte[] readAllBytes() throws IOException { + return this.source.readAllBytes(); + } + + @Override + public byte[] readNBytes(int len) throws IOException { + return this.source.readNBytes(len); + } + + @Override + public int readNBytes(byte[] b, int off, int len) throws IOException { + return this.source.readNBytes(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return this.source.skip(n); + } + + @Override + public void skipNBytes(long n) throws IOException { + this.source.skipNBytes(n); + } + + @Override + public int available() throws IOException { + return this.source.available(); + } + + @Override + public synchronized void mark(int readlimit) { + this.source.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + this.source.reset(); + } + + @Override + public boolean markSupported() { + return this.source.markSupported(); + } + + @Override + public long transferTo(OutputStream out) throws IOException { + return this.source.transferTo(out); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/common/OutputStreamWrapper.java b/src/main/java/net/lamgc/oracle/sentry/common/OutputStreamWrapper.java new file mode 100644 index 0000000..3a4a4b6 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/common/OutputStreamWrapper.java @@ -0,0 +1,34 @@ +package net.lamgc.oracle.sentry.common; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class OutputStreamWrapper extends OutputStream { + + private final OutputStream target; + + public OutputStreamWrapper(OutputStream target) { + this.target = target; + } + + @Override + public void write(int b) throws IOException { + target.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + this.target.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.target.write(b, off, len); + } + + @Override + public void flush() throws IOException { + this.target.flush(); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ComputeInstance.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ComputeInstance.java new file mode 100644 index 0000000..1628459 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ComputeInstance.java @@ -0,0 +1,163 @@ +package net.lamgc.oracle.sentry.oci.compute; + +import com.oracle.bmc.auth.AuthenticationDetailsProvider; +import com.oracle.bmc.core.ComputeClient; +import com.oracle.bmc.core.model.Image; +import com.oracle.bmc.core.model.Instance; +import com.oracle.bmc.core.requests.GetImageRequest; +import com.oracle.bmc.core.requests.GetInstanceRequest; +import com.oracle.bmc.core.requests.InstanceActionRequest; +import com.oracle.bmc.core.responses.GetImageResponse; +import com.oracle.bmc.core.responses.GetInstanceResponse; +import com.oracle.bmc.core.responses.InstanceActionResponse; +import net.lamgc.oracle.sentry.ComputeInstanceManager; +import net.lamgc.oracle.sentry.oci.compute.ssh.InstanceSsh; +import net.lamgc.oracle.sentry.oci.compute.ssh.SshAuthInfo; + +import java.util.Objects; + +/** + * 计算实例. + * @author LamGC + */ +public final class ComputeInstance { + + private final ComputeInstanceManager instanceManager; + + private final String instanceId; + private final String userId; + private final String compartmentId; + private final String imageId; + private final AuthenticationDetailsProvider authProvider; + private final InstanceNetwork network; + + private final ComputeClient computeClient; + + public ComputeInstance(ComputeInstanceManager instanceManager, String instanceId, String userId, + String compartmentId, String imageId, AuthenticationDetailsProvider provider) { + this.instanceManager = instanceManager; + this.instanceId = instanceId; + this.userId = userId; + this.compartmentId = compartmentId; + this.imageId = imageId; + this.authProvider = provider; + + computeClient = new ComputeClient(provider); + this.network = new InstanceNetwork(this); + } + + public String getInstanceId() { + return instanceId; + } + + public String getUserId() { + return userId; + } + + public String getCompartmentId() { + return compartmentId; + } + + public String getImageId() { + return imageId; + } + + /** + * 获取并返回实例镜像信息. + *

可获取系统信息. + *

如果实例被 dd, 则本信息不准确. + * @return 返回实例信息. + */ + public Image getImage() { + GetImageResponse image = computeClient.getImage(GetImageRequest.builder() + .imageId(imageId) + .build()); + return image.getImage(); + } + + /** + * 获取实例网络对象. + *

可通过 InstanceNetwork 操作实例网络相关. + * @return 返回 InstanceNetwork. + */ + public InstanceNetwork network() { + return network; + } + + public InstanceSsh ssh() { + Instance.LifecycleState instanceState = getInstanceState(); + if (instanceState != Instance.LifecycleState.Running) { + throw new IllegalStateException("The state of the current instance cannot connect to SSH: " + instanceState); + } + return new InstanceSsh(this, getSshIdentity()); + } + + public Instance.LifecycleState getInstanceState() { + GetInstanceResponse instance = computeClient.getInstance(GetInstanceRequest.builder() + .instanceId(instanceId) + .build()); + + return instance.getInstance().getLifecycleState(); + } + + /** + * 对实例执行操作. + * @param action 操作类型. + * @return 如果成功, 返回实例最新状态. + */ + public Instance.LifecycleState execAction(InstanceAction action) { + InstanceActionResponse actionResponse = computeClient.instanceAction(InstanceActionRequest.builder() + .instanceId(instanceId) + .action(action.getActionValue()) + .build()); + return actionResponse.getInstance().getLifecycleState(); + } + + /** + * 获取实例名称. + */ + public String getInstanceName() { + GetInstanceResponse instance = computeClient.getInstance(GetInstanceRequest.builder() + .instanceId(instanceId) + .build()); + return instance.getInstance().getDisplayName(); + } + + /** + * 获得 OCI 的计算 API 客户端, 可通过该客户端执行更多的操作. + * @return 返回计算 API 客户端. + */ + public ComputeClient getComputeClient() { + return computeClient; + } + + AuthenticationDetailsProvider getAuthProvider() { + return authProvider; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ComputeInstance that = (ComputeInstance) o; + return instanceId.equals(that.instanceId) && userId.equals(that.userId) && compartmentId.equals(that.compartmentId); + } + + @Override + public int hashCode() { + return Objects.hash(instanceId, userId, compartmentId); + } + + /** + * 获取 SSH 认证信息. + */ + private SshAuthInfo getSshIdentity() { + return instanceManager.getSshIdentityProvider() + .getAuthInfoByInstanceId(instanceId); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceAction.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceAction.java new file mode 100644 index 0000000..26fc3ee --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceAction.java @@ -0,0 +1,36 @@ +package net.lamgc.oracle.sentry.oci.compute; + +public enum InstanceAction { + /** + * 启动实例. + */ + START("start"), + /** + * 硬停止实例. + */ + STOP("stop"), + /** + * 硬重启实例. + */ + RESET("reset"), + /** + * 软重启实例, 操作系统将按照正常的重启过程进行. + */ + SOFT_RESET("softreset"), + /** + * 软停止实例, 操作系统将按照正常的关机过程进行. + */ + SOFT_STOP("softstop") + + ; + + private final String actionValue; + + InstanceAction(String actionValue) { + this.actionValue = actionValue; + } + + public String getActionValue() { + return actionValue; + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceNetwork.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceNetwork.java new file mode 100644 index 0000000..003261c --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/InstanceNetwork.java @@ -0,0 +1,62 @@ +package net.lamgc.oracle.sentry.oci.compute; + +import com.oracle.bmc.core.VirtualNetworkClient; +import com.oracle.bmc.core.model.VnicAttachment; +import com.oracle.bmc.core.requests.GetVnicRequest; +import com.oracle.bmc.core.requests.ListVnicAttachmentsRequest; +import com.oracle.bmc.core.responses.GetVnicResponse; +import com.oracle.bmc.core.responses.ListVnicAttachmentsResponse; + +import java.util.HashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; + +/** + * @author LamGC + */ +public class InstanceNetwork { + + private final ComputeInstance instance; + private final VirtualNetworkClient vcnClient; + + InstanceNetwork(ComputeInstance instance) { + this.instance = instance; + this.vcnClient = new VirtualNetworkClient(this.instance.getAuthProvider()); + } + + /** + * 获取实例的所有公共 IP. + * @return 返回所有公共 IP. + * @throws NoSuchElementException 当 InstanceId 所属实例未添加时抛出该异常. + * @throws NullPointerException 当 instanceId 为 {@code null} 时抛出该异常. + */ + public Set getInstancePublicIp() { + Set publicIpSet = new HashSet<>(); + for (VnicAttachment vnicAttachment : listVnicAttachments()) { + GetVnicResponse vnic = vcnClient.getVnic(GetVnicRequest.builder() + .vnicId(vnicAttachment.getVnicId()) + .build()); + publicIpSet.add(vnic.getVnic().getPublicIp()); + } + + return publicIpSet; + } + + /** + * 获取所有已连接的 Vnic 信息. + * @return 返回所有已连接的 Vnic. + */ + public List listVnicAttachments() { + ListVnicAttachmentsResponse listVnicAttachments = instance.getComputeClient() + .listVnicAttachments(ListVnicAttachmentsRequest.builder() + .compartmentId(instance.getCompartmentId()) + .instanceId(instance.getInstanceId()) + .build() + ); + + return listVnicAttachments.getItems().stream().toList(); + } + + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/InstanceSsh.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/InstanceSsh.java new file mode 100644 index 0000000..59b8602 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/InstanceSsh.java @@ -0,0 +1,83 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import com.google.common.base.Strings; +import net.lamgc.oracle.sentry.oci.compute.ComputeInstance; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.future.AuthFuture; +import org.apache.sshd.client.future.ConnectFuture; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public class InstanceSsh implements AutoCloseable { + + private final static Logger log = LoggerFactory.getLogger(InstanceSsh.class); + + private final ComputeInstance instance; + private final SshAuthInfo authInfo; + private final SshClient sshClient; + + public InstanceSsh(ComputeInstance instance, SshAuthInfo authInfo) { + this.instance = Objects.requireNonNull(instance); + this.authInfo = Objects.requireNonNull(authInfo); + + sshClient = SshClient.setUpDefaultClient(); + sshClient.setServerKeyVerifier(new OracleInstanceServerKeyVerifier(instance, authInfo)); + if (authInfo instanceof PublicKeyAuthInfo info) { + sshClient.setKeyIdentityProvider(new FileKeyPairProvider(info.getPrivateKeyPath().toPath())); + if (!Strings.isNullOrEmpty(info.getKeyPassword())) { + sshClient.setFilePasswordProvider(FilePasswordProvider.of(info.getKeyPassword())); + } + } else if (authInfo instanceof PasswordAuthInfo info) { + sshClient.addPasswordIdentity(info.getPassword()); + } else { + throw new IllegalArgumentException("Unsupported authentication type"); + } + sshClient.start(); + } + + public SshSession createSession() throws IOException { + Set instancePublicIps = instance.network().getInstancePublicIp(); + if (instancePublicIps.stream().findFirst().isEmpty()) { + throw new IllegalStateException("Instance has no public IP available."); + } + String connectUri = "ssh://" + authInfo.getUsername() + "@" + instancePublicIps.stream().findFirst().get() + ":22"; + log.info("SSH 正在连接: {}", connectUri); + ConnectFuture connect = sshClient.connect(connectUri); + connect.verify(); + if (!connect.isConnected()) { + if (connect.getException() != null) { + throw new IOException(connect.getException()); + } + throw new IOException("A connection to the server could not be established for an unknown reason."); + } + ClientSession clientSession = connect.getClientSession(); + AuthFuture auth = clientSession.auth(); + auth.verify(20, TimeUnit.SECONDS); + if (auth.isSuccess()) { + return new SshSession(clientSession); + } else { + if (auth.isFailure()) { + clientSession.close(); + throw new IOException("Authentication with server failed.", clientSession.auth().getException()); + } else if (auth.isCanceled()) { + clientSession.close(); + throw new IOException("Authentication cancelled.", clientSession.auth().getException()); + } + throw new IOException("Authentication timeout."); + } + } + + + @Override + public void close() { + sshClient.stop(); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/OracleInstanceServerKeyVerifier.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/OracleInstanceServerKeyVerifier.java new file mode 100644 index 0000000..eb481ec --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/OracleInstanceServerKeyVerifier.java @@ -0,0 +1,79 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import net.lamgc.oracle.sentry.oci.compute.ComputeInstance; +import org.apache.sshd.client.keyverifier.RequiredServerKeyVerifier; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.SocketAddress; +import java.security.PublicKey; +import java.util.Scanner; + +/** + * @author LamGC + */ +public class OracleInstanceServerKeyVerifier implements ServerKeyVerifier { + + private final static Logger log = LoggerFactory.getLogger(OracleInstanceServerKeyVerifier.class); + + private final ComputeInstance instance; + private final SshAuthInfo info; + + public OracleInstanceServerKeyVerifier(ComputeInstance instance, SshAuthInfo info) { + this.instance = instance; + this.info = info; + } + + @Override + public boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) { + if (info.getServerKey() != null) { + return new RequiredServerKeyVerifier(info.getServerKey()) + .verifyServerKey(clientSession, remoteAddress, serverKey); + } else { + log.warn("首次连接实例 SSH, 需要用户确认服务器公钥是否可信..."); + boolean result = confirm(remoteAddress, serverKey); + if (result) { + log.info("用户已确认服务器密钥可信, 将该密钥列入该实例下的信任密钥."); + info.setServerKey(serverKey); + return true; + } else { + log.warn("用户已确认该密钥不可信, 拒绝本次连接."); + return false; + } + } + } + + public boolean confirm(SocketAddress address, PublicKey key) { + String fingerPrint = KeyUtils.getFingerPrint(key); + log.warn("开始密钥认证流程... (InstanceId: {}, ServerAddress: {}, KeyFingerPrint: {})", + instance.getInstanceId(), address, fingerPrint); + Scanner scanner = new Scanner(System.in); + log.info(""" + 本次连接 SSH 为首次连接, 为确保 SSH 安全性,请通过可信渠道获取服务器密钥指纹, 并与下列指纹比对: + 实例 ID:{} + 实例名称:{} + 连接地址:{} + 密钥指纹: + + {} + + 以上密钥指纹是否与服务器密钥指纹相同?如果指纹相同,对该密钥可信,请输入“Yes”,否则输入任意内容拒绝连接。 + 该密钥是否可信?(Yes/No):""", + instance.getInstanceId(), + instance.getInstanceName(), + address, + fingerPrint + ); + + do { + if (scanner.hasNextLine()) { + String input = scanner.nextLine(); + return "yes".trim().equalsIgnoreCase(input); + } + } while (true); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PasswordAuthInfo.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PasswordAuthInfo.java new file mode 100644 index 0000000..06976f3 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PasswordAuthInfo.java @@ -0,0 +1,22 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +/** + * @author LamGC + */ +public class PasswordAuthInfo extends SshAuthInfo { + + private String password; + + @Override + public AuthType getType() { + return AuthType.PASSWORD; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PublicKeyAuthInfo.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PublicKeyAuthInfo.java new file mode 100644 index 0000000..2ba94e4 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/PublicKeyAuthInfo.java @@ -0,0 +1,33 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import org.apache.sshd.common.config.keys.FilePasswordProvider; + +import java.io.File; +import java.security.KeyPair; + +public class PublicKeyAuthInfo extends SshAuthInfo{ + + private File privateKeyPath; + private String keyPassword; + + @Override + public AuthType getType() { + return AuthType.PUBLIC_KEY; + } + + public File getPrivateKeyPath() { + return privateKeyPath; + } + + public void setPrivateKeyPath(File privateKeyPath) { + this.privateKeyPath = privateKeyPath; + } + + public String getKeyPassword() { + return keyPassword; + } + + public void setKeyPassword(String keyPassword) { + this.keyPassword = keyPassword; + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthIdentityProvider.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthIdentityProvider.java new file mode 100644 index 0000000..cd62b20 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthIdentityProvider.java @@ -0,0 +1,166 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.lamgc.oracle.sentry.ComputeInstanceManager; +import net.lamgc.oracle.sentry.oci.compute.ComputeInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** + * @author LamGC + */ +@SuppressWarnings("UnstableApiUsage") +public final class SshAuthIdentityProvider { + + private final static String DEFAULT_AUTH_KEY = "@default"; + private final static Logger log = LoggerFactory.getLogger(SshAuthIdentityProvider.class); + + private final Map authInfoMap = new ConcurrentHashMap<>(); + private final ComputeInstanceManager instanceManager; + private final File identityJsonFile; + private final Gson gson = new GsonBuilder() + .serializeNulls() + .setPrettyPrinting() + .registerTypeAdapter(SshAuthInfo.class, SshAuthInfoSerializer.INSTANCE) + .registerTypeAdapter(PublicKeyAuthInfo.class, SshAuthInfoSerializer.INSTANCE) + .registerTypeAdapter(PasswordAuthInfo.class, SshAuthInfoSerializer.INSTANCE) + .create(); + + private final ScheduledExecutorService scheduledExec = new ScheduledThreadPoolExecutor(1, + new ThreadFactoryBuilder() + .setNameFormat("Thread-SshInfoSave-%d") + .build()); + private final AtomicBoolean needSave = new AtomicBoolean(false); + + + public SshAuthIdentityProvider(ComputeInstanceManager instanceManager, File identityJson) { + this.instanceManager = instanceManager; + this.identityJsonFile = identityJson; + + scheduledExec.scheduleAtFixedRate(() -> { + if (!needSave.get()) { + return; + } + needSave.set(false); + try { + SshAuthIdentityProvider.this.saveAuthInfo(); + } catch (Exception e) { + log.warn("本次 SSH 认证配置保存失败.", e); + } + }, 60, 10, TimeUnit.SECONDS); + } + + public void addSshAuthIdentity(String instanceId, SshAuthInfo authInfo) { + authInfoMap.put(instanceId, authInfo); + } + + /** + * 通过实例 Id 获取相应的实例 Id. + * @param instanceId 实例 Id. + * @return 返回指定实例 Id 的 SSH 认证配置. + * @throws NoSuchElementException 当没有对应实例的配置时将抛出该异常. + */ + public SshAuthInfo getAuthInfoByInstanceId(String instanceId) { + if (!authInfoMap.containsKey(instanceId)) { + SshAuthInfo defaultAuthInfo = getDefaultAuthInfo(); + if (defaultAuthInfo == null) { + throw new NoSuchElementException("The SSH authentication information to which the " + + "specified instance ID belongs cannot be found: " + instanceId); + } + return defaultAuthInfo; + } + return authInfoMap.get(instanceId); + } + + /** + * 获取默认配置. + *

不建议使用, 因为服务器都不一样. + * @return 如果有, 返回配置, 没有默认配置则返回 {@code null}. + */ + public SshAuthInfo getDefaultAuthInfo() { + if (authInfoMap.containsKey(DEFAULT_AUTH_KEY)) { + return authInfoMap.get(DEFAULT_AUTH_KEY); + } + return null; + } + + /** + * 将所有认证配置保存到文件中. + * @throws IOException 如果保存时发生异常, 则抛出. + */ + private synchronized void saveAuthInfo() throws IOException { + log.info("正在保存 SSH 认证配置..."); + String output = gson.toJson(authInfoMap); + Files.writeString(identityJsonFile.toPath(), output, + StandardCharsets.UTF_8, StandardOpenOption.CREATE); + log.info("已成功保存 SSH 认证配置."); + } + + /** + * 通知 Provider 需要保存配置. + */ + public void notifySaveInfo() { + needSave.set(true); + } + + /** + * 从文件加载所有认证配置. + * @throws IOException 如果读取文件时发生异常, 则抛出. + */ + public synchronized void loadAuthInfo() throws IOException { + if (!identityJsonFile.exists()) { + log.warn("SSH 认证配置文件不存在, 跳过加载."); + return; + } + Map map = gson.fromJson(new FileReader(identityJsonFile, StandardCharsets.UTF_8), + new TypeToken>(){}.getType()); + for (String id : map.keySet()) { + SshAuthInfo info = map.get(id); + info.setProvider(this); + addSshAuthIdentity(id, info); + } + + Set missingInstances = checkForMissingInstances(); + if (missingInstances.isEmpty()) { + return; + } + StringBuilder builder = new StringBuilder(); + for (String missingInstanceId : missingInstances) { + builder.append(missingInstanceId).append('\n'); + } + log.warn("以下实例不存在对应的 SSH 认证配置:\n{}", builder); + } + + /** + * 获取所有不存在 SSH 配置的实例 Id. + */ + private Set checkForMissingInstances() { + Set instanceIdSet = instanceManager.getComputeInstances().stream() + .map(ComputeInstance::getInstanceId) + .collect(Collectors.toSet()); + for (String id : authInfoMap.keySet()) { + instanceIdSet.remove(id); + } + return instanceIdSet; + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfo.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfo.java new file mode 100644 index 0000000..dd4a944 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfo.java @@ -0,0 +1,76 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.PublicKey; + +/** + * Ssh 认证信息. + * @author LamGC + */ +@SuppressWarnings("unused") +public abstract class SshAuthInfo { + + private final static Logger log = LoggerFactory.getLogger(SshAuthInfo.class); + + private String username; + /** + * 使用 Sha256 计算的密钥指纹. + */ + private PublicKey serverKey; + + private SshAuthIdentityProvider provider; + + /** + * 获取认证类型. + * @return 返回认证类型. + */ + public abstract AuthType getType(); + + public String getUsername() { + return username; + } + + public PublicKey getServerKey() { + return serverKey; + } + + public void setServerKey(PublicKey serverKey) { + this.serverKey = serverKey; + if (this.provider != null) { + this.provider.notifySaveInfo(); + } + } + + public void setUsername(String username) { + this.username = username; + } + + void setProvider(SshAuthIdentityProvider provider) { + this.provider = provider; + } + + public enum AuthType { + /** + * 密码认证. + */ + PASSWORD(PasswordAuthInfo.class), + /** + * 公钥认证 + */ + PUBLIC_KEY(PublicKeyAuthInfo.class); + + private final Class targetClass; + + AuthType(Class targetClass) { + this.targetClass = targetClass; + } + + public Class getTargetClass() { + return targetClass; + } + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializer.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializer.java new file mode 100644 index 0000000..7c08911 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializer.java @@ -0,0 +1,130 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import com.google.common.base.Strings; +import com.google.gson.*; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntryDataResolver; +import org.apache.sshd.common.config.keys.PublicKeyEntryDecoder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Type; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Collections; + +/** + * @author LamGC + */ +public final class SshAuthInfoSerializer implements JsonSerializer, JsonDeserializer { + + public final static SshAuthInfoSerializer INSTANCE = new SshAuthInfoSerializer(); + + @Override + public SshAuthInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!json.isJsonObject()) { + throw new JsonParseException("It should be a JsonObject"); + } + JsonObject infoObject = json.getAsJsonObject(); + String type = getFieldToStringOrFail(infoObject, "authType"); + SshAuthInfo.AuthType authType = SshAuthInfo.AuthType.valueOf(type.toUpperCase()); + SshAuthInfo info; + if (authType == SshAuthInfo.AuthType.PASSWORD) { + PasswordAuthInfo pswAuthInfo = new PasswordAuthInfo(); + pswAuthInfo.setPassword(getFieldToStringOrFail(infoObject, "password")); + info = pswAuthInfo; + } else if (authType == SshAuthInfo.AuthType.PUBLIC_KEY) { + PublicKeyAuthInfo publicKeyInfo = new PublicKeyAuthInfo(); + String privateKeyPath = getFieldToStringOrFail(infoObject, "privateKeyPath"); + File privateKeyFile = new File(privateKeyPath); + publicKeyInfo.setPrivateKeyPath(privateKeyFile); + info = publicKeyInfo; + } else { + throw new JsonParseException("Unsupported authentication type: " + authType); + } + info.setUsername(getFieldToStringOrFail(infoObject, "username")); + try { + info.setServerKey(decodeSshPublicKey( + infoObject.has("serverKey") && infoObject.get("serverKey").isJsonPrimitive() ? + infoObject.get("serverKey").getAsString() : + null)); + } catch (GeneralSecurityException | IOException e) { + throw new JsonParseException(e); + } + return info; + } + + @Override + public JsonElement serialize(SshAuthInfo src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject json = new JsonObject(); + json.addProperty("authType", src.getType().toString()); + json.addProperty("username", src.getUsername()); + try { + json.addProperty("serverKey", encodeSshPublicKey(src.getServerKey())); + } catch (IOException e) { + throw new JsonParseException(e); + } + if (src instanceof PasswordAuthInfo info) { + json.addProperty("password", info.getPassword()); + } else if (src instanceof PublicKeyAuthInfo info) { + try { + json.addProperty("privateKeyPath", info.getPrivateKeyPath().getCanonicalPath()); + } catch (IOException e) { + throw new JsonParseException(e); + } + json.addProperty("keyPassword", info.getKeyPassword()); + } else { + throw new JsonParseException("Unsupported type"); + } + return json; + } + + private String getFieldToStringOrFail(JsonObject object, String field) { + if (!object.has(field)) { + throw new JsonParseException("Missing field: " + field); + } + return object.get(field).getAsString(); + } + + private PublicKey decodeSshPublicKey(String publicKeyString) throws GeneralSecurityException, IOException { + if (Strings.isNullOrEmpty(publicKeyString)) { + return null; + } + + String[] strings = publicKeyString.split(" ", 3); + + @SuppressWarnings("unchecked") PublicKeyEntryDecoder decoder = + (PublicKeyEntryDecoder) KeyUtils.getPublicKeyEntryDecoder(strings[0]); + return decoder.decodePublicKey(null, strings[0], Base64.getDecoder().decode(strings[1]), Collections.emptyMap()); + } + + private String encodeSshPublicKey(PublicKey key) throws IOException { + if (key == null) { + return null; + } + + StringBuilder builder = new StringBuilder(); + PublicKeyEntry.appendPublicKeyEntry(builder, key); + return builder.toString(); + /* + // 以下代码改写自 KnownHosts 的那个认证器, 说实话翻一下官方代码还可以找到不错的东西. + @SuppressWarnings("unchecked") PublicKeyEntryDecoder decoder + = (PublicKeyEntryDecoder) KeyUtils.getPublicKeyEntryDecoder(key); + if (decoder == null) { + throw new JsonParseException("Cannot retrieve decoder for key=" + key.getAlgorithm()); + } + + try (ByteArrayOutputStream s = new ByteArrayOutputStream(Byte.MAX_VALUE)) { + String keyType = decoder.encodePublicKey(s, key); + byte[] bytes = s.toByteArray(); + PublicKeyEntryDataResolver encoder = PublicKeyEntry.resolveKeyDataEntryResolver(keyType); + return encoder.encodeEntryKeyData(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + }*/ + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshExecChannel.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshExecChannel.java new file mode 100644 index 0000000..b37c9ff --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshExecChannel.java @@ -0,0 +1,47 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import net.lamgc.oracle.sentry.common.InputStreamWrapper; +import net.lamgc.oracle.sentry.common.OutputStreamWrapper; +import org.apache.sshd.client.channel.ChannelExec; +import org.apache.sshd.client.channel.ClientChannelEvent; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.EnumSet; + +public final class SshExecChannel implements Closeable { + + private final ChannelExec channelExec; + + public SshExecChannel(ChannelExec channelExec) { + this.channelExec = channelExec; + } + + public void exec() throws IOException { + channelExec.open(); + channelExec.waitFor(EnumSet.of(ClientChannelEvent.EXIT_STATUS, ClientChannelEvent.EXIT_SIGNAL), 0L); + } + + public Integer exitCode() { + return channelExec.getExitStatus(); + } + + public void setIn(InputStream in) { + channelExec.setIn(new InputStreamWrapper(in)); + } + + public void setOut(OutputStream out) { + channelExec.setOut(new OutputStreamWrapper(out)); + } + + public void setErr(OutputStream err) { + channelExec.setErr(new OutputStreamWrapper(err)); + } + + @Override + public void close() throws IOException { + channelExec.close(); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshSession.java b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshSession.java new file mode 100644 index 0000000..d116570 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshSession.java @@ -0,0 +1,26 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.sftp.client.SftpClient; +import org.apache.sshd.sftp.client.SftpClientFactory; + +import java.io.Closeable; +import java.io.IOException; + +public class SshSession implements Closeable { + + private final ClientSession clientSession; + + public SshSession(ClientSession clientSession) { + this.clientSession = clientSession; + } + + public SshExecChannel execCommand(String command) throws IOException { + return new SshExecChannel(clientSession.createExecChannel(command)); + } + + @Override + public void close() throws IOException { + clientSession.close(); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/Script.java b/src/main/java/net/lamgc/oracle/sentry/script/Script.java new file mode 100644 index 0000000..a6dd598 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/Script.java @@ -0,0 +1,17 @@ +package net.lamgc.oracle.sentry.script; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +/** + * @author LamGC + */ +public abstract class Script { + + /** + * 获取脚本信息. + * @return 返回脚本 ScriptInfo 对象. + */ + public abstract ScriptInfo getScriptInfo(); + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/ScriptComponent.java b/src/main/java/net/lamgc/oracle/sentry/script/ScriptComponent.java new file mode 100644 index 0000000..b556f09 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/ScriptComponent.java @@ -0,0 +1,14 @@ +package net.lamgc.oracle.sentry.script; + +import net.lamgc.oracle.sentry.ComputeInstanceManager; +import net.lamgc.oracle.sentry.script.tools.http.ScriptHttpClient; + +/** + * @author LamGC + */ +public final record ScriptComponent( + ScriptHttpClient HTTP, + ComputeInstanceManager InstanceManager +) { + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/ScriptInfo.java b/src/main/java/net/lamgc/oracle/sentry/script/ScriptInfo.java new file mode 100644 index 0000000..3e2f949 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/ScriptInfo.java @@ -0,0 +1,60 @@ +package net.lamgc.oracle.sentry.script; + +import java.util.Objects; + +/** + * 脚本信息. + * @author LamGC + */ +public class ScriptInfo { + + private String group; + private String artifact; + private String version; + + public String getGroup() { + return group; + } + + public String getArtifact() { + return artifact; + } + + public String getVersion() { + return version; + } + + @Override + public String toString() { + return getGroup() + ":" + getArtifact() + ":" + getVersion(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ScriptInfo that = (ScriptInfo) o; + return group.equals(that.group) && artifact.equals(that.artifact) && version.equals(that.version); + } + + @Override + public int hashCode() { + return Objects.hash(group, artifact, version); + } + + public void setGroup(String group) { + this.group = group; + } + + public void setArtifact(String artifact) { + this.artifact = artifact; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/ScriptLoader.java b/src/main/java/net/lamgc/oracle/sentry/script/ScriptLoader.java new file mode 100644 index 0000000..2ef0a3d --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/ScriptLoader.java @@ -0,0 +1,34 @@ +package net.lamgc.oracle.sentry.script; + +import java.io.File; + +/** + * 脚本加载器. + * @author LamGC + */ +public interface ScriptLoader { + + /** + * 是否可以加载. + * @param scriptFile 脚本文件. + * @return 如果可以加载, 返回 {@code true}. + */ + boolean canLoad(File scriptFile); + + /** + * 加载脚本. + * @param context 脚本上下文. + * @param scriptFile 脚本文件. + * @return 返回脚本对象. + * @throws Exception 当 Loader 抛出异常时, 将视为脚本加载失败, 该脚本跳过加载. + */ + Script loadScript(ScriptComponent context, File scriptFile) throws Exception; + + /** + * 获取脚本信息. + * @param script 脚本对象. + * @return 返回脚本信息. + */ + ScriptInfo getScriptInfo(Script script); + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/ScriptManager.java b/src/main/java/net/lamgc/oracle/sentry/script/ScriptManager.java new file mode 100644 index 0000000..f6897d0 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/ScriptManager.java @@ -0,0 +1,119 @@ +package net.lamgc.oracle.sentry.script; + +import com.google.common.base.Throwables; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 脚本管理器. + * @author LamGC + */ +public final class ScriptManager { + + private final static Logger log = LoggerFactory.getLogger(ScriptManager.class); + + private final Set loaders = new HashSet<>(); + private final File scriptsLocation; + private final ScriptComponent context; + + private final Map scripts = new ConcurrentHashMap<>(); + + public ScriptManager(File scriptsLocation, ScriptComponent context) { + this.scriptsLocation = scriptsLocation; + this.context = context; + loadScriptLoaders(); + } + + private synchronized void loadScriptLoaders() { + if (!loaders.isEmpty()) { + return; + } + ServiceLoader serviceLoader = ServiceLoader.load(ScriptLoader.class); + for (ScriptLoader scriptLoader : serviceLoader) { + loaders.add(scriptLoader); + } + log.info("存在 {} 个加载器可用.", loaders.size()); + } + + /** + * 从文件中加载一个脚本. + * @param scriptFile 脚本文件. + * @return 如果加载成功, 返回 {@code true}, 加载失败或无加载器可用时返回 {@code false} + * @throws InvocationTargetException 当加载器加载脚本抛出异常时, 将通过该异常包装后抛出. + * @throws NullPointerException 当 scriptFile 为 {@code null} 时抛出. + */ + public boolean loadScript(File scriptFile) throws InvocationTargetException { + Objects.requireNonNull(scriptFile); + for (ScriptLoader loader : loaders) { + Script script; + try { + if (loader.canLoad(scriptFile)) { + script = loader.loadScript(context, scriptFile); + if (script == null) { + log.warn("加载器未能正确加载脚本, 已跳过该脚本.(ScriptName: {})", scriptFile.getName()); + return false; + } + } else { + continue; + } + } catch (Exception e) { + log.error("脚本加载时发生异常.(Loader: {}, Path: {})\n{}", + loader.getClass().getName(), + scriptFile.getAbsolutePath(), + Throwables.getStackTraceAsString(e)); + throw new InvocationTargetException(e); + } + ScriptInfo scriptInfo = loader.getScriptInfo(script); + if (scriptInfo == null) { + log.warn("脚本加载成功, 但加载器没有返回脚本信息, 该脚本已放弃."); + return false; + } + scripts.put(scriptInfo, script); + return true; + } + return false; + } + + /** + * 从指定位置加载所有脚本. + */ + public void loadScripts() { + log.info("正在加载脚本...(Path: {})", scriptsLocation.getAbsolutePath()); + File[] files = scriptsLocation.listFiles(File::isFile); + if (files == null) { + log.warn("脚本目录无法访问, 请检查程序是否有权限访问脚本目录.(Path: {})", scriptsLocation.getAbsolutePath()); + return; + } + int loadCount = 0; + for (File scriptFile : files) { + try { + if (loadScript(scriptFile)) { + loadCount ++; + } + } catch (InvocationTargetException ignored) { + } + } + log.info("脚本已全部加载完成, 共成功加载了 {} 个脚本.", loadCount); + } + + /** + * 通过脚本信息返回脚本对象. + * @param scriptInfo 脚本信息. + * @return 返回脚本对象. + * @throws NoSuchElementException 当指定的 ScriptInfo 没有对应脚本对象时抛出该异常. + * @throws NullPointerException 当 scriptInfo 为 {@code null} 时抛出该异常. + */ + public Script getScriptByScriptInfo(ScriptInfo scriptInfo) { + Objects.requireNonNull(scriptInfo); + if (!scripts.containsKey(scriptInfo)) { + throw new NoSuchElementException(scriptInfo.toString()); + } + return scripts.get(scriptInfo); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyDslDelegate.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyDslDelegate.java new file mode 100644 index 0000000..689fa4e --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyDslDelegate.java @@ -0,0 +1,47 @@ +package net.lamgc.oracle.sentry.script.groovy; + +import net.lamgc.oracle.sentry.ComputeInstanceManager; +import net.lamgc.oracle.sentry.script.*; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; +import net.lamgc.oracle.sentry.script.groovy.trigger.*; +import net.lamgc.oracle.sentry.script.tools.http.ScriptHttpClient; +import org.codehaus.groovy.runtime.DefaultGroovyMethods; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +/** + * Groovy DSL 脚本的父类. + * @author LamGC + */ +@SuppressWarnings({"unused", "FieldCanBeLocal"}) +public class GroovyDslDelegate extends Script { + + private final GroovyScriptInfo scriptInfo = new GroovyScriptInfo(); + private final ScriptHttpClient HTTP; + private final ComputeInstanceManager InstanceManager; + + public GroovyDslDelegate(ScriptHttpClient httpClient, ComputeInstanceManager instanceManager) { + HTTP = httpClient; + InstanceManager = instanceManager; + } + + private void trigger(String triggerName, Closure closure){ + DefaultGroovyMethods.with(GroovyTriggerProvider.INSTANCE.getTriggerByName(triggerName), closure); + } + + /** + * 脚本的基本信息. + * @param scriptInfoClosure 配置了脚本信息的闭包对象. + */ + private void info(@DelegatesTo(GroovyScriptInfo.class) Closure scriptInfoClosure) { + DefaultGroovyMethods.with(scriptInfo, scriptInfoClosure); + } + + @Override + public ScriptInfo getScriptInfo() { + return scriptInfo; + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptInfo.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptInfo.java new file mode 100644 index 0000000..0a2b7ae --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptInfo.java @@ -0,0 +1,18 @@ +package net.lamgc.oracle.sentry.script.groovy; + +import net.lamgc.oracle.sentry.script.ScriptInfo; + +public class GroovyScriptInfo extends ScriptInfo { + + public void artifact(String artifact) { + super.setArtifact(artifact); + } + + public void group(String group) { + super.setGroup(group); + } + + public void version(String version) { + super.setVersion(version); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptLoader.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptLoader.java new file mode 100644 index 0000000..66a573c --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyScriptLoader.java @@ -0,0 +1,69 @@ +package net.lamgc.oracle.sentry.script.groovy; + +import com.google.common.base.Throwables; +import groovy.lang.GroovyClassLoader; +import groovy.util.DelegatingScript; +import net.lamgc.oracle.sentry.script.Script; +import net.lamgc.oracle.sentry.script.ScriptComponent; +import net.lamgc.oracle.sentry.script.ScriptInfo; +import net.lamgc.oracle.sentry.script.ScriptLoader; +import org.codehaus.groovy.control.CompilerConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author LamGC + */ +@SuppressWarnings("MapOrSetKeyShouldOverrideHashCodeEquals") +public class GroovyScriptLoader implements ScriptLoader { + + private final static Logger log = LoggerFactory.getLogger(GroovyScriptLoader.class); + + private final GroovyClassLoader scriptClassLoader; + + private final Map scriptInfoMap = new ConcurrentHashMap<>(); + + public GroovyScriptLoader() { + CompilerConfiguration compilerConfiguration = new CompilerConfiguration(); + compilerConfiguration.setScriptBaseClass(DelegatingScript.class.getName()); + this.scriptClassLoader = new GroovyClassLoader(GroovyClassLoader.class.getClassLoader(), compilerConfiguration); + } + + @Override + public boolean canLoad(File scriptFile) { + return scriptFile.getName().endsWith(".groovy"); + } + + @Override + public Script loadScript(ScriptComponent context, File scriptFile) throws IOException { + Class scriptClass = scriptClassLoader.parseClass(scriptFile); + if (!DelegatingScript.class.isAssignableFrom(scriptClass)) { + return null; + } + try { + Constructor constructor = + scriptClass.asSubclass(DelegatingScript.class).getConstructor(); + DelegatingScript newScriptObject = constructor.newInstance(); + GroovyDslDelegate dslDelegate = new GroovyDslDelegate(context.HTTP(), context.InstanceManager()); + newScriptObject.setDelegate(dslDelegate); + newScriptObject.run(); + scriptInfoMap.put(dslDelegate, dslDelegate.getScriptInfo()); + return dslDelegate; + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + log.error("加载脚本时发生异常.(ScriptPath: {})\n{}", scriptFile.getAbsolutePath(), Throwables.getStackTraceAsString(e)); + } + return null; + } + + @Override + public ScriptInfo getScriptInfo(Script script) { + return scriptInfoMap.get(script); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyTriggerProvider.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyTriggerProvider.java new file mode 100644 index 0000000..2fa4752 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/GroovyTriggerProvider.java @@ -0,0 +1,50 @@ +package net.lamgc.oracle.sentry.script.groovy; + +import com.google.common.base.Strings; +import net.lamgc.oracle.sentry.script.groovy.trigger.GroovyTrigger; +import net.lamgc.oracle.sentry.script.groovy.trigger.TriggerName; +import org.springframework.scheduling.Trigger; + +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author LamGC + */ +public class GroovyTriggerProvider { + + private final Map> triggerProviderMap = new ConcurrentHashMap<>(); + + public final static GroovyTriggerProvider INSTANCE = new GroovyTriggerProvider(); + + private GroovyTriggerProvider() { + ServiceLoader loader = ServiceLoader.load(GroovyTrigger.class); + loader.stream().iterator().forEachRemaining(triggerProvider -> { + Class triggerClass = triggerProvider.type(); + if (!triggerClass.isAnnotationPresent(TriggerName.class)) { + return; + } + + TriggerName triggerName = triggerClass.getAnnotation(TriggerName.class); + if (!Strings.isNullOrEmpty(triggerName.value())) { + String name = triggerName.value().toLowerCase(); + if (triggerProviderMap.containsKey(name)) { + return; + } + triggerProviderMap.put(name, triggerProvider); + } + + }); + } + + public GroovyTrigger getTriggerByName(String triggerName) { + if (!triggerProviderMap.containsKey(triggerName.toLowerCase())) { + throw new NoSuchElementException("The specified trigger could not be found: " + triggerName); + } + return triggerProviderMap.get(triggerName).get(); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/GroovyTrigger.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/GroovyTrigger.java new file mode 100644 index 0000000..4a18990 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/GroovyTrigger.java @@ -0,0 +1,19 @@ +package net.lamgc.oracle.sentry.script.groovy.trigger; + +import groovy.lang.DelegatesTo; + +/** + * @author LamGC + */ +public interface GroovyTrigger { + + /** + * 启动触发器. + *

注意, 触发器执行 run 方法不可以阻塞方法返回. + * @param task 触发器需要执行的任务. + */ + void run(Runnable task); + + + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/OnceTrigger.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/OnceTrigger.java new file mode 100644 index 0000000..0a2b1f7 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/OnceTrigger.java @@ -0,0 +1,24 @@ +package net.lamgc.oracle.sentry.script.groovy.trigger; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 只执行一次的触发器, 执行后将不再执行该任务. + * @author LamGC + */ +@TriggerName("once") +public class OnceTrigger implements GroovyTrigger { + + private final static ExecutorService executor = Executors.newFixedThreadPool(4, + new ThreadFactoryBuilder() + .setNameFormat("GroovyOnceExec-%d") + .build()); + + @Override + public void run(Runnable task) { + executor.execute(task); + } +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TimerTrigger.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TimerTrigger.java new file mode 100644 index 0000000..edffa07 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TimerTrigger.java @@ -0,0 +1,55 @@ +package net.lamgc.oracle.sentry.script.groovy.trigger; + +import com.google.common.base.Throwables; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.support.CronTrigger; + +/** + * @author LamGC + */ +@TriggerName("timer") +public class TimerTrigger implements GroovyTrigger { + + private final static Logger log = LoggerFactory.getLogger(TimerTrigger.class); + + private CronTrigger trigger; + private final TaskScheduler scheduler = new ThreadPoolTaskScheduler(); + + /** + * 设定定时时间. + *

只允许在第一次执行时设置. + * @param expression Cron 时间表达式. + */ + public void time(String expression) { + if (trigger == null) { + trigger = new CronTrigger(expression); + } + } + + @Override + public void run(Runnable runnable) { + if (trigger == null) { + if (!log.isDebugEnabled()) { + log.warn("脚本尚未设置 Cron 时间表达式, 任务将不会执行(堆栈信息请检查调试级别日志)."); + } else { + log.warn("{} - 脚本尚未设置 Cron 时间表达式, 任务将不会执行(堆栈信息请检查调试级别日志).", this); + log.warn("{} - 脚本尚未设置 Cron 时间表达式, 任务将不会执行.\n{}", this, Throwables.getStackTraceAsString(new Exception())); + } + return; + } else if (runnable == null) { + if (!log.isDebugEnabled()) { + log.warn("脚本尚未设置 Cron 时间表达式, 任务将不会执行(堆栈信息请检查调试级别日志)."); + } else { + log.warn("{} - 脚本尚未设置任务动作, 任务将不会执行(堆栈信息请检查调试级别日志).", this); + log.warn("{} - 脚本尚未设置任务动作, 任务将不会执行.\n{}", this, Throwables.getStackTraceAsString(new Exception())); + } + return; + } + + scheduler.schedule(runnable, trigger); + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TriggerName.java b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TriggerName.java new file mode 100644 index 0000000..63acff4 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/groovy/trigger/TriggerName.java @@ -0,0 +1,18 @@ +package net.lamgc.oracle.sentry.script.groovy.trigger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 触发器名称. + * @author LamGC + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TriggerName { + + String value(); + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccess.java b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccess.java new file mode 100644 index 0000000..5f2e0c0 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccess.java @@ -0,0 +1,40 @@ +package net.lamgc.oracle.sentry.script.tools.http; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * + * @author LamGC + */ +public class HttpAccess { + + private final HttpClient client; + private final String url; + + HttpAccess(HttpClient client, String url) { + this.client = client; + this.url = url; + } + + public HttpAccessResponse get() throws IOException { + HttpGet request = new HttpGet(url); + HttpResponse response = client.execute(request); + return new HttpAccessResponse(response); + } + + public HttpAccessResponse post(String body) throws IOException { + HttpPost request = new HttpPost(url); + request.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + HttpResponse response = client.execute(request); + return new HttpAccessResponse(response); + } + + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccessResponse.java b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccessResponse.java new file mode 100644 index 0000000..7621f2a --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/HttpAccessResponse.java @@ -0,0 +1,51 @@ +package net.lamgc.oracle.sentry.script.tools.http; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.StatusLine; +import org.apache.http.util.EntityUtils; + +import java.io.File; +import java.io.IOException; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Http 响应. + * @author LamGC + */ +public final class HttpAccessResponse { + + private final StatusLine statusLine; + private final Locale locale; + private final Map headers = new ConcurrentHashMap<>(); + private final HttpEntity entity; + + HttpAccessResponse(HttpResponse response) { + this.statusLine = response.getStatusLine(); + this.locale = response.getLocale(); + for (Header header : response.getAllHeaders()) { + headers.put(header.getName(), header.getValue()); + } + this.entity = response.getEntity(); + } + + public StatusLine getStatusLine() { + return statusLine; + } + + public Locale getLocale() { + return locale; + } + + public String getContentToString() throws IOException { + return EntityUtils.toString(entity); + } + + public HttpEntity getEntity() { + return entity; + } + +} diff --git a/src/main/java/net/lamgc/oracle/sentry/script/tools/http/ScriptHttpClient.java b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/ScriptHttpClient.java new file mode 100644 index 0000000..b2347d0 --- /dev/null +++ b/src/main/java/net/lamgc/oracle/sentry/script/tools/http/ScriptHttpClient.java @@ -0,0 +1,21 @@ +package net.lamgc.oracle.sentry.script.tools.http; + +import org.apache.http.client.HttpClient; + +public class ScriptHttpClient { + + private final HttpClient httpClient; + + public ScriptHttpClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * 打开一个连接. + * @param url 要访问的 Url. + */ + public HttpAccess create(String url) { + return new HttpAccess(httpClient, url); + } + +} diff --git a/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.ScriptLoader b/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.ScriptLoader new file mode 100644 index 0000000..16d1d37 --- /dev/null +++ b/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.ScriptLoader @@ -0,0 +1 @@ +net.lamgc.oracle.sentry.script.groovy.GroovyScriptLoader diff --git a/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.groovy.trigger.GroovyTrigger b/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.groovy.trigger.GroovyTrigger new file mode 100644 index 0000000..7fd6e8f --- /dev/null +++ b/src/main/resources/META-INF/services/net.lamgc.oracle.sentry.script.groovy.trigger.GroovyTrigger @@ -0,0 +1,2 @@ +net.lamgc.oracle.sentry.script.groovy.trigger.OnceTrigger +net.lamgc.oracle.sentry.script.groovy.trigger.TimerTrigger diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..85f4928 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,10 @@ +oracle: + identity: + # location: "C:\\Users\\80728\\.oci\\global\\" + script: + ssh: + identityPath: "./ssh.config.json" + +logging: + level: + root: INFO \ No newline at end of file diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..99aa3d5 --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,75 @@ + + + + + + + ./logs + UTF-8 + [%-d{HH:mm:ss.SSS} %5level][%logger.%method():%-3L][%thread]: %msg%n + [%-d{HH:mm:ss.SSS} %5level][%logger]: %msg%n + ${sys:cgj.logsPath:-logs} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializerTest.java b/src/test/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializerTest.java new file mode 100644 index 0000000..694146a --- /dev/null +++ b/src/test/java/net/lamgc/oracle/sentry/oci/compute/ssh/SshAuthInfoSerializerTest.java @@ -0,0 +1,50 @@ +package net.lamgc.oracle.sentry.oci.compute.ssh; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SshAuthInfoSerializerTest { + + private final static Gson gson = new GsonBuilder() + .registerTypeAdapter(SshAuthInfo.class, new SshAuthInfoSerializer()) + .create(); + + private JsonObject getPasswordAuthObject() { + return gson.fromJson(""" + { + "username": "opc", + "authType": "password", + "serverKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/NGFFKkchNdE8HDE9WHGIcw97ZVOP5edY7drtRQn0xSSG6uLu08T36B8IWT+XJdg45/YMmcuVSzsG1QZs/R3s0URVUhsWjwdezWDeWeBHt8/6TGl2AsgA0iXSAOeRNldhZlITFvWoBEv2wElNjCTsEGo5bBp3rVPqqZNJFUs+FR9s/uVgmFqe7HGhuKhhk7BrRThJ/NcgDRicMQ4yXU3Hl++pG54TVLH+0HmgWg312XNAWtzw2iRmKBAuu2I4pP1TRp93K/lbD7QU8k8W7QcyGSAc73nZrhyzYVMko5wQGt4/vGpchOw7ehkotSejTB1GSyhzBTZobA23For76YLzuVFOjF3lEvSh1QV30ysu0PREKLtY83ad0WHVFqVgJrFHkkXQrglN335BhGwhFzwyMpRxbD8HCDtz6VjpqwoKtd/ExQkcfaj/g10o28vRzHGyzUbCTe433V61fjSsC4Bikw15vTnQ3ZuyOzfyoCYUNpFcf1Wv+mkoWqn9xU8lGvk= Test-Server", + "password": "123456" + } + """, JsonObject.class); + } + + private JsonObject getPublicKeyAuthObject() { + return gson.fromJson(""" + { + "username": "opc", + "authType": "Public_Key", + "serverKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/NGFFKkchNdE8HDE9WHGIcw97ZVOP5edY7drtRQn0xSSG6uLu08T36B8IWT+XJdg45/YMmcuVSzsG1QZs/R3s0URVUhsWjwdezWDeWeBHt8/6TGl2AsgA0iXSAOeRNldhZlITFvWoBEv2wElNjCTsEGo5bBp3rVPqqZNJFUs+FR9s/uVgmFqe7HGhuKhhk7BrRThJ/NcgDRicMQ4yXU3Hl++pG54TVLH+0HmgWg312XNAWtzw2iRmKBAuu2I4pP1TRp93K/lbD7QU8k8W7QcyGSAc73nZrhyzYVMko5wQGt4/vGpchOw7ehkotSejTB1GSyhzBTZobA23For76YLzuVFOjF3lEvSh1QV30ysu0PREKLtY83ad0WHVFqVgJrFHkkXQrglN335BhGwhFzwyMpRxbD8HCDtz6VjpqwoKtd/ExQkcfaj/g10o28vRzHGyzUbCTe433V61fjSsC4Bikw15vTnQ3ZuyOzfyoCYUNpFcf1Wv+mkoWqn9xU8lGvk= Test-Server", + "privateKeyPath": "", + "keyPassword": "" + } + """, JsonObject.class); + } + + @Test + public void deserializeTest() { + SshAuthInfo info = gson.fromJson(getPasswordAuthObject(), SshAuthInfo.class); + + assertTrue(info instanceof PasswordAuthInfo); + assertEquals("opc", info.getUsername()); + assertEquals("123456", ((PasswordAuthInfo) info).getPassword()); + assertEquals("SHA256:qBu2jRXM6Wog/jWUJJ0WLTMb3UdDGAmYEVZQNZdFZNM", KeyUtils.getFingerPrint(info.getServerKey())); + } + +} \ No newline at end of file diff --git a/src/test/java/net/lamgc/oracle/sentry/script/ScriptManagerTest.java b/src/test/java/net/lamgc/oracle/sentry/script/ScriptManagerTest.java new file mode 100644 index 0000000..01ce0d6 --- /dev/null +++ b/src/test/java/net/lamgc/oracle/sentry/script/ScriptManagerTest.java @@ -0,0 +1,22 @@ +package net.lamgc.oracle.sentry.script; + +import net.lamgc.oracle.sentry.ComputeInstanceManager; +import net.lamgc.oracle.sentry.script.tools.http.ScriptHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.Test; + +import java.io.File; + +class ScriptManagerTest { + + @Test + public void loadScriptTest() { + ScriptManager manager = new ScriptManager(new File("./run/scripts"), + new ScriptComponent(new ScriptHttpClient(HttpClientBuilder.create().build()), + new ComputeInstanceManager())); + + manager.loadScripts(); + + } + +} \ No newline at end of file