前言:
本篇文章记录我近期研究的问题:如何利用java实现堡垒机与内部机器建立隧道问题。
问题情景描述:
在生产环境中的集群往往在一个局域网中,而该局域网只能通过某台特定的堡垒机来访问。
即:为了更加安全,所以线上的服务器都无法直接访问,它必须通过一台堡垒机来访问。示意如下:
用户想直接访问内部服务器,但是这些内部服务器并没有外网。 而堡垒机和这些服务器在一个局域网中,堡垒机可以和内部服务器通信,同时堡垒机拥有外网,可以直接被用户访问到。那么我们便可以先由ssh到堡垒机,然后再ssh到内部服务器才能够访问。这样做自然可以减少攻击,但是每次要到内部机器上去执行命令,都需要经历2次ssh,对线上的调试与监控效率影响非常大。下面就来全面介绍一下用java如何来解决该问题。
通过ProxyCommand+Netcat
一、前提条件 流程介绍
- 本机、跳板机、目标机器(内部服务器)三者都需要已经做过公钥认证。
tip:如果不做秘钥认证就会提示分别输入跳板机和目标机器的密码,需要输入两次密码,非常繁琐。而且要利用java做交互式命令输入。这个问题最终我找到解决的办法,不需要手动进行交互式命令输入,Jsch中UIKeyboardInteractive 能够进行赋值,解决这个问题。
利用java做公钥认证的方式,暂时我没有解决掉。我改为上面的 利用命令做交互式赋值来解决的。
- linux服务器安装Netcat
- 以CentOS Linux 为例:
yum install nc
- 配置本机ssh config
运行命令:
vim ~/.ssh/config
内容
vim ~/.ssh/config
如下:
Host foo #目标主机(内网服务器)别名,也可写成目标服务器IP,可使用通配符,如:Host 10.208.*
HostName 192.168.0.11 #目标机域名或IP地址
User root #SSH用户名
Port 22 #SSH端口
ProxyCommand ssh -q -p 22 [email protected] nc %h %p #这地方的用户名, ip 是指的 堡垒机的
IdentityFile ~/.ssh/id_rsa #登陆堡垒机的私钥所在位置,如默认位置可不用显示指定
4、原理
通过ProxyCommand ,可以在开启ssh之前执行一个命令打开代理隧道,这个命令 nc %h % p
是在堡垒机上使用nc开启了远程隧道。
ProxyCommand 参数 中的-q 是为了防止和堡垒机的ssh连接产生多余的输出,比如 不加 -q 就会导致每次断开连接时会多一句 killed by signal 1
。
代码搬上来:
清单一 :gradle项目中导入使用的包
// ssh
compile 'com.jcraft:jsch:0.1.54'
// google json
compile 'com.google.code.gson:gson:2.8.5'
注意:如果你们使用的是maven构建的项目,那就去maven官网中去找对应的包,引入。
清单二:MyUserInfo.java 用户信息类
实现 Jsch包中的UserInfo,UIKeyboardInteractive,用来存用户信息,以及进行交互式命令的赋值。
注意:promptYesNo()方法,要手动改为true,只用这样,在运行的时候才能,赋值为yes,便不会再提示输入跳板机和目标机器的密码。
import com.jcraft.jsch.UIKeyboardInteractive;
import com.jcraft.jsch.UserInfo;
/**
* @author wangchunlan
* @Description
* @date 2018/10/12 14:46
**/
public abstract class MyUserInfo implements UserInfo,UIKeyboardInteractive {
@Override
public String[] promptKeyboardInteractive(String destination, String name, String instruction, String[] prompt, boolean[] echo) {
return new String[0];
}
@Override
public String getPassphrase() {
return null;
}
@Override
public String getPassword() {
return null;
}
@Override
public boolean promptPassword(String message) {
return false;
}
@Override
public boolean promptPassphrase(String message) {
return false;
}
@Override
public boolean promptYesNo(String message) {
// 注意此处改为true
return true;
}
@Override
public void showMessage(String message) {
}
}
清单三、 SSHInfo.java 堡垒机与目标机器的常用属性封装
注意:
1、我在setCommandOutput()方法中做了修改,添加了一句reader = new BufferedReader(new InputStreamReader(commandOutput));。
2、 SSHInfo()构造方法, 创建了对象:this.ssh =new JSch();
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
/**
* 跳板机 与目标机器的 常用属性 封装
* @author wangchunlan
* @Description
* @date 2018/10/12 14:52
**/
public class SSHInfo {
private Session session;
private JSch ssh;
// 目标机器
private String targer_username;
private String targer_password;
private String targer_host;
// 堡垒机
private String jump_username;
private String jump_password;
private String jump_host;
private int port = 22;
private InputStream commandOutput;
private BufferedReader reader;
private Channel channel;
private boolean ready;
public SSHInfo(){
}
public SSHInfo(String targer_username, String targer_password, String targer_host, String jump_username, String jump_password, String jump_host, int port) {
this.ssh =new JSch();
this.targer_username = targer_username;
this.targer_password = targer_password;
this.targer_host = targer_host;
this.jump_username = jump_username;
this.jump_password = jump_password;
this.jump_host = jump_host;
this.port = port;
}
public SSHInfo(String targer_username, String targer_password, String targer_host, String jump_username, String jump_password, String jump_host, int port, InputStream commandOutput) {
this.ssh =new JSch();
this.targer_username = targer_username;
this.targer_password = targer_password;
this.targer_host = targer_host;
this.jump_username = jump_username;
this.jump_password = jump_password;
this.jump_host = jump_host;
this.port = port;
this.commandOutput = commandOutput;
}
public Session getSession() {
return session;
}
public void setSession(Session session) {
this.session = session;
}
public JSch getSsh() {
return ssh;
}
public void setSsh(JSch ssh) {
this.ssh = ssh;
}
public String getTarger_username() {
return targer_username;
}
public void setTarger_username(String targer_username) {
this.targer_username = targer_username;
}
public String getTarger_password() {
return targer_password;
}
public void setTarger_password(String targer_password) {
this.targer_password = targer_password;
}
public String getTarger_host() {
return targer_host;
}
public void setTarger_host(String targer_host) {
this.targer_host = targer_host;
}
public String getJump_username() {
return jump_username;
}
public void setJump_username(String jump_username) {
this.jump_username = jump_username;
}
public String getJump_password() {
return jump_password;
}
public void setJump_password(String jump_password) {
this.jump_password = jump_password;
}
public String getJump_host() {
return jump_host;
}
public void setJump_host(String jump_host) {
this.jump_host = jump_host;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public InputStream getCommandOutput() {
return commandOutput;
}
public void setCommandOutput(InputStream commandOutput) {
this.commandOutput = commandOutput;
reader = new BufferedReader(new InputStreamReader(commandOutput));
}
public BufferedReader getReader() {
return reader;
}
public void setReader(BufferedReader reader) {
this.reader = reader;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public boolean isReady() {
return ready;
}
public void setReady(boolean ready) {
this.ready = ready;
}
}
清单四、SSHConnection.java 链接工具类
import com.jcraft.jsch.*;
import top.smartpos.itom.utils.LogUtils;
import java.io.File;
import java.util.List;
/**
* SSH链接工具类
* @author wangchunlan
* @Description
* @date 2018/10/12 15:43
**/
public class SSHConnection {
// private SSHInfo sshInfo=new SSHInfo();
private SSHInfo sshInfo=new SSHInfo("root","targer_password","192.168.0.11","root","jump_password","192.168.0.85",22);
public boolean connect(){
try {
String config=config(sshInfo.getPort(),sshInfo.getTarger_username(),sshInfo.getTarger_host(),sshInfo.getJump_username(),sshInfo.getJump_host());
System.out.println(config);
ConfigRepository configRepository=OpenSSHConfig.parse(config);
sshInfo.getSsh().setConfigRepository(configRepository);
Session session=sshInfo.getSsh().getSession("foo");
session.setPassword(sshInfo.getTarger_password());
session.setUserInfo(new MyUserInfo() {});
session.connect(30000);
sshInfo.setSession(session);
sshInfo.setReady(true);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public String write(String command) {
try {
sshInfo.setChannel(sshInfo.getSession().openChannel("exec"));
Channel channel= sshInfo.getChannel();
((ChannelExec) channel).setCommand(command);
sshInfo.setCommandOutput(channel.getInputStream());
channel.connect(3000);
StringBuilder sBuilder = new StringBuilder();
String lido = sshInfo.getReader().readLine();
while (lido != null) {
sBuilder.append(lido);
sBuilder.append("\n");
lido = sshInfo.getReader().readLine();
}
System.out.println("The remote command is: " + command);
return sBuilder.toString();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
// 断开 通道和会话
public void close() {
if (sshInfo.getChannel() != null)
sshInfo.getChannel().disconnect();
if (sshInfo.getSession() != null)
sshInfo.getSession() .disconnect();
sshInfo.setReady(false);
}
public String config(int port,String targer_username,String targer_host,String jump_username,String jump_host){
// todo :foo 要改为final 常量
String bastion=jump_username+"@"+jump_host+":"+port;
String config="";
config=
"Port "+port+"\n"+
"\n"+
"Host foo"+"\n"+
" User "+targer_username+"\n"+
" Hostname "+targer_host+"\n"+
" ProxyJump "+bastion+"\n"+
"Host *\n"+
" ConnectTime 30000\n"+
" PreferredAuthentications keyboard-interactive,password,publickey\n"+
" #ForwardAgent yes\n"+
" #StrictHostKeyChecking no\n"+
" #IdentityFile ~/.ssh/id_rsa\n"+ //登陆跳板机的私钥所在位置,如默认位置可不用显示指定
" #UserKnownHostsFile ~/.ssh/known_hosts";
return config;
}
/**
* 上传文件
* @param sourceFile 本地路径
* @param dirDestino 上传文件绝对路径 如:/root/kvm2.xml
* @return
*/
/**
* 上传文件
* @param sourceFile 本地文件绝对路径 如:c:/kvm.xml
* @param targetDirFileLocation 上传文件所在目录 如:/root/
* @return
*/
public boolean upload(String sourceFile,String targetDirFileLocation) {
try {
File origem_ = new File(sourceFile);
targetDirFileLocation = targetDirFileLocation.replace(" ", "_");
String targetFile = targetDirFileLocation.concat("/").concat(origem_.getName());
return upload(sourceFile, targetFile, targetDirFileLocation);
} catch (Exception e) {
throw new SSHException(e);
}
}
/**
* 上传文件
* @param sourceFile 本地文件绝对路径 如:c:/kvm.xml
* @param targetFile 目标文件绝对路径 如:/root/kvm2.xml
* @param targetDirFileLocation 上传文件所在目录 如:/root/
* @return
*/
public boolean upload(String sourceFile,String targetFile,String targetDirFileLocation) {
try {
ChannelSftp sftp = (ChannelSftp) sshInfo.getSession().openChannel("sftp");
sftp.connect();
targetDirFileLocation = targetDirFileLocation.replace(" ", "_");
sftp.cd(targetDirFileLocation);
sftp.put(sourceFile, targetFile);
sftp.disconnect();
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 下载文件
* @param sourceFile 下载文件绝对路径名称 如:/root/kvm2.xml
* @param targetFile 下载文件目标位置绝对路径名称 如:C:\Users\kvm2.xml
* @return
*/
public boolean download(String sourceFile, String targetFile){
try {
ChannelSftp sftp = (ChannelSftp) sshInfo.getSession().openChannel("sftp");
sftp.connect();
sftp.get(sourceFile, targetFile);
sftp.disconnect();
return true;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 判断单个源文件[上传文件] 是否存在
* @param sourceFile 源文件
* @return
*/
public boolean prepareUpload(String sourceFile) {
File file = new File(sourceFile);
if (file.exists() && file.isFile()) {
return true;
}
return false;
}
/**
* 判断多个源文件[上传文件] 是否存在
* @param sourceFiles 源文件
* @return
*/
public boolean prepareUpload(List sourceFiles) {
for(String item:sourceFiles){
File file = new File(item);
boolean isTrue=file.exists() && file.isFile();
if (!isTrue) {
return false;
}
continue;
}
return true;
}
public SSHInfo getSshInfo() {
return sshInfo;
}
public void setSshInfo(SSHInfo sshInfo) {
this.sshInfo = sshInfo;
}
}
清单五、TestDemo.java 测试用例
/**
* 测试
* @author wangchunlan
* @Description
* @date 2018/10/12 16:29
**/
public class TestDemo {
public static void main(String[] args) {
// 测试一、创建目录
createDir("wangchunlan");
// 测试二、 上传单个文件
uploadTo("/root/kvm2.txt","/root/","C:\\Users\\Administrator\\Desktop\\kvm2.xml");
}
/**
* 创建文件夹
* tip:当文件夹存在时,不报错。
* @param targetDirFileLocation 创建(目标)文件夹的绝对路径 如:/root/ma
*/
public static void createDir(String targetDirFileLocation) {
SSHConnection ssh = new SSHConnection();
try {
ssh.connect();
if (ssh.getSshInfo().isReady()) {
ssh.write("mkdir -p " + targetDirFileLocation);
String out = ssh.write("ifconfig");
System.out.print(out);
ssh.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 上传单个文件
*
* @param targetFile 目标文件的绝对路径 如 :/root/kvm2.txt"
* @param targetDirFileLocation 目标文件所在绝对路径 如:/root/
* @param sourceFile 源文件的绝对路径完整名称 如:C:\Users\kvm2.txt
* @return
*/
public static boolean uploadTo(String targetFile, String targetDirFileLocation, String sourceFile) {
SSHConnection ssh = new SSHConnection();
try {
ssh.connect();
if (ssh.getSshInfo().isReady()) {
boolean isExist = ssh.prepareUpload(sourceFile);
if (!isExist) {
System.out.print("本地不存在此文件");
return false;
}
if (ssh.upload(sourceFile, targetFile, targetDirFileLocation)) {
System.out.print("文件上传成功");
ssh.close();
return true;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 创建文件夹
* tip:当文件夹存在时,不报错。
*
* @param targetDirFileLocation 创建(目标)文件夹的绝对路径 如:/root/ma
*/
public static void createDir(SSHConnection ssh, String targetDirFileLocation) {
ssh.write("mkdir -p " + targetDirFileLocation);
}
}
说明一下:我用的堡垒机的IP :192.168.0.85,内部服务器的IP是192.168.0.11。
运行TestDemo.java文件,我们来测试一下。
测试一、创建目录
createDir("wangchunlan");
先去linux内部服务器(192.168.0.11)中查看一下/root/目录下文件:
然后运行结果,控制台输出:
我们到内部服务器中查看一下,是否真的有这个文件wangchunlan
测试二、上传单个文件
uploadTo("/root/kvm2.txt","/root/","C:\Users\Administrator\Desktop\kvm2.xml");
同理可验证:文件上传也是可以用的。
以上代码,已经完成了用java实现 Jsch建立隧道问题。
下面是我的一点文章扩展。
通过搜集资料和实践,最终确定了用JSch来链接到服务器并进行系列远程命令操作。而在建立2层链接时,开始是利用普通2层session,太浪费时间,每次用都要链接2次,关闭也是如此。后又改用端口转发,虽然能够实现,如果服务器堡垒机的端口意外泄露,会造成 恶意攻击。最后,确定下使用Proxycommand。
关于使用Proxy 建立隧道,有两种方式 ProxyCommand 和ProxyJump.我使用的是linux命令实现的。可以看我的另一篇文章 《用java 运行proxyCommand 命令,带来命令交互式问题》。
关于使用端口转发,也请参考我的另一篇文章《 java 利用jsch端口转发 建立连接》。
关于OpenSSH/Cookbook/Proxies and Jump Hosts 文章,get到的知识点,提取整理一下《 OpenSSH / Cookbook / Proxies和Jump Hosts 知识点提取》。
参考资料:
1、OpenSSH/Cookbook/Proxies and Jump Hosts 重点推荐:对于socks代理,堡垒机转发链接网关,以及利用netcat隧道连接,解释的非常通透,我也是因为这篇文章才豁然开朗的;
2、SSH ProxyCommand example: Going through one host to reach another server :通过这篇文章的示例,在linux测试,初步理解了实现原理。
3、透过SSH代理穿越跳板机的方法 : 说清楚了 问题场景以及实现步骤,所以我参考了其中的ProxyCommand+Netcat 部分的叙述。
4、JSch - Examples - OpenSSHConfig.java :官网的示例,拯救了对于Jsch使用的疑惑。
5、Secure Shell (SSH) and Java : 重点推荐: 说清了 java实现 ssh的的思路,需要翻墙才能阅读。