package com.faforever.client.relay;
import com.faforever.client.connectivity.ConnectivityService;
import com.faforever.client.connectivity.DatagramGateway;
import com.faforever.client.fx.PlatformService;
import com.faforever.client.game.GameService;
import com.faforever.client.game.GameType;
import com.faforever.client.i18n.I18n;
import com.faforever.client.map.MapService;
import com.faforever.client.net.GatewayUtil;
import com.faforever.client.notification.NotificationService;
import com.faforever.client.preferences.PreferencesService;
import com.faforever.client.relay.event.GameFullEvent;
import com.faforever.client.relay.event.RehostRequestEvent;
import com.faforever.client.remote.FafService;
import com.faforever.client.remote.domain.GameLaunchMessage;
import com.faforever.client.reporting.ReportingService;
import com.faforever.client.user.UserService;
import com.google.common.eventbus.EventBus;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.apache.commons.compress.utils.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.SocketUtils;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.invoke.MethodHandles;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.function.Consumer;
import static com.faforever.client.net.SocketUtil.readSocket;
import static com.github.nocatch.NoCatch.noCatch;
import static java.net.InetAddress.getLoopbackAddress;
import static java.nio.charset.StandardCharsets.US_ASCII;
/**
* <p>Acts as a proxy between the game and the "outside world" (server and peers). See <a
* href="https://github.com/micheljung/downlords-faf-client/wiki/Application-Design#connection-overview">the wiki
* page</a> for a graphical explanation.</p> <p>Being a proxy includes rewriting the sender/receiver of all outgoing and
* incoming packages. Apart from being necessary, this makes us IPv6 compatible.</p>
*/
public class LocalRelayServerImpl implements LocalRelayServer {
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
/**
* A collection of runnables to be executed whenever the game connected to this relay server.
*/
private final Collection<Runnable> onGameConnectedListeners;
/**
* Consumer for packages that come from "outside" and need to be forwarded to the game.
*/
private final Consumer<DatagramPacket> incomingPacketConsumer;
/**
* Maps socket addresses of peers (their public IP/port) to datagram sockets opened by this class. So every peer has
* its own proxy socket the game connects to.
*/
private final Map<SocketAddress, DatagramSocket> proxySocketsByOriginalAddress;
/**
* Maps player UIDs to socket addresses of peers (their public IP/port).
*/
private final Map<Integer, SocketAddress> originalAddressByUid;
private final BooleanProperty started;
@Resource
UserService userService;
@Resource
PreferencesService preferencesService;
@Resource
FafService fafService;
@Resource
ThreadPoolExecutor threadPoolExecutor;
@Resource
GameService gameService;
@Resource
NotificationService notificationService;
@Resource
I18n i18n;
@Resource
ReportingService reportingService;
@Resource
PlatformService platformService;
@Resource
MapService mapService;
@Resource
EventBus eventBus;
@Resource
ConnectivityService connectivityService;
private FaDataOutputStream gameOutputStream;
private FaDataInputStream gameInputStream;
private LobbyMode lobbyMode;
/**
* The server socket that acts as a proxy for the FAF server. The game sees this as a GPGNet server.
*/
private ServerSocket serverSocket;
/**
* The socket that is created as soon as the game connects to {@link #serverSocket}.
*/
private Socket gameSocket;
/**
* Future that is completed as soon as {@link #serverSocket} is up.
*/
private CompletableFuture<Integer> gpgPortFuture;
/**
* A consumer that forwards game packets to the "outside world".
*/
private DatagramGateway packetGateway;
/**
* The datagram socket address (IP/port) on which the game accepts packages.
*/
private CompletableFuture<InetSocketAddress> gameUdpSocketFuture;
/**
* The address of the computer's default gateway.
*/
private InetAddress defaultGatewayAddress;
public LocalRelayServerImpl() {
proxySocketsByOriginalAddress = new HashMap<>();
originalAddressByUid = new HashMap<>();
onGameConnectedListeners = new ArrayList<>();
lobbyMode = LobbyMode.DEFAULT_LOBBY;
incomingPacketConsumer = this::forwardPacket;
started = new SimpleBooleanProperty();
}
@Override
public void addOnGameConnectedListener(Runnable listener) {
onGameConnectedListeners.add(listener);
}
@Override
public Integer getPort() {
try {
return gpgPortFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
@Override
public CompletionStage<Integer> start(DatagramGateway gateway) {
synchronized (started) {
if (started.get()) {
logger.warn("Local relay server was already running, restarting");
close();
}
}
logger.debug("Starting relay server");
this.packetGateway = gateway;
this.defaultGatewayAddress = noCatch(GatewayUtil::findGateway);
gateway.addOnPacketListener(incomingPacketConsumer);
gameUdpSocketFuture = new CompletableFuture<>();
gpgPortFuture = new CompletableFuture<>();
threadPoolExecutor.execute(this::innerStart);
return gpgPortFuture;
}
@Override
public InetSocketAddress getGameSocketAddress() {
try {
return gameUdpSocketFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
@PreDestroy
public void close() {
synchronized (started) {
if (!started.get()) {
return;
}
logger.info("Closing relay server");
proxySocketsByOriginalAddress.values().forEach(IOUtils::closeQuietly);
proxySocketsByOriginalAddress.clear();
originalAddressByUid.clear();
if (packetGateway != null) {
packetGateway.removeOnPacketListener(incomingPacketConsumer);
}
IOUtils.closeQuietly(serverSocket);
IOUtils.closeQuietly(gameSocket);
started.set(false);
}
}
private void forwardPacket(DatagramPacket packet) {
// https://github.com/FAForever/downlords-faf-client/issues/369
if (defaultGatewayAddress != null && defaultGatewayAddress.equals(packet.getAddress())) {
packet.setAddress(connectivityService.getExternalSocketAddress().getAddress());
}
DatagramSocket relaySocket = createOrGetRelaySocket(packet.getSocketAddress());
noCatch(() -> {
if (logger.isTraceEnabled()) {
logger.trace("Forwarding {} bytes from peer '{}' through '{}': {}", packet.getLength(),
packet.getSocketAddress(), relaySocket.getLocalSocketAddress(),
new String(packet.getData(), 0, packet.getLength(), US_ASCII));
}
packet.setSocketAddress(getGameSocketAddress());
relaySocket.send(packet);
});
}
/**
* Opens a local UDP socket that serves as a proxy for a peer to FA. In other words, instead of connecting to peer X
* directly, a local port is opened which represents that peer. FA will then data to this port, thinking it's peer X.
* All data received on that port is then forwarded to the original peer and any data received on the public UDP
* socket is forwarded through its peer socket.
*
* @param originalSocketAddress the original address of the peer, to receive data from and send data to
* @return the UDP socket the peer has been bound to
*/
private DatagramSocket createOrGetRelaySocket(SocketAddress originalSocketAddress) {
if (!proxySocketsByOriginalAddress.containsKey(originalSocketAddress)) {
try {
DatagramSocket relaySocket = new DatagramSocket(new InetSocketAddress(getLoopbackAddress(), 0));
logger.debug("Mapping peer {} to relay socket {}", originalSocketAddress, relaySocket.getLocalSocketAddress());
proxySocketsByOriginalAddress.put(originalSocketAddress, relaySocket);
readSocket(threadPoolExecutor, relaySocket, packet -> {
packet.setSocketAddress(originalSocketAddress);
if (logger.isTraceEnabled()) {
logger.trace("Forwarding {} bytes from FA to peer {}: {}", packet.getLength(),
originalSocketAddress, new String(packet.getData(), 0, packet.getLength(), US_ASCII));
}
packetGateway.send(packet);
});
} catch (SocketException e) {
throw new RuntimeException(e);
}
}
return proxySocketsByOriginalAddress.get(originalSocketAddress);
}
private void innerStart() {
noCatch(() -> {
try (ServerSocket serverSocket = new ServerSocket(0, 0, getLoopbackAddress())) {
LocalRelayServerImpl.this.serverSocket = serverSocket;
int localPort = serverSocket.getLocalPort();
gpgPortFuture.complete(localPort);
logger.info("GPG relay server listening on port {}", localPort);
synchronized (started) {
started.set(true);
}
try (Socket faSocket = serverSocket.accept()) {
LocalRelayServerImpl.this.gameSocket = faSocket;
logger.debug("Forged Alliance connected to relay server from {}:{}", faSocket.getInetAddress(), faSocket.getPort());
onGameConnectedListeners.forEach(Runnable::run);
LocalRelayServerImpl.this.gameInputStream = createFaInputStream(faSocket.getInputStream());
LocalRelayServerImpl.this.gameOutputStream = createFaOutputStream(faSocket.getOutputStream());
redirectGpgConnection();
}
}
});
}
private FaDataInputStream createFaInputStream(InputStream inputStream) {
return new FaDataInputStream(inputStream);
}
private FaDataOutputStream createFaOutputStream(OutputStream outputStream) {
return new FaDataOutputStream(outputStream);
}
/**
* Starts a background task that reads data from FA and redirects it to the given ServerWriter.
*/
private void redirectGpgConnection() {
try {
//noinspection InfiniteLoopStatement
while (true) {
String message = gameInputStream.readString();
List<Object> chunks = gameInputStream.readChunks();
GpgClientMessage gpgClientMessage = new GpgClientMessage(message, chunks);
handleDataFromFa(gpgClientMessage);
}
} catch (IOException e) {
logger.info("Forged Alliance disconnected from local relay server (" + e.getMessage() + ")");
close();
}
}
private void handleDataFromFa(GpgClientMessage gpgClientMessage) throws IOException {
GpgClientCommand command = gpgClientMessage.getCommand();
if (isIdleLobbyMessage(gpgClientMessage)) {
String username = userService.getUsername();
if (lobbyMode == null) {
throw new IllegalStateException("lobbyMode has not been set");
}
int faGamePort = SocketUtils.findAvailableUdpPort();
logger.debug("Picked port for FA to listen: {}", faGamePort);
handleCreateLobby(new CreateLobbyServerMessage(lobbyMode, faGamePort, username, userService.getUid(), 1));
gameUdpSocketFuture.complete(new InetSocketAddress(getLoopbackAddress(), faGamePort));
} else if (command == GpgClientCommand.REHOST) {
eventBus.post(new RehostRequestEvent());
} else if (command == GpgClientCommand.JSON_STATS) {
logger.debug("Received game stats: {}", gpgClientMessage.getArgs().get(0));
} else if (command == GpgClientCommand.GAME_FULL) {
eventBus.post(new GameFullEvent(null));
return;
}
fafService.sendGpgMessage(gpgClientMessage);
}
/**
* Returns {@code true} if the game lobby is "idle", which basically means the game has been started (into lobby) and
* does now need to be told on which port to listen on.
*/
private boolean isIdleLobbyMessage(GpgClientMessage gpgClientMessage) {
return gpgClientMessage.getCommand() == GpgClientCommand.GAME_STATE
&& gpgClientMessage.getArgs().get(0).equals("Idle");
}
private void handleCreateLobby(CreateLobbyServerMessage createLobbyServerMessage) throws IOException {
writeToFa(createLobbyServerMessage);
}
private void writeToFa(GpgServerMessage gpgServerMessage) {
try {
writeFaProtocolHeader(gpgServerMessage);
gameOutputStream.writeArgs(gpgServerMessage.getArgs());
gameOutputStream.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void writeFaProtocolHeader(GpgServerMessage gpgServerMessage) throws IOException {
String commandString = gpgServerMessage.getMessageType().getString();
int headerSize = commandString.length();
String headerField = commandString.replace("\t", "/t").replace("\n", "/n");
logger.debug("Writing data to FA, command: {}, args: {}", commandString, gpgServerMessage.getArgs());
gameOutputStream.writeInt(headerSize);
gameOutputStream.writeString(headerField);
}
@PostConstruct
void postConstruct() {
fafService.addOnMessageListener(GameLaunchMessage.class, this::updateLobbyModeFromGameInfo);
fafService.addOnMessageListener(HostGameMessage.class, this::handleHostGame);
fafService.addOnMessageListener(JoinGameMessage.class, this::handleJoinGame);
fafService.addOnMessageListener(ConnectToPeerMessage.class, this::handleConnectToPeer);
fafService.addOnMessageListener(DisconnectFromPeerMessage.class, this::handleDisconnectFromPeer);
}
private void updateLobbyModeFromGameInfo(GameLaunchMessage gameLaunchMessage) {
if (GameType.LADDER_1V1.getString().equals(gameLaunchMessage.getMod())) {
lobbyMode = LobbyMode.NO_LOBBY;
} else {
lobbyMode = LobbyMode.DEFAULT_LOBBY;
}
}
private void handleDisconnectFromPeer(DisconnectFromPeerMessage disconnectFromPeerMessage) {
SocketAddress originalAddress = originalAddressByUid.remove(disconnectFromPeerMessage.getUid());
DatagramSocket proxySocket = proxySocketsByOriginalAddress.remove(originalAddress);
IOUtils.closeQuietly(proxySocket);
writeToFa(disconnectFromPeerMessage);
}
private void handleHostGame(HostGameMessage hostGameMessage) {
writeToFa(hostGameMessage);
}
private void handleConnectToPeer(ConnectToPeerMessage connectToPeerMessage) {
InetSocketAddress peerAddress = connectToPeerMessage.getPeerAddress();
ConnectToPeerMessage clone = connectToPeerMessage.clone();
clone.setPeerAddress((InetSocketAddress) createOrGetRelaySocket(peerAddress).getLocalSocketAddress());
writeToFa(clone);
}
private void handleJoinGame(JoinGameMessage joinGameMessage) {
InetSocketAddress originalAddress = joinGameMessage.getPeerAddress();
originalAddressByUid.put(joinGameMessage.getPeerUid(), originalAddress);
JoinGameMessage clone = joinGameMessage.clone();
clone.setPeerAddress((InetSocketAddress) createOrGetRelaySocket(originalAddress).getLocalSocketAddress());
writeToFa(clone);
}
}