java netty开发一个http/https代理
程序员文章站
2022-03-02 15:29:48
...
http代理数据传播路径:
- 客户端将请求发送到代理,代理解析出消息目的地再去请求服务器
- 服务器将完整结果返回给代理,代理再将结果返回给客户端
- 代理就在两者之间进行中转数据
https消息传播模式:
- 客户端将请求的目的地端口明文发送到代理,
- 代理解析出服务器host 端口,并连接成功,返回客户端连接成功的标识
- 客户端知道代理已经连接成功了,开始将ssl握手之类的加密数据发送给代理
- 代理就在服务器客户端之间进行转发数据,他并不知道传输的数据到底是什么,因为是加密的
实现方式:
编程语言:java
框架选择:netty
首先创建一个标准的netty启动
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 6000)
.childHandler(new ProxyServiceInit());
ChannelFuture f = b.bind(PropertiesUtil.getIntProp("start.port")).sync();
f.channel().closeFuture().sync();
} finally {
workGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
向其中添加这么两个handler,其中HttpServerCodec是netty自带的,HttpService是自己实现的
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline p = channel.pipeline();
p.addLast("httpcode", new HttpServerCodec());
p.addLast("httpservice", new HttpService());
}
HttpServerCodec会将客户端传进来的消息转成httpobject对象,并且是已经被聚合了的http消息,我们在自己写的HttpService中使用
自定义httpservice 集成simpleinbondhandler
public class HttpService extends SimpleChannelInboundHandler<HttpObject> {
//保留全局ctx
private ChannelHandlerContext ctx;
//创建一会用于连接web服务器的 Bootstrap
private Bootstrap b = new Bootstrap();
//channelActive方法中将ctx保留为全局变量
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
this.ctx = ctx;
}
//Complete方法中刷新数据
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, HttpObject msg) throws Exception {
if (msg instanceof HttpRequest) {
//转成 HttpRequest
HttpRequest req = (HttpRequest) msg;
if (PasswordChecker.digestLogin(req)) { //检测密码,后面讲
HttpMethod method = req.method(); //获取请求方式,http的有get post ..., https的是 CONNECT
String headerHost = req.headers().get("Host"); //获取请求头中的Host字段
String host = "";
int port = 80; //端口默认80
String[] split = headerHost.split(":"); //可能有请求是 host:port的情况,
host = split[0];
if (split.length > 1) {
port = Integer.valueOf(split[1]);
}
Promise<Channel> promise = createPromise(host, port); //根据host和port创建连接到服务器的连接
/*
根据是http还是http的不同,为promise添加不同的监听器
*/
if (method.equals(HttpMethod.CONNECT)) {
//如果是https的连接
promise.addListener(new FutureListener<Channel>() {
@Override
public void operationComplete(Future<Channel> channelFuture) throws Exception {
//首先向浏览器发送一个200的响应,证明已经连接成功了,可以发送数据了
FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, new HttpResponseStatus(200, "OK"));
//向浏览器发送同意连接的响应,并在发送完成后移除httpcode和httpservice两个handler
ctx.writeAndFlush(resp).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
ChannelPipeline p = ctx.pipeline();
p.remove("httpcode");
p.remove("httpservice");
}
});
ChannelPipeline p = ctx.pipeline();
//将客户端channel添加到转换数据的channel,(这个NoneHandler是自己写的)
p.addLast(new NoneHandler(channelFuture.getNow()));
}
});
} else {
//如果是http连接,首先将接受的请求转换成原始字节数据
EmbeddedChannel em = new EmbeddedChannel(new HttpRequestEncoder());
em.writeOutbound(req);
final Object o = em.readOutbound();
em.close();
promise.addListener(new FutureListener<Channel>() {
@Override
public void operationComplete(Future<Channel> channelFuture) throws Exception {
//移除 httpcode httpservice 并添加 NoneHandler,并向服务器发送请求的byte数据
ChannelPipeline p = ctx.pipeline();
p.remove("httpcode");
p.remove("httpservice");
//添加handler
p.addLast(new NoneHandler(channelFuture.getNow()));
channelFuture.get().writeAndFlush(o);
}
});
}
} else {
ctx.writeAndFlush(PasswordChecker.getDigest());
}
} else {
ReferenceCountUtil.release(msg);
}
}
//根据host和端口,创建一个连接web的连接
private Promise<Channel> createPromise(String host, int port) {
final Promise<Channel> promise = ctx.executor().newPromise();
b.group(ctx.channel().eventLoop())
.channel(NioSocketChannel.class)
.remoteAddress(host, port)
.handler(new NoneHandler(ctx.channel()))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.connect()
.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
if (channelFuture.isSuccess()) {
promise.setSuccess(channelFuture.channel());
} else {
ctx.close();
channelFuture.cancel(true);
}
}
});
return promise;
}
}
noneHandler里面只做数据的转发,没有任何逻辑
public class NoneHandler extends ChannelInboundHandlerAdapter {
private Channel outChannel;
public NoneHandler(Channel outChannel) {
this.outChannel = outChannel;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//System.out.println("交换数据");
outChannel.write(msg);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
outChannel.flush();
}
}
上面我们添加了密码的验证,去掉密码验证部分就能直接使用了,可以先去掉测试一下,添加密码验证放在服务器上更安全
密码验证的逻辑就是,
-
http(s)请求头中包含我们约定好的密码,如果没有我们就发送一个407响应,这样浏览器(chromr,firefox,ie)就知道代理服务器要验证密码,就会弹出窗口要我们输入密码,
-
如果验证成功过一次,下次的请求浏览器会自动带上密码,不用再重新输入
-
密码的方式有两种,一种是简单的Basic,密码是明文传输的,一种是digest方式
方式一 basic 方式
如果没有输入密码的情况下,代理返回的响应是
FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED);
resp.headers().add("Proxy-Authenticate", "Basic realm=\"Text\"");
resp.headers().setInt("Content-Length", resp.content().readableBytes());
return resp;
发送一个 PROXY_AUTHENTICATION_REQUIRED响应,响应头不包含 "Basic realm=\"Text\"",其中 `Text`可以随便起,这样浏览器就能弹窗出来
报文可能长这样
HTTP/1.0 407 PROXY_AUTHENTICATION_REQUIRED
Server: SokEvo/1.0
WWW-Authenticate: Basic realm="Text"
Content-Type: text/html
Content-Length: xxx
代理如何验证密码呢?就是将头部的帐号密码解析出来,和我们的帐号密码进行比对
用户输入帐号密码后,服务器可能收到这样的报文
Get /index.html HTTP/1.0
Host:www.google.com
Proxy-Authorization: Basic xxxxxxxxxxxxxxxxxxxxxxxxxxxx
//basic方式登录
public static boolean basicLogin(HttpRequest req) {
//获取请求头中的 Proxy-Authorization
String s = req.headers().get("Proxy-Authorization");
if (s == null) {
return false;
}
//密码的形式是 `Basic 帐号:密码`用冒号拼接在一起,在取base64
try {
String[] split = s.split(" ");
byte[] decode = Base64.decodeBase64(split[1]); //去数组中的第二个,第一个是一个Basic固定的字符
String userNamePassWord = new String(decode);
String[] split1 = userNamePassWord.split(":", 2);
PasswordChecker.basicCheck(split1[0], split1[1]); //比较帐号密码是不是我们自己的帐号密码
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
方式二 digest方式,
这种方式是,服务器发送一个随机数给浏览器,浏览器用密码和一堆东西混合(用户名,uri之类的)在一起,进行md5加密,将混合的东西传给服务器,
那么服务器以同样的方式进行操作一遍,如果得到的结果与浏览器传上来的相同,那么就证明密码正确,传输阶段,不会暴露帐号密码
这个参考*的 http digest 摘要加密方式页面