Skip to content

Commit

Permalink
feat(client): support http proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
Zmax0 committed Jul 11, 2024
1 parent f1c5c16 commit 17eb169
Show file tree
Hide file tree
Showing 39 changed files with 708 additions and 253 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

[![codecov](https://codecov.io/gh/Zmax0/urban-spork/branch/master/graph/badge.svg?token=6QAZQ05HZV)](https://codecov.io/gh/Zmax0/urban-spork)

A sock5 proxy for improved privacy and security
A network tool for improved privacy and security

## Features

### Local

- http
- socks5

### Transport

| | Shadowsocks | VMess | Trojan |
Expand Down Expand Up @@ -75,25 +80,26 @@ put *config.json* file into the unpacked folder before running server
> `packetEncoding`: "None" | "Packet"
> `user`: (OPTIONAL for shadowsocks) support multiple users with [*Shadowsocks 2022 Extensible Identity Headers*](https://github.com/Shadowsocks-NET/shadowsocks-specs/blob/main/2022-2-shadowsocks-2022-extensible-identity-headers.md)
> `user`: (OPTIONAL for shadowsocks) support multiple users with [*Shadowsocks 2022 Extensible Identity
Headers*](https://github.com/Shadowsocks-NET/shadowsocks-specs/blob/main/2022-2-shadowsocks-2022-extensible-identity-headers.md)

> `ssl`: (OPTIONAL) SSL specific configurations
>> `certificateFile`: certificate file
> > `certificateFile`: certificate file
>> `keyFile`: private key file for encryption
> > `keyFile`: private key file for encryption
>> `keyPassword`: password of the private key file
> > `keyPassword`: password of the private key file
>> `serverName`: the Server Name Indication field in the SSL handshake. If left blank, it will be set to `server.host`
> > `serverName`: the Server Name Indication field in the SSL handshake. If left blank, it will be set to `server.host`
>> `verifyHostname`: whether to verify SSL hostname, default is `true`
> > `verifyHostname`: whether to verify SSL hostname, default is `true`
> `ws`: (OPTIONAL) WebSocket specific configurations
>> `header`: the header to be sent in HTTP request, should be key-value pairs in clear-text string format
> > `header`: the header to be sent in HTTP request, should be key-value pairs in clear-text string format
>> `path`: the HTTP path for the websocket request
> > `path`: the HTTP path for the websocket request
## Build

Expand Down
2 changes: 1 addition & 1 deletion urban-spork-client-gui/src/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
requires com.urbanspork.client;
requires com.urbanspork.common;
requires io.netty.handler;
requires java.datatransfer;
requires java.desktop;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
requires org.slf4j;

exports com.urbanspork.client.gui.console to javafx.graphics, ch.qos.logback.core;
exports com.urbanspork.client.gui.tray to javafx.graphics, ch.qos.logback.core;
}
2 changes: 1 addition & 1 deletion urban-spork-client/src/com/urbanspork/client/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public static void launch(ClientConfig config, CompletableFuture<Instance> promi
.childOption(ChannelOption.SO_KEEPALIVE, true) // socks5 require
.childOption(ChannelOption.TCP_NODELAY, false)
.childOption(ChannelOption.SO_LINGER, 1)
.childHandler(new ClientSocksInitializer(current))
.childHandler(new ClientInitializer(current))
.bind(InetAddress.getLoopbackAddress(), config.getPort()).sync().addListener((ChannelFutureListener) future -> {
ServerSocketChannel tcp = (ServerSocketChannel) future.channel();
InetSocketAddress tcpLocalAddress = tcp.localAddress();
Expand Down
140 changes: 140 additions & 0 deletions urban-spork-client/src/com/urbanspork/client/ClientConnectHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.urbanspork.client;

import com.urbanspork.client.trojan.ClientHeaderEncoder;
import com.urbanspork.client.vmess.ClientAeadCodec;
import com.urbanspork.common.channel.AttributeKeys;
import com.urbanspork.common.channel.ChannelCloseUtils;
import com.urbanspork.common.channel.DefaultChannelInboundHandler;
import com.urbanspork.common.codec.shadowsocks.Mode;
import com.urbanspork.common.codec.shadowsocks.tcp.Context;
import com.urbanspork.common.codec.shadowsocks.tcp.TcpRelayCodec;
import com.urbanspork.common.config.ServerConfig;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.handler.codec.MessageToMessageCodec;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler;
import io.netty.handler.codec.socks.SocksCmdType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetSocketAddress;
import java.net.URISyntaxException;
import java.util.List;
import java.util.function.Consumer;

interface ClientConnectHandler {
Logger logger = LoggerFactory.getLogger(ClientConnectHandler.class);

ChannelHandler inboundHandler();

InboundWriter inboundWriter();

Consumer<Channel> outboundWriter();

default void connect(Channel inbound, InetSocketAddress dstAddress) {
ServerConfig config = inbound.attr(AttributeKeys.SERVER_CONFIG).get();
boolean autoResponse = !config.wsEnabled();
InetSocketAddress serverAddress = new InetSocketAddress(config.getHost(), config.getPort());
new Bootstrap()
.group(inbound.eventLoop())
.channel(inbound.getClass())
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.handler(getOutboundChannelHandler(inbound, dstAddress, config))
.connect(serverAddress).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
Channel outbound = future.channel();
outbound.pipeline().addLast(new DefaultChannelInboundHandler(inbound)); // R → L
inbound.pipeline().remove(inboundHandler());
if (autoResponse) {
inbound.pipeline().addLast(new DefaultChannelInboundHandler(outbound));
inboundWriter().success().accept(inbound);
outboundWriter().accept(outbound);
}
} else {
logger.error("Connect proxy server {} failed", serverAddress);
inboundWriter().failure().accept(inbound);
ChannelCloseUtils.closeOnFlush(inbound);
}
});
}

private ChannelHandler getOutboundChannelHandler(Channel inbound, InetSocketAddress address, ServerConfig config) {
return new ChannelInitializer<>() {
@Override
protected void initChannel(Channel outbound) throws Exception {
if (config.wsEnabled()) {
enableWebSocket(inbound, outbound, config);
}
switch (config.getProtocol()) {
case vmess -> outbound.pipeline().addLast(new ClientAeadCodec(config.getCipher(), address, config.getPassword()));
case trojan -> outbound.pipeline().addLast(
ClientInitializer.buildSslHandler(outbound, config),
new ClientHeaderEncoder(config.getPassword(), address, SocksCmdType.CONNECT.byteValue())
);
default -> outbound.pipeline().addLast(new TcpRelayCodec(new Context(), config, address, Mode.Client));
}
}
};
}

private void enableWebSocket(Channel inbound, Channel outbound, ServerConfig config) throws URISyntaxException {
outbound.pipeline().addLast(
new HttpClientCodec(),
new HttpObjectAggregator(0xffff),
ClientInitializer.buildWebSocketHandler(config),
new WebSocketCodec(inbound, config, inboundWriter(), outboundWriter())
);
inbound.closeFuture().addListener(future -> outbound.writeAndFlush(new CloseWebSocketFrame()));
}

record InboundWriter(Consumer<Channel> success, Consumer<Channel> failure) {}

class WebSocketCodec extends MessageToMessageCodec<BinaryWebSocketFrame, ByteBuf> {
private final Channel inbound;
private final ServerConfig config;
private final InboundWriter inboundWriter;
private final Consumer<Channel> outboundWriter;

public WebSocketCodec(Channel inbound, ServerConfig config, InboundWriter inboundWriter, Consumer<Channel> outboundWriter) {
this.inbound = inbound;
this.config = config;
this.inboundWriter = inboundWriter;
this.outboundWriter = outboundWriter;
}

@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) {
out.add(new BinaryWebSocketFrame(msg.retain()));
}

@Override
protected void decode(ChannelHandlerContext ctx, BinaryWebSocketFrame msg, List<Object> out) {
out.add(msg.retain().content());
}

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) {
inbound.pipeline().addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx2, Object msg) {
ctx.channel().writeAndFlush(msg);
}
}); // L → R
inboundWriter.success().accept(inbound);
outboundWriter.accept(ctx.channel());
}
if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_TIMEOUT) {
logger.error("Connect proxy websocket server {}:{} time out", config.getHost(), config.getPort());
inboundWriter.failure().accept(inbound);
ChannelCloseUtils.closeOnFlush(inbound);
}
ctx.fireUserEventTriggered(evt);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolConfig;
import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler;
import io.netty.handler.codec.socksx.SocksPortUnificationServerHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
Expand All @@ -25,21 +24,20 @@
import java.util.Map;
import java.util.Optional;

public class ClientSocksInitializer extends ChannelInitializer<NioSocketChannel> {
public class ClientInitializer extends ChannelInitializer<NioSocketChannel> {

private final ServerConfig config;

public ClientSocksInitializer(ServerConfig config) {
public ClientInitializer(ServerConfig config) {
this.config = config;
}

@Override
protected void initChannel(NioSocketChannel channel) {
channel.attr(AttributeKeys.SERVER_CONFIG).set(config);
channel.pipeline()
.addLast(config.getTrafficShapingHandler())
.addLast(new SocksPortUnificationServerHandler())
.addLast(ClientSocksMessageHandler.INSTANCE);
.addLast(config.getTrafficShapingHandler())
.addLast(new ClientProxyUnificationHandler());
}

public static SslHandler buildSslHandler(Channel ch, ServerConfig config) throws SSLException {
Expand Down Expand Up @@ -71,7 +69,7 @@ public static WebSocketClientProtocolHandler buildWebSocketHandler(ServerConfig
Optional<WebSocketSetting> ws = Optional.ofNullable(config.getWs());
String path = ws.map(WebSocketSetting::getPath).orElseThrow(() -> new IllegalArgumentException("required path not present"));
WebSocketClientProtocolConfig.Builder builder = WebSocketClientProtocolConfig.newBuilder()
.webSocketUri(new URI("ws", null, config.getHost(), config.getPort(), path, null, null));
.webSocketUri(new URI("ws", null, config.getHost(), config.getPort(), path, null, null));
ws.map(WebSocketSetting::getHeader).ifPresent(h -> {
HttpHeaders headers = new DefaultHttpHeaders();
for (Map.Entry<String, String> entry : h.entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.urbanspork.client;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.socksx.SocksPortUnificationServerHandler;
import io.netty.handler.codec.socksx.SocksVersion;

import java.util.List;

class ClientProxyUnificationHandler extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
byte versionVal = in.getByte(in.readerIndex());
SocksVersion version = SocksVersion.valueOf(versionVal);
ChannelPipeline p = ctx.pipeline();
if (version == SocksVersion.UNKNOWN) {
p.addLast(HttpPortUnificationHandler.INSTANCE);
} else {
p.addLast(new SocksPortUnificationServerHandler(), ClientSocksMessageHandler.INSTANCE);
}
p.remove(this);
}
}
Loading

0 comments on commit 17eb169

Please sign in to comment.