diff --git a/.gitignore b/.gitignore index 00b45a827..fd8f6bff5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf +.idea/**/compiler.xml # Sensitive or high-churn files .idea/**/dataSources/ @@ -85,6 +86,15 @@ modules.xml # BlueJ files *.ctxt +# Eclipse # +**/.classpath +**/.project +**/.settings/ +**/bin/ + +# NetBeans Gradle# +.nb-gradle/ + # Mobile Tools for Java (J2ME) .mtj.tmp/ @@ -102,7 +112,7 @@ hs_err_pid* ### Gradle ### .gradle -/build/ +build/ # Ignore Gradle GUI config gradle-app.setting @@ -121,4 +131,7 @@ gradle-app.setting # Other trash logs/ -velocity.toml \ No newline at end of file +/velocity.toml +server-icon.png +/bin/ +run/ diff --git a/Jenkinsfile b/Jenkinsfile index 638e40dfe..d7d78b2ec 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,8 +1,8 @@ pipeline { agent { docker { - image 'openjdk:8-jdk-slim' - args '-v gradle-cache:/root/.gradle:rw' + image 'velocitypowered/openjdk8-plus-git:slim' + args '-v gradle-cache:/root/.gradle:rw -v maven-repo:/maven-repo:rw -v javadoc:/javadoc' } } @@ -18,5 +18,15 @@ pipeline { sh './gradlew test' } } + stage('Deploy Artifacts') { + steps { + sh 'export MAVEN_DEPLOYMENT=true; ./gradlew publish' + } + } + stage('Deploy Javadoc') { + steps { + sh 'rsync -av --delete ./api/build/docs/javadoc/ /javadoc' + } + } } } \ No newline at end of file diff --git a/README.md b/README.md index e93376abf..b7348b485 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Velocity [![Build Status](https://img.shields.io/jenkins/s/https/ci.velocitypowered.com/job/velocity/job/master.svg)](https://ci.velocitypowered.com/job/velocity/job/master/) +[![Join our Discord](https://img.shields.io/discord/472484458856185878.svg?logo=discord&label=)](https://discord.gg/8cB9Bgf) Velocity is a next-generation Minecraft: Java Edition proxy suite. It is designed specifically with mass-scale Minecraft in mind. @@ -22,10 +23,17 @@ wrapper script (`./gradlew`) as our CI builds using it. It is sufficient to run `./gradlew build` to run the full build cycle. +## Running + +Once you've built Velocity, you can copy and run the `-all` JAR from +`proxy/build/libs`. Velocity will generate a default configuration file +and you can configure it from there. + +Alternatively, you can get the proxy JAR from the [downloads](https://www.velocitypowered.com/downloads) +page. + ## Status -Velocity is far from finished, but most of the essential pieces are in place: -you can switch between two servers running Minecraft 1.9-1.13. More versions -and functionality is planned. - -You should join us on **irc.spi.gt** `#velocity` or send us a pull request. +Velocity is far from finished, but most of the essential pieces you would +expect are in place. Velocity supports Minecraft 1.8-1.13. More functionality +is planned. diff --git a/api/build.gradle b/api/build.gradle index 8021a51dd..1c7d350a9 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,13 +1,24 @@ plugins { id 'java' id 'com.github.johnrengelman.shadow' version '2.0.4' + id 'maven-publish' +} + +sourceSets { + ap { + compileClasspath += main.compileClasspath + main.output + } } dependencies { compile 'com.google.code.gson:gson:2.8.5' compile "com.google.guava:guava:${guavaVersion}" - compile 'net.kyori:text:1.12-1.6.0-SNAPSHOT' + compile 'net.kyori:text:1.12-1.6.4' compile 'com.moandjiezana.toml:toml4j:0.7.2' + compile "org.slf4j:slf4j-api:${slf4jVersion}" + compile 'com.google.inject:guice:4.2.0' + compile 'org.checkerframework:checker-qual:2.5.4' + testCompile "org.junit.jupiter:junit-jupiter-api:${junitVersion}" testCompile "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" } @@ -20,10 +31,55 @@ task javadocJar(type: Jar) { task sourcesJar(type: Jar) { classifier 'sources' from sourceSets.main.allSource + from sourceSets.ap.output +} + +jar { + from sourceSets.ap.output +} + +shadowJar { + from sourceSets.ap.output } artifacts { archives javadocJar archives shadowJar archives sourcesJar +} + +javadoc { + options.encoding = 'UTF-8' + options.charSet = 'UTF-8' + options.links( + 'http://www.slf4j.org/apidocs/', + 'https://google.github.io/guava/releases/25.1-jre/api/docs/', + 'https://google.github.io/guice/api-docs/4.2/javadoc/', + 'https://jd.kyori.net/text/1.12-1.6.4/', + 'https://docs.oracle.com/javase/8/docs/api/' + ) + + // Disable the crazy super-strict doclint tool in Java 8 + options.addStringOption('Xdoclint:none', '-quiet') +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + } + } + + repositories { + maven { + name = 'myRepo' + def base = project.ext.getCurrentBranchName() == "master" ? 'file:///maven-repo' : File.createTempDir().toURI().toURL().toString() + def releasesRepoUrl = "$base/releases" + def snapshotsRepoUrl = "$base/snapshots" + url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + } + } } \ No newline at end of file diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java new file mode 100644 index 000000000..a00f9c7d3 --- /dev/null +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java @@ -0,0 +1,87 @@ +package com.velocitypowered.api.plugin.ap; + +import com.google.gson.Gson; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.PluginDescription; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.Name; +import javax.lang.model.element.TypeElement; +import javax.tools.Diagnostic; +import javax.tools.FileObject; +import javax.tools.StandardLocation; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.*; + +@SupportedAnnotationTypes({"com.velocitypowered.api.plugin.Plugin"}) +public class PluginAnnotationProcessor extends AbstractProcessor { + private ProcessingEnvironment environment; + private String pluginClassFound; + private boolean warnedAboutMultiplePlugins; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + this.environment = processingEnv; + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (roundEnv.processingOver()) { + return false; + } + + for (Element element : roundEnv.getElementsAnnotatedWith(Plugin.class)) { + if (element.getKind() != ElementKind.CLASS) { + environment.getMessager().printMessage(Diagnostic.Kind.ERROR, "Only classes can be annotated with " + + Plugin.class.getCanonicalName()); + return false; + } + + Name qualifiedName = ((TypeElement) element).getQualifiedName(); + + if (Objects.equals(pluginClassFound, qualifiedName.toString())) { + if (!warnedAboutMultiplePlugins) { + environment.getMessager().printMessage(Diagnostic.Kind.WARNING, "Velocity does not yet currently support " + + "multiple plugins. We are using " + pluginClassFound + " for your plugin's main class."); + warnedAboutMultiplePlugins = true; + } + return false; + } + + Plugin plugin = element.getAnnotation(Plugin.class); + if (!PluginDescription.ID_PATTERN.matcher(plugin.id()).matches()) { + environment.getMessager().printMessage(Diagnostic.Kind.ERROR, "Invalid ID for plugin " + + qualifiedName + ". IDs must start alphabetically, have alphanumeric characters, and can " + + "contain dashes or underscores."); + return false; + } + + // All good, generate the velocity-plugin.json. + SerializedPluginDescription description = SerializedPluginDescription.from(plugin, qualifiedName.toString()); + try { + FileObject object = environment.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", "velocity-plugin.json"); + try (Writer writer = new BufferedWriter(object.openWriter())) { + new Gson().toJson(description, writer); + } + pluginClassFound = qualifiedName.toString(); + } catch (IOException e) { + environment.getMessager().printMessage(Diagnostic.Kind.ERROR, "Unable to generate plugin file"); + } + } + + return false; + } +} diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java new file mode 100644 index 000000000..925074cc2 --- /dev/null +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java @@ -0,0 +1,151 @@ +package com.velocitypowered.api.plugin.ap; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.velocitypowered.api.plugin.Plugin; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class SerializedPluginDescription { + // @Nullable is used here to make GSON skip these in the serialized file + private final String id; + private final @Nullable String name; + private final @Nullable String version; + private final @Nullable String description; + private final @Nullable String url; + private final @Nullable List authors; + private final @Nullable List dependencies; + private final String main; + + public SerializedPluginDescription(String id, String name, String version, String description, String url, + List authors, List dependencies, String main) { + this.id = Preconditions.checkNotNull(id, "id"); + this.name = Strings.emptyToNull(name); + this.version = Strings.emptyToNull(version); + this.description = Strings.emptyToNull(description); + this.url = Strings.emptyToNull(url); + this.authors = authors == null || authors.isEmpty() ? null : authors; + this.dependencies = dependencies == null || dependencies.isEmpty() ? null : dependencies; + this.main = Preconditions.checkNotNull(main, "main"); + } + + public static SerializedPluginDescription from(Plugin plugin, String qualifiedName) { + List dependencies = new ArrayList<>(); + for (com.velocitypowered.api.plugin.Dependency dependency : plugin.dependencies()) { + dependencies.add(new Dependency(dependency.id(), dependency.optional())); + } + return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(), plugin.description(), plugin.url(), + Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty()).collect(Collectors.toList()), dependencies, qualifiedName); + } + + public String getId() { + return id; + } + + public @Nullable String getName() { + return name; + } + + public @Nullable String getVersion() { + return version; + } + + public @Nullable String getDescription() { + return description; + } + + public @Nullable String getUrl() { + return url; + } + + public @Nullable List getAuthors() { + return authors; + } + + public @Nullable List getDependencies() { + return dependencies; + } + + public String getMain() { + return main; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SerializedPluginDescription that = (SerializedPluginDescription) o; + return Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(version, that.version) && + Objects.equals(description, that.description) && + Objects.equals(url, that.url) && + Objects.equals(authors, that.authors) && + Objects.equals(dependencies, that.dependencies) && + Objects.equals(main, that.main); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, version, description, url, authors, dependencies); + } + + @Override + public String toString() { + return "SerializedPluginDescription{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", version='" + version + '\'' + + ", description='" + description + '\'' + + ", url='" + url + '\'' + + ", authors=" + authors + + ", dependencies=" + dependencies + + ", main='" + main + '\'' + + '}'; + } + + public static class Dependency { + private final String id; + private final boolean optional; + + public Dependency(String id, boolean optional) { + this.id = id; + this.optional = optional; + } + + public String getId() { + return id; + } + + public boolean isOptional() { + return optional; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Dependency that = (Dependency) o; + return optional == that.optional && + Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, optional); + } + + @Override + public String toString() { + return "Dependency{" + + "id='" + id + '\'' + + ", optional=" + optional + + '}'; + } + } +} diff --git a/api/src/ap/resources/META-INF/services/javax.annotation.processing.Processor b/api/src/ap/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 000000000..a96abf5d2 --- /dev/null +++ b/api/src/ap/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +com.velocitypowered.api.plugin.ap.PluginAnnotationProcessor \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/command/Command.java b/api/src/main/java/com/velocitypowered/api/command/Command.java new file mode 100644 index 000000000..d1cfd9f49 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/Command.java @@ -0,0 +1,29 @@ +package com.velocitypowered.api.command; + +import com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.util.List; + +/** + * Represents a command that can be executed by a {@link CommandSource}, such as a {@link com.velocitypowered.api.proxy.Player} + * or the console. + */ +public interface Command { + /** + * Executes the command for the specified {@link CommandSource}. + * @param source the source of this command + * @param args the arguments for this command + */ + void execute(@NonNull CommandSource source, @NonNull String[] args); + + /** + * Provides tab complete suggestions for a command for a specified {@link CommandSource}. + * @param source the source to run the command for + * @param currentArgs the current, partial arguments for this command + * @return tab complete suggestions + */ + default List suggest(@NonNull CommandSource source, @NonNull String[] currentArgs) { + return ImmutableList.of(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandManager.java b/api/src/main/java/com/velocitypowered/api/command/CommandManager.java new file mode 100644 index 000000000..040c3d295 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/CommandManager.java @@ -0,0 +1,29 @@ +package com.velocitypowered.api.command; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents an interface to register a command executor with the proxy. + */ +public interface CommandManager { + /** + * Registers the specified command with the manager with the specified aliases. + * @param command the command to register + * @param aliases the alias to use + */ + void register(@NonNull Command command, String... aliases); + + /** + * Unregisters a command. + * @param alias the command alias to unregister + */ + void unregister(@NonNull String alias); + + /** + * Attempts to execute a command from the specified {@code cmdLine}. + * @param source the command's source + * @param cmdLine the command to run + * @return true if the command was found and executed, false if it was not + */ + boolean execute(@NonNull CommandSource source, @NonNull String cmdLine); +} diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandSource.java b/api/src/main/java/com/velocitypowered/api/command/CommandSource.java new file mode 100644 index 000000000..70c11318e --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/CommandSource.java @@ -0,0 +1,16 @@ +package com.velocitypowered.api.command; + +import com.velocitypowered.api.permission.PermissionSubject; +import net.kyori.text.Component; +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents something that can be used to run a {@link Command}. + */ +public interface CommandSource extends PermissionSubject { + /** + * Sends the specified {@code component} to the invoker. + * @param component the text component to send + */ + void sendMessage(@NonNull Component component); +} diff --git a/api/src/main/java/com/velocitypowered/api/command/package-info.java b/api/src/main/java/com/velocitypowered/api/command/package-info.java new file mode 100644 index 000000000..1ce9d9c30 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides a simple command framework. + */ +package com.velocitypowered.api.command; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/EventHandler.java b/api/src/main/java/com/velocitypowered/api/event/EventHandler.java new file mode 100644 index 000000000..146239cf7 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/EventHandler.java @@ -0,0 +1,12 @@ +package com.velocitypowered.api.event; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents an interface to perform direct dispatch of an event. This makes integration easier to achieve with platforms + * such as RxJava. + */ +@FunctionalInterface +public interface EventHandler { + void execute(@NonNull E event); +} diff --git a/api/src/main/java/com/velocitypowered/api/event/EventManager.java b/api/src/main/java/com/velocitypowered/api/event/EventManager.java new file mode 100644 index 000000000..28b197e55 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/EventManager.java @@ -0,0 +1,75 @@ +package com.velocitypowered.api.event; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Allows plugins to register and deregister listeners for event handlers. + */ +public interface EventManager { + /** + * Requests that the specified {@code listener} listen for events and associate it with the {@code plugin}. + * @param plugin the plugin to associate with the listener + * @param listener the listener to register + */ + void register(@NonNull Object plugin, @NonNull Object listener); + + /** + * Requests that the specified {@code handler} listen for events and associate it with the {@code plugin}. + * @param plugin the plugin to associate with the handler + * @param eventClass the class for the event handler to register + * @param handler the handler to register + * @param the event type to handle + */ + default void register(@NonNull Object plugin, @NonNull Class eventClass, @NonNull EventHandler handler) { + register(plugin, eventClass, PostOrder.NORMAL, handler); + } + + /** + * Requests that the specified {@code handler} listen for events and associate it with the {@code plugin}. + * @param plugin the plugin to associate with the handler + * @param eventClass the class for the event handler to register + * @param postOrder the order in which events should be posted to the handler + * @param handler the handler to register + * @param the event type to handle + */ + void register(@NonNull Object plugin, @NonNull Class eventClass, @NonNull PostOrder postOrder, @NonNull EventHandler handler); + + /** + * Fires the specified event to the event bus asynchronously. This allows Velocity to continue servicing connections + * while a plugin handles a potentially long-running operation such as a database query. + * @param event the event to fire + * @return a {@link CompletableFuture} representing the posted event + */ + @NonNull CompletableFuture fire(@NonNull E event); + + /** + * Posts the specified event to the event bus and discards the result. + * @param event the event to fire + */ + default void fireAndForget(@NonNull Object event) { + fire(event); + } + + /** + * Unregisters all listeners for the specified {@code plugin}. + * @param plugin the plugin to deregister listeners for + */ + void unregisterListeners(@NonNull Object plugin); + + /** + * Unregisters a specific listener for a specific plugin. + * @param plugin the plugin associated with the listener + * @param listener the listener to deregister + */ + void unregisterListener(@NonNull Object plugin, @NonNull Object listener); + + /** + * Unregisters a specific event handler for a specific plugin. + * @param plugin the plugin to associate with the handler + * @param handler the handler to register + * @param the event type to handle + */ + void unregister(@NonNull Object plugin, @NonNull EventHandler handler); +} diff --git a/api/src/main/java/com/velocitypowered/api/event/PostOrder.java b/api/src/main/java/com/velocitypowered/api/event/PostOrder.java new file mode 100644 index 000000000..a9bffe331 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/PostOrder.java @@ -0,0 +1,11 @@ +package com.velocitypowered.api.event; + +/** + * Represents the order an event will be posted to a listener method, relative + * to other listeners. + */ +public enum PostOrder { + + FIRST, EARLY, NORMAL, LATE, LAST; + +} diff --git a/api/src/main/java/com/velocitypowered/api/event/ResultedEvent.java b/api/src/main/java/com/velocitypowered/api/event/ResultedEvent.java new file mode 100644 index 000000000..cf7330180 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/ResultedEvent.java @@ -0,0 +1,114 @@ +package com.velocitypowered.api.event; + +import com.google.common.base.Preconditions; +import net.kyori.text.Component; +import net.kyori.text.serializer.ComponentSerializers; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.Optional; + +/** + * Indicates an event that has a result attached to it. + */ +public interface ResultedEvent { + /** + * Returns the result associated with this event. + * @return the result of this event + */ + R getResult(); + + /** + * Sets the result of this event. The result must be non-null. + * @param result the new result + */ + void setResult(@NonNull R result); + + /** + * Represents a result for an event. + */ + interface Result { + /** + * Returns whether or not the event is allowed to proceed. Plugins may choose to skip denied events, and the + * proxy will respect the result of this method. + * @return whether or not the event is allowed to proceed + */ + boolean isAllowed(); + } + + /** + * A generic "allowed/denied" result. + */ + class GenericResult implements Result { + private static final GenericResult ALLOWED = new GenericResult(true); + private static final GenericResult DENIED = new GenericResult(true); + + private final boolean allowed; + + private GenericResult(boolean b) { + this.allowed = b; + } + + @Override + public boolean isAllowed() { + return allowed; + } + + @Override + public String toString() { + return allowed ? "allowed" : "denied"; + } + + public static GenericResult allowed() { + return ALLOWED; + } + + public static GenericResult denied() { + return DENIED; + } + } + + /** + * Represents an "allowed/denied" result with a reason allowed for denial. + */ + class ComponentResult implements Result { + private static final ComponentResult ALLOWED = new ComponentResult(true, null); + + private final boolean allowed; + private final @Nullable Component reason; + + protected ComponentResult(boolean allowed, @Nullable Component reason) { + this.allowed = allowed; + this.reason = reason; + } + + @Override + public boolean isAllowed() { + return allowed; + } + + public Optional getReason() { + return Optional.ofNullable(reason); + } + + @Override + public String toString() { + if (allowed) { + return "allowed"; + } + if (reason != null) { + return "denied: " + ComponentSerializers.PLAIN.serialize(reason); + } + return "denied"; + } + + public static ComponentResult allowed() { + return ALLOWED; + } + + public static ComponentResult denied(@NonNull Component reason) { + Preconditions.checkNotNull(reason, "reason"); + return new ComponentResult(false, reason); + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/Subscribe.java b/api/src/main/java/com/velocitypowered/api/event/Subscribe.java new file mode 100644 index 000000000..adb45b1ca --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/Subscribe.java @@ -0,0 +1,22 @@ +package com.velocitypowered.api.event; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation that indicates that this method can be used to listen for an event from the proxy. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Subscribe { + + /** + * The order events will be posted to this listener. + * + * @return the order + */ + PostOrder order() default PostOrder.NORMAL; + +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/ConnectionHandshakeEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/ConnectionHandshakeEvent.java new file mode 100644 index 000000000..f2ecc0547 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/ConnectionHandshakeEvent.java @@ -0,0 +1,27 @@ +package com.velocitypowered.api.event.connection; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.InboundConnection; +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * This event is fired when a handshake is established between a client and Velocity. + */ +public class ConnectionHandshakeEvent { + private final @NonNull InboundConnection connection; + + public ConnectionHandshakeEvent(@NonNull InboundConnection connection) { + this.connection = Preconditions.checkNotNull(connection, "connection"); + } + + public InboundConnection getConnection() { + return connection; + } + + @Override + public String toString() { + return "ConnectionHandshakeEvent{" + + "connection=" + connection + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/DisconnectEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/DisconnectEvent.java new file mode 100644 index 000000000..0ef43909b --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/DisconnectEvent.java @@ -0,0 +1,28 @@ +package com.velocitypowered.api.event.connection; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.Player; +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * This event is fired when a player disconnects from the proxy. Operations on the provided player, aside from basic + * data retrieval operations, may behave in undefined ways. + */ +public class DisconnectEvent { + private @NonNull final Player player; + + public DisconnectEvent(@NonNull Player player) { + this.player = Preconditions.checkNotNull(player, "player"); + } + + public Player getPlayer() { + return player; + } + + @Override + public String toString() { + return "DisconnectEvent{" + + "player=" + player + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/LoginEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/LoginEvent.java new file mode 100644 index 000000000..0ac7f9374 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/LoginEvent.java @@ -0,0 +1,41 @@ +package com.velocitypowered.api.event.connection; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.event.ResultedEvent; +import com.velocitypowered.api.proxy.Player; +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * This event is fired once the player has been authenticated but before they connect to a server on the proxy. + */ +public class LoginEvent implements ResultedEvent { + private final Player player; + private ComponentResult result; + + public LoginEvent(@NonNull Player player) { + this.player = Preconditions.checkNotNull(player, "player"); + this.result = ComponentResult.allowed(); + } + + public Player getPlayer() { + return player; + } + + @Override + public ComponentResult getResult() { + return result; + } + + @Override + public void setResult(@NonNull ComponentResult result) { + this.result = Preconditions.checkNotNull(result, "result"); + } + + @Override + public String toString() { + return "LoginEvent{" + + "player=" + player + + ", result=" + result + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java new file mode 100644 index 000000000..ef8edfb9f --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java @@ -0,0 +1,119 @@ +package com.velocitypowered.api.event.connection; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.event.ResultedEvent; +import com.velocitypowered.api.proxy.InboundConnection; + +import net.kyori.text.Component; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * This event is fired when a player has initiated a connection with the proxy but before the proxy authenticates the + * player with Mojang or before the player's proxy connection is fully established (for offline mode). + */ +public class PreLoginEvent implements ResultedEvent { + private final InboundConnection connection; + private final String username; + private PreLoginComponentResult result; + + public PreLoginEvent(InboundConnection connection, String username) { + this.connection = Preconditions.checkNotNull(connection, "connection"); + this.username = Preconditions.checkNotNull(username, "username"); + this.result = PreLoginComponentResult.allowed(); + } + + public InboundConnection getConnection() { + return connection; + } + + public String getUsername() { + return username; + } + + @Override + public PreLoginComponentResult getResult() { + return result; + } + + @Override + public void setResult(@NonNull PreLoginComponentResult result) { + this.result = Preconditions.checkNotNull(result, "result"); + } + + @Override + public String toString() { + return "PreLoginEvent{" + + "connection=" + connection + + ", username='" + username + '\'' + + ", result=" + result + + '}'; + } + + /** + * Represents an "allowed/allowed with online mode/denied" result with a reason allowed for denial. + */ + public static class PreLoginComponentResult extends ResultedEvent.ComponentResult { + private static final PreLoginComponentResult ALLOWED = new PreLoginComponentResult((Component) null); + private static final PreLoginComponentResult FORCE_ONLINEMODE = new PreLoginComponentResult(true); + + private final boolean onlineMode; + + /** + * Allows online mode to be enabled for the player connection, if Velocity is running in offline mode. + * @param allowedOnlineMode if true, online mode will be used for the connection + */ + private PreLoginComponentResult(boolean allowedOnlineMode) { + super(true, null); + this.onlineMode = allowedOnlineMode; + } + + private PreLoginComponentResult(@Nullable Component reason) { + super(reason == null, reason); + // Don't care about this + this.onlineMode = false; + } + + public boolean isOnlineModeAllowed() { + return this.onlineMode; + } + + @Override + public String toString() { + if (isOnlineModeAllowed()) { + return "allowed with online mode"; + } + + return super.toString(); + } + + /** + * Returns a result indicating the connection will be allowed through the proxy. + * @return the allowed result + */ + public static PreLoginComponentResult allowed() { + return ALLOWED; + } + + /** + * Returns a result indicating the connection will be allowed through the proxy, but the connection will be + * forced to use online mode provided that the proxy is in offline mode. This acts similarly to {@link #allowed()} + * on an online-mode proxy. + * @return the result + */ + public static PreLoginComponentResult forceOnlineMode() { + return FORCE_ONLINEMODE; + } + + /** + * Denies the login with the specified reason. + * @param reason the reason for disallowing the connection + * @return a new result + */ + public static PreLoginComponentResult denied(@NonNull Component reason) { + Preconditions.checkNotNull(reason, "reason"); + return new PreLoginComponentResult(reason); + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/package-info.java b/api/src/main/java/com/velocitypowered/api/event/connection/package-info.java new file mode 100644 index 000000000..44e41f8d5 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides events for handling incoming connections to the proxy and loigns. + */ +package com.velocitypowered.api.event.connection; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/package-info.java b/api/src/main/java/com/velocitypowered/api/event/package-info.java new file mode 100644 index 000000000..34819f6ba --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides core support for handling events with Velocity. Subpackages include event classes. + */ +package com.velocitypowered.api.event; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/permission/PermissionsSetupEvent.java b/api/src/main/java/com/velocitypowered/api/event/permission/PermissionsSetupEvent.java new file mode 100644 index 000000000..de3976025 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/permission/PermissionsSetupEvent.java @@ -0,0 +1,64 @@ +package com.velocitypowered.api.event.permission; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.permission.PermissionFunction; +import com.velocitypowered.api.permission.PermissionProvider; +import com.velocitypowered.api.permission.PermissionSubject; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Called when a {@link PermissionSubject}'s permissions are being setup. + * + *

This event is only called once per subject, on initialisation.

+ */ +public class PermissionsSetupEvent { + private final PermissionSubject subject; + private final PermissionProvider defaultProvider; + private PermissionProvider provider; + + public PermissionsSetupEvent(PermissionSubject subject, PermissionProvider provider) { + this.subject = Preconditions.checkNotNull(subject, "subject"); + this.provider = this.defaultProvider = Preconditions.checkNotNull(provider, "provider"); + } + + public @NonNull PermissionSubject getSubject() { + return this.subject; + } + + /** + * Uses the provider function to obtain a {@link PermissionFunction} for + * the subject. + * + * @param subject the subject + * @return the obtained permission function + */ + public @NonNull PermissionFunction createFunction(PermissionSubject subject) { + return this.provider.createFunction(subject); + } + + public @NonNull PermissionProvider getProvider() { + return this.provider; + } + + /** + * Sets the {@link PermissionFunction} that should be used for the subject. + * + *

Specifying null will reset the provider to the default + * instance given when the event was posted.

+ * + * @param provider the provider + */ + public void setProvider(@Nullable PermissionProvider provider) { + this.provider = provider == null ? this.defaultProvider : provider; + } + + @Override + public String toString() { + return "PermissionsSetupEvent{" + + "subject=" + subject + + ", defaultProvider=" + defaultProvider + + ", provider=" + provider + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/permission/package-info.java b/api/src/main/java/com/velocitypowered/api/event/permission/package-info.java new file mode 100644 index 000000000..402d8db84 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/permission/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides events to handle setting up permissions for permission subjects. + */ +package com.velocitypowered.api.event.permission; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/player/GameProfileRequestEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/GameProfileRequestEvent.java new file mode 100644 index 000000000..2a02d0a18 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/GameProfileRequestEvent.java @@ -0,0 +1,71 @@ +package com.velocitypowered.api.event.player; + +import com.velocitypowered.api.proxy.InboundConnection; +import org.checkerframework.checker.nullness.qual.Nullable; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.util.GameProfile; + +/** + * This event is fired after the {@link com.velocitypowered.api.event.connection.PreLoginEvent} in order to set up the + * game profile for the user. This can be used to configure a custom profile for a user, i.e. skin replacement. + */ +public class GameProfileRequestEvent { + private final String username; + private final InboundConnection connection; + private final GameProfile originalProfile; + private final boolean onlineMode; + private GameProfile gameProfile; + + public GameProfileRequestEvent(InboundConnection connection, GameProfile originalProfile, boolean onlineMode) { + this.connection = Preconditions.checkNotNull(connection, "connection"); + this.originalProfile = Preconditions.checkNotNull(originalProfile, "originalProfile"); + this.username = originalProfile.getName(); + this.onlineMode = onlineMode; + } + + public InboundConnection getConnection() { + return connection; + } + + public String getUsername() { + return username; + } + + public GameProfile getOriginalProfile() { + return originalProfile; + } + + public boolean isOnlineMode() { + return onlineMode; + } + + /** + * Returns the game profile that will be used to initialize the connection with. Should no profile be currently + * specified, the one generated by the proxy (for offline mode) or retrieved from the Mojang session servers (for + * online mode) will be returned instead. + * @return the user's {@link GameProfile} + */ + public GameProfile getGameProfile() { + return gameProfile == null ? originalProfile : gameProfile; + } + + /** + * Sets the game profile to use for this connection. It is invalid to use this method on an online-mode connection. + * @param gameProfile the profile to use for the connection, {@code null} uses the original profile + */ + public void setGameProfile(@Nullable GameProfile gameProfile) { + Preconditions.checkState(!onlineMode, "Connection is in online mode, profiles can not be faked"); + this.gameProfile = gameProfile; + } + + @Override + public String toString() { + return "GameProfileRequestEvent{"+ + "username=" + username + + ", gameProfile=" + gameProfile + + "}"; + } + + +} diff --git a/api/src/main/java/com/velocitypowered/api/event/player/ServerConnectedEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/ServerConnectedEvent.java new file mode 100644 index 000000000..726ceb2e7 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/ServerConnectedEvent.java @@ -0,0 +1,35 @@ +package com.velocitypowered.api.event.player; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.ServerInfo; + +/** + * This event is fired once the player has successfully connected to the target server and the connection to the previous + * server has been de-established. + */ +public class ServerConnectedEvent { + private final Player player; + private final ServerInfo server; + + public ServerConnectedEvent(Player player, ServerInfo server) { + this.player = Preconditions.checkNotNull(player, "player"); + this.server = Preconditions.checkNotNull(server, "server"); + } + + public Player getPlayer() { + return player; + } + + public ServerInfo getServer() { + return server; + } + + @Override + public String toString() { + return "ServerConnectedEvent{" + + "player=" + player + + ", server=" + server + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java new file mode 100644 index 000000000..2d86c06f8 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java @@ -0,0 +1,86 @@ +package com.velocitypowered.api.event.player; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.event.ResultedEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.ServerInfo; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.Optional; + +/** + * This event is fired before the player connects to a server. + */ +public class ServerPreConnectEvent implements ResultedEvent { + private final Player player; + private ServerResult result; + + public ServerPreConnectEvent(Player player, ServerResult result) { + this.player = Preconditions.checkNotNull(player, "player"); + this.result = Preconditions.checkNotNull(result, "result"); + } + + public Player getPlayer() { + return player; + } + + @Override + public ServerResult getResult() { + return result; + } + + @Override + public void setResult(@NonNull ServerResult result) { + this.result = Preconditions.checkNotNull(result, "result"); + } + + @Override + public String toString() { + return "ServerPreConnectEvent{" + + "player=" + player + + ", result=" + result + + '}'; + } + + /** + * Represents the result of the {@link ServerPreConnectEvent}. + */ + public static class ServerResult implements ResultedEvent.Result { + private static final ServerResult DENIED = new ServerResult(false, null); + + private final boolean allowed; + private final ServerInfo info; + + private ServerResult(boolean allowed, @Nullable ServerInfo info) { + this.allowed = allowed; + this.info = info; + } + + @Override + public boolean isAllowed() { + return allowed; + } + + public Optional getInfo() { + return Optional.ofNullable(info); + } + + @Override + public String toString() { + if (!allowed) { + return "denied"; + } + return "allowed: connect to " + info.getName(); + } + + public static ServerResult denied() { + return DENIED; + } + + public static ServerResult allowed(ServerInfo server) { + Preconditions.checkNotNull(server, "server"); + return new ServerResult(true, server); + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/player/package-info.java b/api/src/main/java/com/velocitypowered/api/event/player/package-info.java new file mode 100644 index 000000000..1d0513b60 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides events for handling actions performed by players. + */ +package com.velocitypowered.api.event.player; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyInitializeEvent.java b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyInitializeEvent.java new file mode 100644 index 000000000..14bee8b1e --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyInitializeEvent.java @@ -0,0 +1,11 @@ +package com.velocitypowered.api.event.proxy; + +/** + * This event is fired by the proxy after plugins have been loaded but before the proxy starts accepting connections. + */ +public class ProxyInitializeEvent { + @Override + public String toString() { + return "ProxyInitializeEvent"; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyPingEvent.java b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyPingEvent.java new file mode 100644 index 000000000..e40d810b9 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyPingEvent.java @@ -0,0 +1,38 @@ +package com.velocitypowered.api.event.proxy; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.InboundConnection; +import com.velocitypowered.api.proxy.server.ServerPing; + +/** + * This event is fired when a server list ping request is sent by a remote client. + */ +public class ProxyPingEvent { + private final InboundConnection connection; + private ServerPing ping; + + public ProxyPingEvent(InboundConnection connection, ServerPing ping) { + this.connection = Preconditions.checkNotNull(connection, "connection"); + this.ping = Preconditions.checkNotNull(ping, "ping"); + } + + public InboundConnection getConnection() { + return connection; + } + + public ServerPing getPing() { + return ping; + } + + public void setPing(ServerPing ping) { + this.ping = Preconditions.checkNotNull(ping, "ping"); + } + + @Override + public String toString() { + return "ProxyPingEvent{" + + "connection=" + connection + + ", ping=" + ping + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyShutdownEvent.java b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyShutdownEvent.java new file mode 100644 index 000000000..0ae15f192 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/proxy/ProxyShutdownEvent.java @@ -0,0 +1,12 @@ +package com.velocitypowered.api.event.proxy; + +/** + * This event is fired by the proxy after the proxy has stopped accepting connections but before the proxy process + * exits. + */ +public class ProxyShutdownEvent { + @Override + public String toString() { + return "ProxyShutdownEvent"; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/proxy/package-info.java b/api/src/main/java/com/velocitypowered/api/event/proxy/package-info.java new file mode 100644 index 000000000..3b7aab6b7 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/proxy/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides events for handling the lifecycle of the proxy. + */ +package com.velocitypowered.api.event.proxy; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/package-info.java b/api/src/main/java/com/velocitypowered/api/package-info.java deleted file mode 100644 index 6d8698019..000000000 --- a/api/src/main/java/com/velocitypowered/api/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.velocitypowered.api; - -/** - * Welcome to the Velocity API documentation. - */ \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/permission/PermissionFunction.java b/api/src/main/java/com/velocitypowered/api/permission/PermissionFunction.java new file mode 100644 index 000000000..102e36eba --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/permission/PermissionFunction.java @@ -0,0 +1,33 @@ +package com.velocitypowered.api.permission; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Function that calculates the permission settings for a given + * {@link PermissionSubject}. + */ +@FunctionalInterface +public interface PermissionFunction { + /** + * A permission function that always returns {@link Tristate#TRUE}. + */ + PermissionFunction ALWAYS_TRUE = p -> Tristate.TRUE; + + /** + * A permission function that always returns {@link Tristate#FALSE}. + */ + PermissionFunction ALWAYS_FALSE = p -> Tristate.FALSE; + + /** + * A permission function that always returns {@link Tristate#UNDEFINED}. + */ + PermissionFunction ALWAYS_UNDEFINED = p -> Tristate.UNDEFINED; + + /** + * Gets the subjects setting for a particular permission. + * + * @param permission the permission + * @return the value the permission is set to + */ + @NonNull Tristate getPermissionSetting(@NonNull String permission); +} diff --git a/api/src/main/java/com/velocitypowered/api/permission/PermissionProvider.java b/api/src/main/java/com/velocitypowered/api/permission/PermissionProvider.java new file mode 100644 index 000000000..fc040330c --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/permission/PermissionProvider.java @@ -0,0 +1,17 @@ +package com.velocitypowered.api.permission; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Provides {@link PermissionFunction}s for {@link PermissionSubject}s. + */ +@FunctionalInterface +public interface PermissionProvider { + /** + * Creates a {@link PermissionFunction} for the subject. + * + * @param subject the subject + * @return the function + */ + @NonNull PermissionFunction createFunction(@NonNull PermissionSubject subject); +} diff --git a/api/src/main/java/com/velocitypowered/api/permission/PermissionSubject.java b/api/src/main/java/com/velocitypowered/api/permission/PermissionSubject.java new file mode 100644 index 000000000..8377f0729 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/permission/PermissionSubject.java @@ -0,0 +1,16 @@ +package com.velocitypowered.api.permission; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents a object that has a set of queryable permissions. + */ +public interface PermissionSubject { + /** + * Determines whether or not the subject has a particular permission. + * + * @param permission the permission to check for + * @return whether or not the subject has the permission + */ + boolean hasPermission(@NonNull String permission); +} diff --git a/api/src/main/java/com/velocitypowered/api/permission/Tristate.java b/api/src/main/java/com/velocitypowered/api/permission/Tristate.java new file mode 100644 index 000000000..698a5f95b --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/permission/Tristate.java @@ -0,0 +1,74 @@ +package com.velocitypowered.api.permission; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Represents three different states of a setting. + * + *

Possible values:

+ *

+ *
    + *
  • {@link #TRUE} - a positive setting
  • + *
  • {@link #FALSE} - a negative (negated) setting
  • + *
  • {@link #UNDEFINED} - a non-existent setting
  • + *
+ */ +public enum Tristate { + + /** + * A value indicating a positive setting + */ + TRUE(true), + + /** + * A value indicating a negative (negated) setting + */ + FALSE(false), + + /** + * A value indicating a non-existent setting + */ + UNDEFINED(false); + + /** + * Returns a {@link Tristate} from a boolean + * + * @param val the boolean value + * @return {@link #TRUE} or {@link #FALSE}, if the value is true or false, respectively. + */ + public static @NonNull Tristate fromBoolean(boolean val) { + return val ? TRUE : FALSE; + } + + /** + * Returns a {@link Tristate} from a nullable boolean. + * + *

Unlike {@link #fromBoolean(boolean)}, this method returns {@link #UNDEFINED} + * if the value is null.

+ * + * @param val the boolean value + * @return {@link #UNDEFINED}, {@link #TRUE} or {@link #FALSE}, if the value + * is null, true or false, respectively. + */ + public static @NonNull Tristate fromNullableBoolean(@Nullable Boolean val) { + return val == null ? UNDEFINED : val ? TRUE : FALSE; + } + + private final boolean booleanValue; + + Tristate(boolean booleanValue) { + this.booleanValue = booleanValue; + } + + /** + * Returns the value of the Tristate as a boolean. + * + *

A value of {@link #UNDEFINED} converts to false.

+ * + * @return a boolean representation of the Tristate. + */ + public boolean asBoolean() { + return this.booleanValue; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/permission/package-info.java b/api/src/main/java/com/velocitypowered/api/permission/package-info.java new file mode 100644 index 000000000..a68255248 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/permission/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides the basic building blocks for a custom permission system. + */ +package com.velocitypowered.api.permission; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/plugin/Dependency.java b/api/src/main/java/com/velocitypowered/api/plugin/Dependency.java new file mode 100644 index 000000000..6af5d11e7 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/Dependency.java @@ -0,0 +1,30 @@ +package com.velocitypowered.api.plugin; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Represents a dependency for a {@link Plugin} + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({}) +public @interface Dependency { + /** + * The plugin ID of the dependency. + * + * @return The dependency plugin ID + * @see Plugin#id() + */ + String id(); + + // TODO Add required version field + + /** + * If this dependency is optional for the plugin to work. By default + * this is {@code false}. + * + * @return true if the dependency is optional for the plugin to work + */ + boolean optional() default false; +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/InvalidPluginException.java b/api/src/main/java/com/velocitypowered/api/plugin/InvalidPluginException.java new file mode 100644 index 000000000..bd9bdad24 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/InvalidPluginException.java @@ -0,0 +1,19 @@ +package com.velocitypowered.api.plugin; + +public class InvalidPluginException extends Exception { + public InvalidPluginException() { + super(); + } + + public InvalidPluginException(String message) { + super(message); + } + + public InvalidPluginException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidPluginException(Throwable cause) { + super(cause); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java new file mode 100644 index 000000000..b3e603f13 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java @@ -0,0 +1,66 @@ +package com.velocitypowered.api.plugin; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation used to describe a Velocity plugin. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Plugin { + /** + * The ID of the plugin. This ID should be unique as to + * not conflict with other plugins. + * + * The plugin ID must match the {@link PluginDescription#ID_PATTERN}. + * + * @return the ID for this plugin + */ + String id(); + + /** + * The human readable name of the plugin as to be used in descriptions and + * similar things. + * + * @return The plugin name, or an empty string if unknown + */ + String name() default ""; + + /** + * The version of the plugin. + * + * @return the version of the plugin, or an empty string if unknown + */ + String version() default ""; + + /** + * The description of the plugin, explaining what it can be used for. + * + * @return The plugin description, or an empty string if unknown + */ + String description() default ""; + + /** + * The URL or website of the plugin. + * + * @return The plugin url, or an empty string if unknown + */ + String url() default ""; + + /** + * The author of the plugin. + * + * @return the plugin's author, or empty if unknown + */ + String[] authors() default ""; + + /** + * The dependencies required to load before this plugin. + * + * @return the plugin dependencies + */ + Dependency[] dependencies() default {}; +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/PluginContainer.java b/api/src/main/java/com/velocitypowered/api/plugin/PluginContainer.java new file mode 100644 index 000000000..6ff5a6fee --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/PluginContainer.java @@ -0,0 +1,26 @@ +package com.velocitypowered.api.plugin; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.util.Optional; + +/** + * A wrapper around a plugin loaded by the proxy. + */ +public interface PluginContainer { + /** + * Returns the plugin's description. + * + * @return the plugin's description + */ + @NonNull PluginDescription getDescription(); + + /** + * Returns the created plugin if it is available. + * + * @return the instance if available + */ + default Optional getInstance() { + return Optional.empty(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java new file mode 100644 index 000000000..d789f68c5 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java @@ -0,0 +1,106 @@ +package com.velocitypowered.api.plugin; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.velocitypowered.api.plugin.meta.PluginDependency; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Represents metadata for a specific version of a plugin. + */ +public interface PluginDescription { + /** + * The pattern plugin IDs must match. Plugin IDs may only contain + * alphanumeric characters, dashes or underscores, must start with + * an alphabetic character and cannot be longer than 64 characters. + */ + Pattern ID_PATTERN = Pattern.compile("[a-z][a-z0-9-_]{0,63}"); + + /** + * Gets the qualified ID of the {@link Plugin} within this container. + * + * @return the plugin ID + * @see Plugin#id() + */ + String getId(); + + /** + * Gets the name of the {@link Plugin} within this container. + * + * @return an {@link Optional} with the plugin name, may be empty + * @see Plugin#name() + */ + default Optional getName() { + return Optional.empty(); + } + + /** + * Gets the version of the {@link Plugin} within this container. + * + * @return an {@link Optional} with the plugin version, may be empty + * @see Plugin#version() + */ + default Optional getVersion() { + return Optional.empty(); + } + + /** + * Gets the description of the {@link Plugin} within this container. + * + * @return an {@link Optional} with the plugin description, may be empty + * @see Plugin#description() + */ + default Optional getDescription() { + return Optional.empty(); + } + + /** + * Gets the url or website of the {@link Plugin} within this container. + * + * @return an {@link Optional} with the plugin url, may be empty + * @see Plugin#url() + */ + default Optional getUrl() { + return Optional.empty(); + } + + /** + * Gets the authors of the {@link Plugin} within this container. + * + * @return the plugin authors, may be empty + * @see Plugin#authors() + */ + default List getAuthors() { + return ImmutableList.of(); + } + + /** + * Gets a {@link Collection} of all dependencies of the {@link Plugin} within + * this container. + * + * @return the plugin dependencies, can be empty + * @see Plugin#dependencies() + */ + default Collection getDependencies() { + return ImmutableSet.of(); + } + + default Optional getDependency(String id) { + return Optional.empty(); + } + + /** + * Returns the source the plugin was loaded from. + * + * @return the source the plugin was loaded from or {@link Optional#empty()} + * if unknown + */ + default Optional getSource() { + return Optional.empty(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/PluginManager.java b/api/src/main/java/com/velocitypowered/api/plugin/PluginManager.java new file mode 100644 index 000000000..ba015d0a6 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/PluginManager.java @@ -0,0 +1,55 @@ +package com.velocitypowered.api.plugin; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * The class that manages plugins. This manager can retrieve + * {@link PluginContainer}s from {@link Plugin} instances, getting + * {@link Logger}s, etc. + */ +public interface PluginManager { + /** + * Gets the plugin container from an instance. + * + * @param instance the instance + * @return the container + */ + @NonNull Optional fromInstance(@NonNull Object instance); + + /** + * Retrieves a {@link PluginContainer} based on its ID. + * + * @param id the plugin ID + * @return the plugin, if available + */ + @NonNull Optional getPlugin(@NonNull String id); + + /** + * Gets a {@link Collection} of all {@link PluginContainer}s. + * + * @return the plugins + */ + @NonNull Collection getPlugins(); + + /** + * Checks if a plugin is loaded based on its ID. + * + * @param id the id of the {@link Plugin} + * @return {@code true} if loaded + */ + boolean isLoaded(@NonNull String id); + + /** + * Adds the specified {@code path} to the plugin classpath. + * + * @param plugin the plugin + * @param path the path to the JAR you want to inject into the classpath + * @throws UnsupportedOperationException if the operation is not applicable to this plugin + */ + void addToClasspath(@NonNull Object plugin, @NonNull Path path); +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/annotation/DataDirectory.java b/api/src/main/java/com/velocitypowered/api/plugin/annotation/DataDirectory.java new file mode 100644 index 000000000..aa120d94e --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/annotation/DataDirectory.java @@ -0,0 +1,18 @@ +package com.velocitypowered.api.plugin.annotation; + +import com.google.inject.BindingAnnotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation requests that Velocity inject a {@link java.nio.file.Path} instance with a plugin-specific data + * directory. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@BindingAnnotation +public @interface DataDirectory { +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/annotation/package-info.java b/api/src/main/java/com/velocitypowered/api/plugin/annotation/package-info.java new file mode 100644 index 000000000..81486dfed --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/annotation/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides annotations to handle injecting dependencies for plugins. + */ +package com.velocitypowered.api.plugin.annotation; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/plugin/meta/PluginDependency.java b/api/src/main/java/com/velocitypowered/api/plugin/meta/PluginDependency.java new file mode 100644 index 000000000..f63b87c5c --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/meta/PluginDependency.java @@ -0,0 +1,79 @@ +package com.velocitypowered.api.plugin.meta; + +import javax.annotation.Nullable; + +import java.util.Objects; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Strings.emptyToNull; + +/** + * Represents a dependency on another plugin. + */ +public final class PluginDependency { + private final String id; + @Nullable private final String version; + + private final boolean optional; + + public PluginDependency(String id, @Nullable String version, boolean optional) { + this.id = checkNotNull(id, "id"); + checkArgument(!id.isEmpty(), "id cannot be empty"); + this.version = emptyToNull(version); + this.optional = optional; + } + + /** + * Returns the plugin ID of this {@link PluginDependency} + * + * @return the plugin ID + */ + public String getId() { + return id; + } + + /** + * Returns the version this {@link PluginDependency} should match. + * + * @return an {@link Optional} with the plugin version, may be empty + */ + public Optional getVersion() { + return Optional.ofNullable(version); + } + + /** + * Returns whether the dependency is optional for the plugin to work + * correctly. + * + * @return true if dependency is optional + */ + public boolean isOptional() { + return optional; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PluginDependency that = (PluginDependency) o; + return optional == that.optional && + Objects.equals(id, that.id) && + Objects.equals(version, that.version); + } + + @Override + public int hashCode() { + return Objects.hash(id, version, optional); + } + + @Override + public String toString() { + return "PluginDependency{" + + "id='" + id + '\'' + + ", version='" + version + '\'' + + ", optional=" + optional + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/meta/package-info.java b/api/src/main/java/com/velocitypowered/api/plugin/meta/package-info.java new file mode 100644 index 000000000..cde1887ae --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/meta/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides metadata for plugins. + */ +package com.velocitypowered.api.plugin.meta; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/plugin/package-info.java b/api/src/main/java/com/velocitypowered/api/plugin/package-info.java new file mode 100644 index 000000000..6eea17fd6 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/plugin/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides the Velocity plugin API. + */ +package com.velocitypowered.api.plugin; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java b/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java index 5283ef422..853b4c3de 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java @@ -1,28 +1,29 @@ package com.velocitypowered.api.proxy; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.api.proxy.server.ServerInfo; import net.kyori.text.Component; +import org.checkerframework.checker.nullness.qual.NonNull; import java.util.Optional; import java.util.concurrent.CompletableFuture; /** - * Represents a connection request. A connection request is created using {@link Player#createConnectionRequest(ServerInfo)} - * and is used to allow a plugin to compose and request a connection to another Minecraft server using a fluent API. + * Provides a fluent interface to compose and send a connection request to another server behind the proxy. A connection + * request is created using {@link Player#createConnectionRequest(ServerInfo)}. */ public interface ConnectionRequestBuilder { /** * Returns the server that this connection request represents. * @return the server this request will connect to */ - ServerInfo getServer(); + @NonNull ServerInfo getServer(); /** * Initiates the connection to the remote server and emits a result on the {@link CompletableFuture} after the user * has logged on. No messages will be communicated to the client: the user is responsible for all error handling. * @return a {@link CompletableFuture} representing the status of this connection */ - CompletableFuture connect(); + @NonNull CompletableFuture connect(); /** * Initiates the connection to the remote server without waiting for a result. Velocity will use generic error @@ -49,7 +50,7 @@ public interface ConnectionRequestBuilder { Status getStatus(); /** - * Returns a reason for the failure to connect to the server. None may be provided. + * Returns an (optional) textual reason for the failure to connect to the server. * @return the reason why the user could not connect to the server */ Optional getReason(); diff --git a/api/src/main/java/com/velocitypowered/api/proxy/InboundConnection.java b/api/src/main/java/com/velocitypowered/api/proxy/InboundConnection.java new file mode 100644 index 000000000..e3cdcce24 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/InboundConnection.java @@ -0,0 +1,33 @@ +package com.velocitypowered.api.proxy; + +import java.net.InetSocketAddress; +import java.util.Optional; + +/** + * Represents an incoming connection to the proxy. + */ +public interface InboundConnection { + /** + * Returns the player's IP address. + * @return the player's IP + */ + InetSocketAddress getRemoteAddress(); + + /** + * Returns the hostname that the user entered into the client, if applicable. + * @return the hostname from the client + */ + Optional getVirtualHost(); + + /** + * Determine whether or not the player remains online. + * @return whether or not the player active + */ + boolean isActive(); + + /** + * Returns the current protocol version this connection uses. + * @return the protocol version the connection uses + */ + int getProtocolVersion(); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/Player.java index f2f233459..dc56e527e 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java @@ -1,18 +1,20 @@ package com.velocitypowered.api.proxy; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.messages.ChannelMessageSink; +import com.velocitypowered.api.proxy.messages.ChannelMessageSource; +import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.api.util.MessagePosition; import net.kyori.text.Component; +import org.checkerframework.checker.nullness.qual.NonNull; -import javax.annotation.Nonnull; -import java.net.InetSocketAddress; import java.util.Optional; import java.util.UUID; /** * Represents a player who is connected to the proxy. */ -public interface Player { +public interface Player extends CommandSource, InboundConnection, ChannelMessageSource, ChannelMessageSink { /** * Returns the player's current username. * @return the username @@ -29,25 +31,13 @@ public interface Player { * Returns the server that the player is currently connected to. * @return an {@link Optional} the server that the player is connected to, which may be empty */ - Optional getCurrentServer(); - - /** - * Returns the player's IP address. - * @return the player's IP - */ - InetSocketAddress getRemoteAddress(); - - /** - * Determine whether or not the player remains online. - * @return whether or not the player active - */ - boolean isActive(); - + Optional getCurrentServer(); + /** * Sends a chat message to the player's client. * @param component the chat message to send */ - default void sendMessage(@Nonnull Component component) { + default void sendMessage(@NonNull Component component) { sendMessage(component, MessagePosition.CHAT); } @@ -56,12 +46,31 @@ public interface Player { * @param component the chat message to send * @param position the position for the message */ - void sendMessage(@Nonnull Component component, @Nonnull MessagePosition position); + void sendMessage(@NonNull Component component, @NonNull MessagePosition position); /** * Creates a new connection request so that the player can connect to another server. * @param info the server to connect to * @return a new connection request */ - ConnectionRequestBuilder createConnectionRequest(@Nonnull ServerInfo info); + ConnectionRequestBuilder createConnectionRequest(@NonNull ServerInfo info); + + /** + * Sets the tab list header and footer for the player. + * @param header the header component + * @param footer the footer component + */ + void setHeaderAndFooter(Component header, Component footer); + + /** + * Clears the tab list header and footer for the player. + */ + void clearHeaderAndFooter(); + + /** + * Disconnects the player with the specified reason. Once this method is called, further calls to other {@link Player} + * methods will become undefined. + * @param reason component with the reason + */ + void disconnect(Component reason); } diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java b/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java new file mode 100644 index 000000000..a2af42b28 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/ProxyServer.java @@ -0,0 +1,110 @@ +package com.velocitypowered.api.proxy; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.proxy.messages.ChannelRegistrar; +import com.velocitypowered.api.scheduler.Scheduler; +import com.velocitypowered.api.proxy.server.ServerInfo; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +/** + * Provides an interface to a Minecraft server proxy. + */ +public interface ProxyServer { + /** + * Retrieves the player currently connected to this proxy by their Minecraft username. The search is case-insensitive. + * @param username the username to search for + * @return an {@link Optional} with the player, which may be empty + */ + Optional getPlayer(String username); + + /** + * Retrieves the player currently connected to this proxy by their Minecraft UUID. + * @param uuid the UUID + * @return an {@link Optional} with the player, which may be empty + */ + Optional getPlayer(UUID uuid); + + /** + * Retrieves all players currently connected to this proxy. This call may or may not be a snapshot of all players + * online. + * @return the players online on this proxy + */ + Collection getAllPlayers(); + + /** + * Returns the number of players currently connected to this proxy. + * @return the players on this proxy + */ + int getPlayerCount(); + + /** + * Retrieves a registered {@link ServerInfo} instance by its name. The search is case-insensitive. + * @param name the name of the server + * @return the registered server, which may be empty + */ + Optional getServerInfo(String name); + + /** + * Retrieves all {@link ServerInfo}s registered with this proxy. + * @return the servers registered with this proxy + */ + Collection getAllServers(); + + /** + * Registers a server with this proxy. A server with this name should not already exist. + * @param server the server to register + */ + void registerServer(ServerInfo server); + + /** + * Unregisters this server from the proxy. + * @param server the server to unregister + */ + void unregisterServer(ServerInfo server); + + /** + * Returns an instance of {@link CommandSource} that can be used to determine if the command is being invoked by + * the console or a console-like executor. Plugins that execute commands are strongly urged to implement their own + * {@link CommandSource} instead of using the console invoker. + * @return the console command invoker + */ + CommandSource getConsoleCommandSource(); + + /** + * Gets the {@link PluginManager} instance. + * + * @return the plugin manager instance + */ + PluginManager getPluginManager(); + + /** + * Gets the {@link EventManager} instance. + * + * @return the event manager instance + */ + EventManager getEventManager(); + + /** + * Gets the {@link CommandManager} instance. + * @return the command manager + */ + CommandManager getCommandManager(); + + /** + * Gets the {@link Scheduler} instance. + * @return the scheduler instance + */ + Scheduler getScheduler(); + + /** + * Gets the {@link ChannelRegistrar} instance. + * @return the channel registrar + */ + ChannelRegistrar getChannelRegistrar(); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java new file mode 100644 index 000000000..5f12fb326 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java @@ -0,0 +1,22 @@ +package com.velocitypowered.api.proxy; + +import com.velocitypowered.api.proxy.messages.ChannelMessageSink; +import com.velocitypowered.api.proxy.messages.ChannelMessageSource; +import com.velocitypowered.api.proxy.server.ServerInfo; + +/** + * Represents a connection to a backend server from the proxy for a client. + */ +public interface ServerConnection extends ChannelMessageSource, ChannelMessageSink { + /** + * Returns the server that this connection is connected to. + * @return the server this connection is connected to + */ + ServerInfo getServerInfo(); + + /** + * Returns the player that this connection is associated with. + * @return the player for this connection + */ + Player getPlayer(); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelIdentifier.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelIdentifier.java new file mode 100644 index 000000000..4acd5d72a --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelIdentifier.java @@ -0,0 +1,12 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * Represents a kind of channel identifier. + */ +public interface ChannelIdentifier { + /** + * Returns the textual representation of this identifier. + * @return the textual representation of the identifier + */ + String getId(); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSink.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSink.java new file mode 100644 index 000000000..57ebe4ea6 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSink.java @@ -0,0 +1,13 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * Represents something that can send plugin messages. + */ +public interface ChannelMessageSink { + /** + * Sends a plugin message to this target. + * @param identifier the channel identifier to send the message on + * @param data the data to send + */ + void sendPluginMessage(ChannelIdentifier identifier, byte[] data); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSource.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSource.java new file mode 100644 index 000000000..8cb85ef1c --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelMessageSource.java @@ -0,0 +1,7 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * A marker interface that indicates a source of plugin messages. + */ +public interface ChannelMessageSource { +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelRegistrar.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelRegistrar.java new file mode 100644 index 000000000..2d77988b5 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelRegistrar.java @@ -0,0 +1,20 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * Represents an interface to register and unregister {@link MessageHandler} instances for handling plugin messages from + * the client or the server. + */ +public interface ChannelRegistrar { + /** + * Registers the specified message handler to listen for plugin messages on the specified channels. + * @param handler the handler to register + * @param identifiers the channel identifiers to register + */ + void register(MessageHandler handler, ChannelIdentifier... identifiers); + + /** + * Unregisters the handler for the specified channel. + * @param identifiers the identifiers to unregister + */ + void unregister(ChannelIdentifier... identifiers); +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelSide.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelSide.java new file mode 100644 index 000000000..12256f432 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/ChannelSide.java @@ -0,0 +1,15 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * Represents from "which side" of the proxy the plugin message came from. + */ +public enum ChannelSide { + /** + * The plugin message came from a server that a client was connected to. + */ + FROM_SERVER, + /** + * The plugin message came from the client. + */ + FROM_CLIENT +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/LegacyChannelIdentifier.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/LegacyChannelIdentifier.java new file mode 100644 index 000000000..a5a345cfd --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/LegacyChannelIdentifier.java @@ -0,0 +1,48 @@ +package com.velocitypowered.api.proxy.messages; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; + +import java.util.Objects; + +/** + * Reperesents a legacy channel identifier (for Minecraft 1.12 and below). For modern 1.13 plugin messages, please see + * {@link MinecraftChannelIdentifier}. This class is immutable and safe for multi-threaded use. + */ +public final class LegacyChannelIdentifier implements ChannelIdentifier { + private final String name; + + public LegacyChannelIdentifier(String name) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(name), "provided name is empty"); + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "LegacyChannelIdentifier{" + + "name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LegacyChannelIdentifier that = (LegacyChannelIdentifier) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String getId() { + return name; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/MessageHandler.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/MessageHandler.java new file mode 100644 index 000000000..70a7e5fa7 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/MessageHandler.java @@ -0,0 +1,28 @@ +package com.velocitypowered.api.proxy.messages; + +/** + * Represents a handler for handling plugin messages. + */ +public interface MessageHandler { + /** + * Handles an incoming plugin message. + * @param source the source of the plugin message + * @param side from where the plugin message originated + * @param identifier the channel on which the message was sent + * @param data the data inside the plugin message + * @return a {@link ForwardStatus} indicating whether or not to forward this plugin message on + */ + ForwardStatus handle(ChannelMessageSource source, ChannelSide side, ChannelIdentifier identifier, byte[] data); + + enum ForwardStatus { + /** + * Forwards this plugin message on to the client or server, depending on the {@link ChannelSide} it originated + * from. + */ + FORWARD, + /** + * Discard the plugin message and do not forward it on. + */ + HANDLED + } +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/MinecraftChannelIdentifier.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/MinecraftChannelIdentifier.java new file mode 100644 index 000000000..1355ed239 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/MinecraftChannelIdentifier.java @@ -0,0 +1,81 @@ +package com.velocitypowered.api.proxy.messages; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Represents a Minecraft 1.13+ channel identifier. This class is immutable and safe for multi-threaded use. + */ +public final class MinecraftChannelIdentifier implements ChannelIdentifier { + private static final Pattern VALID_IDENTIFIER_REGEX = Pattern.compile("[a-z0-9\\-_]+"); + + private final String namespace; + private final String name; + + private MinecraftChannelIdentifier(String namespace, String name) { + this.namespace = namespace; + this.name = name; + } + + /** + * Creates an identifier in the default namespace ({@code minecraft}). Plugins are strongly encouraged to provide + * their own namespace. + * @param name the name in the default namespace to use + * @return a new channel identifier + */ + public static MinecraftChannelIdentifier forDefaultNamespace(String name) { + return new MinecraftChannelIdentifier("minecraft", name); + } + + /** + * Creates an identifier in the specified namespace. + * @param namespace the namespace to use + * @param name the channel name inside the specified namespace + * @return a new channel identifier + */ + public static MinecraftChannelIdentifier create(String namespace, String name) { + Preconditions.checkArgument(!Strings.isNullOrEmpty(namespace), "namespace is null or empty"); + Preconditions.checkArgument(!Strings.isNullOrEmpty(name), "namespace is null or empty"); + Preconditions.checkArgument(VALID_IDENTIFIER_REGEX.matcher(namespace).matches(), "namespace is not valid"); + Preconditions.checkArgument(VALID_IDENTIFIER_REGEX.matcher(name).matches(), "name is not valid"); + return new MinecraftChannelIdentifier(namespace, name); + } + + public String getNamespace() { + return namespace; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "MinecraftChannelIdentifier{" + + "namespace='" + namespace + '\'' + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MinecraftChannelIdentifier that = (MinecraftChannelIdentifier) o; + return Objects.equals(namespace, that.namespace) && + Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, name); + } + + @Override + public String getId() { + return namespace + ":" + name; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/messages/package-info.java b/api/src/main/java/com/velocitypowered/api/proxy/messages/package-info.java new file mode 100644 index 000000000..d55279722 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/messages/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides an interface to receive, handle, and send plugin messages on the proxy from clients and servers. + */ +package com.velocitypowered.api.proxy.messages; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/proxy/package-info.java b/api/src/main/java/com/velocitypowered/api/proxy/package-info.java new file mode 100644 index 000000000..3a22511ab --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides an interface to interact with the proxy at a low level. + */ +package com.velocitypowered.api.proxy; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/server/ServerInfo.java b/api/src/main/java/com/velocitypowered/api/proxy/server/ServerInfo.java similarity index 76% rename from api/src/main/java/com/velocitypowered/api/server/ServerInfo.java rename to api/src/main/java/com/velocitypowered/api/proxy/server/ServerInfo.java index 8bab632d0..3dffa7111 100644 --- a/api/src/main/java/com/velocitypowered/api/server/ServerInfo.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/server/ServerInfo.java @@ -1,6 +1,7 @@ -package com.velocitypowered.api.server; +package com.velocitypowered.api.proxy.server; import com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.NonNull; import java.net.InetSocketAddress; import java.util.Objects; @@ -9,24 +10,24 @@ import java.util.Objects; * ServerInfo represents a server that a player can connect to. This object is immutable and safe for concurrent access. */ public final class ServerInfo { - private final String name; - private final InetSocketAddress address; + private final @NonNull String name; + private final @NonNull InetSocketAddress address; /** * Creates a new ServerInfo object. * @param name the name for the server * @param address the address of the server to connect to */ - public ServerInfo(String name, InetSocketAddress address) { + public ServerInfo(@NonNull String name, @NonNull InetSocketAddress address) { this.name = Preconditions.checkNotNull(name, "name"); this.address = Preconditions.checkNotNull(address, "address"); } - public final String getName() { + public final @NonNull String getName() { return name; } - public final InetSocketAddress getAddress() { + public final @NonNull InetSocketAddress getAddress() { return address; } diff --git a/api/src/main/java/com/velocitypowered/api/proxy/server/ServerPing.java b/api/src/main/java/com/velocitypowered/api/proxy/server/ServerPing.java new file mode 100644 index 000000000..f48fbc3a8 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/server/ServerPing.java @@ -0,0 +1,244 @@ +package com.velocitypowered.api.proxy.server; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.util.Favicon; +import net.kyori.text.Component; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.*; + +/** + * Represents a 1.7 and above server list ping response. This class is immutable. + */ +public class ServerPing { + private final Version version; + private final Players players; + private final Component description; + private final @Nullable Favicon favicon; + + public ServerPing(@NonNull Version version, @NonNull Players players, @NonNull Component description, @Nullable Favicon favicon) { + this.version = Preconditions.checkNotNull(version, "version"); + this.players = Preconditions.checkNotNull(players, "players"); + this.description = Preconditions.checkNotNull(description, "description"); + this.favicon = favicon; + } + + public Version getVersion() { + return version; + } + + public Players getPlayers() { + return players; + } + + public Component getDescription() { + return description; + } + + public Optional getFavicon() { + return Optional.ofNullable(favicon); + } + + @Override + public String toString() { + return "ServerPing{" + + "version=" + version + + ", players=" + players + + ", description=" + description + + ", favicon='" + favicon + '\'' + + '}'; + } + + public Builder asBuilder() { + Builder builder = new Builder(); + builder.version = version; + builder.onlinePlayers = players.online; + builder.maximumPlayers = players.max; + builder.samplePlayers.addAll(players.sample); + builder.description = description; + builder.favicon = favicon; + return builder; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for {@link ServerPing} objects. + */ + public static class Builder { + private Version version; + private int onlinePlayers; + private int maximumPlayers; + private final List samplePlayers = new ArrayList<>(); + private Component description; + private Favicon favicon; + + private Builder() { + + } + + public Builder version(Version version) { + this.version = Preconditions.checkNotNull(version, "version"); + return this; + } + + public Builder onlinePlayers(int onlinePlayers) { + this.onlinePlayers = onlinePlayers; + return this; + } + + public Builder maximumPlayers(int maximumPlayers) { + this.maximumPlayers = maximumPlayers; + return this; + } + + public Builder samplePlayers(SamplePlayer... players) { + this.samplePlayers.addAll(Arrays.asList(players)); + return this; + } + + public Builder clearSamplePlayers() { + this.samplePlayers.clear(); + return this; + } + + public Builder description(Component description) { + this.description = Preconditions.checkNotNull(description, "description"); + return this; + } + + public Builder favicon(Favicon favicon) { + this.favicon = Preconditions.checkNotNull(favicon, "favicon"); + return this; + } + + public ServerPing build() { + return new ServerPing(version, new Players(onlinePlayers, maximumPlayers, samplePlayers), description, favicon); + } + + public Version getVersion() { + return version; + } + + public int getOnlinePlayers() { + return onlinePlayers; + } + + public int getMaximumPlayers() { + return maximumPlayers; + } + + public List getSamplePlayers() { + return samplePlayers; + } + + public Component getDescription() { + return description; + } + + public Favicon getFavicon() { + return favicon; + } + + @Override + public String toString() { + return "Builder{" + + "version=" + version + + ", onlinePlayers=" + onlinePlayers + + ", maximumPlayers=" + maximumPlayers + + ", samplePlayers=" + samplePlayers + + ", description=" + description + + ", favicon=" + favicon + + '}'; + } + } + + public static class Version { + private final int protocol; + private final String name; + + public Version(int protocol, String name) { + this.protocol = protocol; + this.name = name; + } + + public int getProtocol() { + return protocol; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "Version{" + + "protocol=" + protocol + + ", name='" + name + '\'' + + '}'; + } + } + + public static class Players { + private final int online; + private final int max; + private final List sample; + + public Players(int online, int max, List sample) { + this.online = online; + this.max = max; + this.sample = ImmutableList.copyOf(sample); + } + + public int getOnline() { + return online; + } + + public int getMax() { + return max; + } + + public List getSample() { + return sample; + } + + @Override + public String toString() { + return "Players{" + + "online=" + online + + ", max=" + max + + ", sample=" + sample + + '}'; + } + } + + public static class SamplePlayer { + private final String name; + private final UUID id; + + public SamplePlayer(String name, UUID id) { + this.name = name; + this.id = id; + } + + public String getName() { + return name; + } + + public UUID getId() { + return id; + } + + @Override + public String toString() { + return "SamplePlayer{" + + "name='" + name + '\'' + + ", id=" + id + + '}'; + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/server/package-info.java b/api/src/main/java/com/velocitypowered/api/proxy/server/package-info.java new file mode 100644 index 000000000..7f902113a --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/server/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides utilities to handle server information. + */ +package com.velocitypowered.api.proxy.server; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/scheduler/ScheduledTask.java b/api/src/main/java/com/velocitypowered/api/scheduler/ScheduledTask.java new file mode 100644 index 000000000..2ee41380f --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/scheduler/ScheduledTask.java @@ -0,0 +1,24 @@ +package com.velocitypowered.api.scheduler; + +/** + * Represents a task that is scheduled to run on the proxy. + */ +public interface ScheduledTask { + /** + * Returns the plugin that scheduled this task. + * @return the plugin that scheduled this task + */ + Object plugin(); + + /** + * Returns the current status of this task. + * @return the current status of this task + */ + TaskStatus status(); + + /** + * Cancels this task. If the task is already running, the thread in which it is running will be interrupted. + * If the task is not currently running, Velocity will terminate it safely. + */ + void cancel(); +} diff --git a/api/src/main/java/com/velocitypowered/api/scheduler/Scheduler.java b/api/src/main/java/com/velocitypowered/api/scheduler/Scheduler.java new file mode 100644 index 000000000..fd652d1c6 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/scheduler/Scheduler.java @@ -0,0 +1,55 @@ +package com.velocitypowered.api.scheduler; + +import java.util.concurrent.TimeUnit; + +/** + * Represents a scheduler to execute tasks on the proxy. + */ +public interface Scheduler { + /** + * Initializes a new {@link TaskBuilder} for creating a task on the proxy. + * @param plugin the plugin to request the task for + * @param runnable the task to run when scheduled + * @return the task builder + */ + TaskBuilder buildTask(Object plugin, Runnable runnable); + + /** + * Represents a fluent interface to schedule tasks on the proxy. + */ + interface TaskBuilder { + /** + * Specifies that the task should delay its execution by the specified amount of time. + * @param time the time to delay by + * @param unit the unit of time for {@code time} + * @return this builder, for chaining + */ + TaskBuilder delay(int time, TimeUnit unit); + + /** + * Specifies that the task should continue running after waiting for the specified amount, until it is cancelled. + * @param time the time to delay by + * @param unit the unit of time for {@code time} + * @return this builder, for chaining + */ + TaskBuilder repeat(int time, TimeUnit unit); + + /** + * Clears the delay on this task. + * @return this builder, for chaining + */ + TaskBuilder clearDelay(); + + /** + * Clears the repeat interval on this task. + * @return this builder, for chaining + */ + TaskBuilder clearRepeat(); + + /** + * Schedules this task for execution. + * @return the scheduled task + */ + ScheduledTask schedule(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/scheduler/TaskStatus.java b/api/src/main/java/com/velocitypowered/api/scheduler/TaskStatus.java new file mode 100644 index 000000000..e86f56e12 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/scheduler/TaskStatus.java @@ -0,0 +1,16 @@ +package com.velocitypowered.api.scheduler; + +public enum TaskStatus { + /** + * The task is scheduled and is currently running. + */ + SCHEDULED, + /** + * The task was cancelled with {@link ScheduledTask#cancel()}. + */ + CANCELLED, + /** + * The task has run to completion. This is applicable only for tasks without a repeat. + */ + FINISHED +} diff --git a/api/src/main/java/com/velocitypowered/api/scheduler/package-info.java b/api/src/main/java/com/velocitypowered/api/scheduler/package-info.java new file mode 100644 index 000000000..155395528 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/scheduler/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides utilities for scheduling tasks with a fluent builder. + */ +package com.velocitypowered.api.scheduler; \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/util/Favicon.java b/api/src/main/java/com/velocitypowered/api/util/Favicon.java new file mode 100644 index 000000000..8fc1b2cb3 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/Favicon.java @@ -0,0 +1,89 @@ +package com.velocitypowered.api.util; + +import com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.NonNull; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Objects; + +/** + * Represents a Minecraft server favicon. A Minecraft server favicon is a 64x64 image that can be displayed to a remote + * client that sends a Server List Ping packet, and is automatically displayed in the Minecraft client. + */ +public final class Favicon { + private final String base64Url; + + /** + * Directly create a favicon using its Base64 URL directly. You are generally better served by the create() series + * of functions. + * @param base64Url the url for use with this favicon + */ + public Favicon(@NonNull String base64Url) { + this.base64Url = Preconditions.checkNotNull(base64Url, "base64Url"); + } + + /** + * Returns the Base64-encoded URI for this image. + * @return a URL representing this favicon + */ + public String getBase64Url() { + return base64Url; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Favicon favicon = (Favicon) o; + return Objects.equals(base64Url, favicon.base64Url); + } + + @Override + public int hashCode() { + return Objects.hash(base64Url); + } + + @Override + public String toString() { + return "Favicon{" + + "base64Url='" + base64Url + '\'' + + '}'; + } + + /** + * Creates a new {@code Favicon} from the specified {@code image}. + * @param image the image to use for the favicon + * @return the created {@link Favicon} instance + */ + public static Favicon create(@NonNull BufferedImage image) { + Preconditions.checkNotNull(image, "image"); + Preconditions.checkArgument(image.getWidth() == 64 && image.getHeight() == 64, "Image does not have" + + " 64x64 dimensions (found %sx%s)", image.getWidth(), image.getHeight()); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + try { + ImageIO.write(image, "PNG", os); + } catch (IOException e) { + throw new AssertionError(e); + } + return new Favicon("data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray())); + } + + /** + * Creates a new {@code Favicon} by reading the image from the specified {@code path}. + * @param path the path to the image to create a favicon for + * @return the created {@link Favicon} instance + * @throws IOException if the file could not be read from the path + */ + public static Favicon create(@NonNull Path path) throws IOException { + try (InputStream stream = Files.newInputStream(path)) { + return create(ImageIO.read(stream)); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/data/GameProfile.java b/api/src/main/java/com/velocitypowered/api/util/GameProfile.java similarity index 61% rename from proxy/src/main/java/com/velocitypowered/proxy/data/GameProfile.java rename to api/src/main/java/com/velocitypowered/api/util/GameProfile.java index 6c6620c8e..5fd716436 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/data/GameProfile.java +++ b/api/src/main/java/com/velocitypowered/api/util/GameProfile.java @@ -1,20 +1,23 @@ -package com.velocitypowered.proxy.data; +package com.velocitypowered.api.util; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; -import com.velocitypowered.proxy.util.UuidUtils; +import org.checkerframework.checker.nullness.qual.NonNull; import java.util.List; import java.util.UUID; -public class GameProfile { +/** + * Represents a Mojang game profile. This class is immutable. + */ +public final class GameProfile { private final String id; private final String name; private final List properties; - public GameProfile(String id, String name, List properties) { - this.id = id; - this.name = name; + public GameProfile(@NonNull String id, @NonNull String name, @NonNull List properties) { + this.id = Preconditions.checkNotNull(id, "id"); + this.name = Preconditions.checkNotNull(name, "name"); this.properties = ImmutableList.copyOf(properties); } @@ -31,10 +34,15 @@ public class GameProfile { } public List getProperties() { - return ImmutableList.copyOf(properties); + return properties; } - public static GameProfile forOfflinePlayer(String username) { + /** + * Creates a game profile suitable for use in offline-mode. + * @param username the username to use + * @return the new offline-mode game profile + */ + public static GameProfile forOfflinePlayer(@NonNull String username) { Preconditions.checkNotNull(username, "username"); String id = UuidUtils.toUndashed(UuidUtils.generateOfflinePlayerUuid(username)); return new GameProfile(id, username, ImmutableList.of()); @@ -49,15 +57,15 @@ public class GameProfile { '}'; } - public class Property { + public final class Property { private final String name; private final String value; private final String signature; - public Property(String name, String value, String signature) { - this.name = name; - this.value = value; - this.signature = signature; + public Property(@NonNull String name, @NonNull String value, @NonNull String signature) { + this.name = Preconditions.checkNotNull(name, "name"); + this.value = Preconditions.checkNotNull(value, "value"); + this.signature = Preconditions.checkNotNull(signature, "signature"); } public String getName() { diff --git a/api/src/main/java/com/velocitypowered/api/util/LegacyChatColorUtils.java b/api/src/main/java/com/velocitypowered/api/util/LegacyChatColorUtils.java index 31703fe81..a21b97ffd 100644 --- a/api/src/main/java/com/velocitypowered/api/util/LegacyChatColorUtils.java +++ b/api/src/main/java/com/velocitypowered/api/util/LegacyChatColorUtils.java @@ -1,6 +1,7 @@ package com.velocitypowered.api.util; import com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.NonNull; import java.util.regex.Pattern; @@ -25,7 +26,7 @@ public class LegacyChatColorUtils { * @param text the text to translate * @return the translated text */ - public static String translate(char originalChar, String text) { + public static String translate(char originalChar, @NonNull String text) { Preconditions.checkNotNull(text, "text"); char[] textChars = text.toCharArray(); int foundSectionIdx = -1; @@ -58,7 +59,7 @@ public class LegacyChatColorUtils { * @param text the text to remove color codes from * @return a new String without Minecraft color codes */ - public static String removeFormatting(String text) { + public static String removeFormatting(@NonNull String text) { Preconditions.checkNotNull(text, "text"); return CHAT_COLOR_MATCHER.matcher(text).replaceAll(""); } diff --git a/api/src/main/java/com/velocitypowered/api/util/UuidUtils.java b/api/src/main/java/com/velocitypowered/api/util/UuidUtils.java new file mode 100644 index 000000000..a6c89b9cd --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/UuidUtils.java @@ -0,0 +1,50 @@ +package com.velocitypowered.api.util; + +import com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.UUID; + +/** + * Provides a small, useful selection of utilities for working with Minecraft UUIDs. + */ +public class UuidUtils { + private UuidUtils() { + throw new AssertionError(); + } + + /** + * Converts from an undashed Mojang-style UUID into a Java {@link UUID} object. + * @param string the string to convert + * @return the UUID object + */ + public static @NonNull UUID fromUndashed(final @NonNull String string) { + Objects.requireNonNull(string, "string"); + Preconditions.checkArgument(string.length() == 32, "Length is incorrect"); + return new UUID( + Long.parseUnsignedLong(string.substring(0, 16), 16), + Long.parseUnsignedLong(string.substring(16), 16) + ); + } + + /** + * Converts from a Java {@link UUID} object into an undashed Mojang-style UUID. + * @param uuid the UUID to convert + * @return the undashed UUID + */ + public static @NonNull String toUndashed(final @NonNull UUID uuid) { + Preconditions.checkNotNull(uuid, "uuid"); + return Long.toUnsignedString(uuid.getMostSignificantBits(), 16) + Long.toUnsignedString(uuid.getLeastSignificantBits(), 16); + } + + /** + * Generates a UUID for use for offline mode. + * @param username the username to use + * @return the offline mode UUID + */ + public static @NonNull UUID generateOfflinePlayerUuid(@NonNull String username) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/util/package-info.java b/api/src/main/java/com/velocitypowered/api/util/package-info.java new file mode 100644 index 000000000..ef28b6624 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/util/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides a selection of miscellaneous utilities for use by plugins and the proxy. + */ +package com.velocitypowered.api.util; \ No newline at end of file diff --git a/build.gradle b/build.gradle index 42320b400..7de9e8fc0 100644 --- a/build.gradle +++ b/build.gradle @@ -12,9 +12,21 @@ allprojects { ext { // dependency versions junitVersion = '5.3.0-M1' + slf4jVersion = '1.7.25' log4jVersion = '2.11.0' nettyVersion = '4.1.28.Final' guavaVersion = '25.1-jre' + + getCurrentBranchName = { + new ByteArrayOutputStream().withStream { os -> + exec { + executable = "git" + args = ["rev-parse", "--abbrev-ref", "HEAD"] + standardOutput = os + } + return os.toString().trim() + } + } } repositories { @@ -30,4 +42,4 @@ allprojects { junitXml.enabled = true } } -} \ No newline at end of file +} diff --git a/native/src/main/java/com/velocitypowered/natives/util/Natives.java b/native/src/main/java/com/velocitypowered/natives/util/Natives.java index 792a7aeb9..b8703ae5f 100644 --- a/native/src/main/java/com/velocitypowered/natives/util/Natives.java +++ b/native/src/main/java/com/velocitypowered/natives/util/Natives.java @@ -41,12 +41,12 @@ public class Natives { public static final NativeCodeLoader compressor = new NativeCodeLoader<>( ImmutableList.of( new NativeCodeLoader.Variant<>(NativeCodeLoader.MACOS, - copyAndLoadNative("/macosx/velocity-compress.dylib"), "native compression (macOS)", + copyAndLoadNative("/macosx/velocity-compress.dylib"), "native (macOS)", NativeVelocityCompressor.FACTORY), new NativeCodeLoader.Variant<>(NativeCodeLoader.LINUX, - copyAndLoadNative("/linux_x64/velocity-compress.so"), "native compression (Linux amd64)", + copyAndLoadNative("/linux_x64/velocity-compress.so"), "native (Linux amd64)", NativeVelocityCompressor.FACTORY), - new NativeCodeLoader.Variant<>(NativeCodeLoader.ALWAYS, () -> {}, "Java compression", JavaVelocityCompressor.FACTORY) + new NativeCodeLoader.Variant<>(NativeCodeLoader.ALWAYS, () -> {}, "Java", JavaVelocityCompressor.FACTORY) ) ); diff --git a/proxy/build.gradle b/proxy/build.gradle index 32632575e..547faed22 100644 --- a/proxy/build.gradle +++ b/proxy/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' id 'com.github.johnrengelman.shadow' version '2.0.4' + id 'de.sebastianboegl.shadow.transformer.log4j' version '2.1.1' } compileJava { @@ -21,17 +22,70 @@ jar { dependencies { compile project(':velocity-api') compile project(':velocity-native') + compile "io.netty:netty-codec:${nettyVersion}" compile "io.netty:netty-codec-http:${nettyVersion}" compile "io.netty:netty-handler:${nettyVersion}" compile "io.netty:netty-transport-native-epoll:${nettyVersion}" compile "io.netty:netty-transport-native-epoll:${nettyVersion}:linux-x86_64" + compile "io.netty:netty-transport-native-kqueue:${nettyVersion}:osx-x86_64" + compile "org.apache.logging.log4j:log4j-api:${log4jVersion}" compile "org.apache.logging.log4j:log4j-core:${log4jVersion}" + compile "org.apache.logging.log4j:log4j-slf4j-impl:${log4jVersion}" + + compile 'net.minecrell:terminalconsoleappender:1.1.1' + runtime 'net.java.dev.jna:jna:4.5.2' // Needed for JLine + runtime 'com.lmax:disruptor:3.4.2' // Async loggers + + compile 'it.unimi.dsi:fastutil:8.2.1' + compile 'net.kyori:event-method-asm:3.0.0' + testCompile "org.junit.jupiter:junit-jupiter-api:${junitVersion}" testCompile "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" } +shadowJar { + exclude 'it/unimi/dsi/fastutil/booleans/**' + exclude 'it/unimi/dsi/fastutil/bytes/**' + exclude 'it/unimi/dsi/fastutil/chars/**' + exclude 'it/unimi/dsi/fastutil/doubles/**' + exclude 'it/unimi/dsi/fastutil/floats/**' + exclude 'it/unimi/dsi/fastutil/ints/*Int2*' + exclude 'it/unimi/dsi/fastutil/ints/IntAVL*' + exclude 'it/unimi/dsi/fastutil/ints/IntArray*' + exclude 'it/unimi/dsi/fastutil/ints/IntBi*' + exclude 'it/unimi/dsi/fastutil/ints/IntList*' + exclude 'it/unimi/dsi/fastutil/ints/IntOpen*' + exclude 'it/unimi/dsi/fastutil/ints/IntRB*' + exclude 'it/unimi/dsi/fastutil/ints/IntSet*' + exclude 'it/unimi/dsi/fastutil/ints/IntSorted*' + exclude 'it/unimi/dsi/fastutil/io/**' + exclude 'it/unimi/dsi/fastutil/longs/**' + exclude 'it/unimi/dsi/fastutil/objects/*ObjectArray*' + exclude 'it/unimi/dsi/fastutil/objects/*ObjectAVL*' + exclude 'it/unimi/dsi/fastutil/objects/*Object*Big*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Boolean*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Byte*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Char*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Double*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Float*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2IntArray*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2IntAVL*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2IntLinked*' + exclude 'it/unimi/dsi/fastutil/objects/*Object*OpenCustom*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2IntRB*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2IntSorted*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Long*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Object*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Reference*' + exclude 'it/unimi/dsi/fastutil/objects/*Object2Short*' + exclude 'it/unimi/dsi/fastutil/objects/*ObjectRB*' + exclude 'it/unimi/dsi/fastutil/objects/*ObjectSorted*' + exclude 'it/unimi/dsi/fastutil/objects/*Reference*' + exclude 'it/unimi/dsi/fastutil/shorts/**' +} + artifacts { archives shadowJar -} \ No newline at end of file +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java b/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java index 7853d1639..8ab722a2e 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java @@ -1,12 +1,33 @@ package com.velocitypowered.proxy; +import com.velocitypowered.proxy.console.VelocityConsole; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.text.DecimalFormat; + public class Velocity { - public static void main(String... args) throws InterruptedException { + private static final Logger logger = LogManager.getLogger(Velocity.class); + + private static long startTime; + + static { + // We use BufferedImage for favicons, and on macOS this puts the Java application in the dock. How inconvenient. + // Force AWT to work with its head chopped off. + System.setProperty("java.awt.headless", "true"); + } + + public static void main(String... args) { + startTime = System.currentTimeMillis(); + logger.info("Booting up Velocity..."); + final VelocityServer server = VelocityServer.getServer(); server.start(); Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown, "Shutdown thread")); - Thread.currentThread().join(); + double bootTime = (System.currentTimeMillis() - startTime) / 1000d; + logger.info("Done ({}s)!", new DecimalFormat("#.##").format(bootTime)); + new VelocityConsole(server).start(); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index cedf2910e..92db57593 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -1,17 +1,40 @@ package com.velocitypowered.proxy; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.velocitypowered.natives.util.Natives; -import com.velocitypowered.network.ConnectionManager; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.util.Favicon; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.proxy.server.ServerInfo; +import com.velocitypowered.proxy.network.ConnectionManager; +import com.velocitypowered.proxy.command.ServerCommand; +import com.velocitypowered.proxy.command.ShutdownCommand; +import com.velocitypowered.proxy.command.VelocityCommand; import com.velocitypowered.proxy.config.VelocityConfiguration; +import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import com.velocitypowered.proxy.connection.http.NettyHttpClient; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.proxy.command.VelocityCommandManager; +import com.velocitypowered.proxy.messages.VelocityChannelRegistrar; +import com.velocitypowered.proxy.plugin.VelocityEventManager; +import com.velocitypowered.proxy.protocol.util.FaviconSerializer; +import com.velocitypowered.proxy.plugin.VelocityPluginManager; +import com.velocitypowered.proxy.scheduler.Sleeper; +import com.velocitypowered.proxy.scheduler.VelocityScheduler; import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.proxy.util.EncryptionUtils; +import com.velocitypowered.proxy.util.Ratelimiter; import com.velocitypowered.proxy.util.ServerMap; import io.netty.bootstrap.Bootstrap; import net.kyori.text.Component; +import net.kyori.text.TextComponent; +import net.kyori.text.serializer.ComponentSerializers; import net.kyori.text.serializer.GsonComponentSerializer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -22,13 +45,17 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyPair; -import java.util.Map; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; -public class VelocityServer { +public class VelocityServer implements ProxyServer { private static final Logger logger = LogManager.getLogger(VelocityServer.class); private static final VelocityServer INSTANCE = new VelocityServer(); public static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(Component.class, new GsonComponentSerializer()) + .registerTypeHierarchyAdapter(Favicon.class, new FaviconSerializer()) .create(); private final ConnectionManager cm = new ConnectionManager(); @@ -36,8 +63,33 @@ public class VelocityServer { private NettyHttpClient httpClient; private KeyPair serverKeyPair; private final ServerMap servers = new ServerMap(); + private final VelocityCommandManager commandManager = new VelocityCommandManager(); + private final AtomicBoolean shutdownInProgress = new AtomicBoolean(false); + private boolean shutdown = false; + private final VelocityPluginManager pluginManager = new VelocityPluginManager(this); + + private final Map connectionsByUuid = new ConcurrentHashMap<>(); + private final Map connectionsByName = new ConcurrentHashMap<>(); + private final CommandSource consoleCommandSource = new CommandSource() { + @Override + public void sendMessage(Component component) { + logger.info(ComponentSerializers.LEGACY.serialize(component)); + } + + @Override + public boolean hasPermission(String permission) { + return true; + } + }; + private Ratelimiter ipAttemptLimiter; + private VelocityEventManager eventManager; + private VelocityScheduler scheduler; + private VelocityChannelRegistrar channelRegistrar; private VelocityServer() { + commandManager.register(new VelocityCommand(), "velocity"); + commandManager.register(new ServerCommand(), "server"); + commandManager.register(new ShutdownCommand(), "shutdown"); } public static VelocityServer getServer() { @@ -52,12 +104,12 @@ public class VelocityServer { return configuration; } - public void start() { - logger.info("Using {}", Natives.compressor.getLoadedVariant()); - logger.info("Using {}", Natives.cipher.getLoadedVariant()); + @Override + public VelocityCommandManager getCommandManager() { + return commandManager; + } - // Create a key pair - logger.info("Booting up Velocity..."); + public void start() { try { Path configPath = Paths.get("velocity.toml"); try { @@ -82,10 +134,55 @@ public class VelocityServer { } serverKeyPair = EncryptionUtils.createRsaKeyPair(1024); - + ipAttemptLimiter = new Ratelimiter(configuration.getLoginRatelimit()); httpClient = new NettyHttpClient(this); + eventManager = new VelocityEventManager(pluginManager); + scheduler = new VelocityScheduler(pluginManager, Sleeper.SYSTEM); + channelRegistrar = new VelocityChannelRegistrar(); + loadPlugins(); + + try { + // Go ahead and fire the proxy initialization event. We block since plugins should have a chance + // to fully initialize before we accept any connections to the server. + eventManager.fire(new ProxyInitializeEvent()).get(); + } catch (InterruptedException | ExecutionException e) { + // Ignore, we don't care. InterruptedException is unlikely to happen (and if it does, you've got bigger + // issues) and there is almost no chance ExecutionException will be thrown. + } this.cm.bind(configuration.getBind()); + + if (configuration.isQueryEnabled()) { + this.cm.queryBind(configuration.getBind().getHostString(), configuration.getQueryPort()); + } + } + + private void loadPlugins() { + logger.info("Loading plugins..."); + + try { + Path pluginPath = Paths.get("plugins"); + + if (Files.notExists(pluginPath)) { + Files.createDirectory(pluginPath); + } else { + if (!Files.isDirectory(pluginPath)) { + logger.warn("Plugin location {} is not a directory, continuing without loading plugins", pluginPath); + return; + } + + pluginManager.loadPlugins(pluginPath); + } + } catch (Exception e) { + logger.error("Couldn't load plugins", e); + } + + // Register the plugin main classes so that we may proceed with firing the proxy initialize event + pluginManager.getPlugins().forEach(container -> { + container.getInstance().ifPresent(plugin -> eventManager.register(plugin, plugin)); + }); + + logger.info("Loaded {} plugins", pluginManager.getPlugins().size()); } public ServerMap getServers() { @@ -96,11 +193,122 @@ public class VelocityServer { return this.cm.createWorker(); } + public boolean isShutdown() { + return shutdown; + } + public void shutdown() { + if (!shutdownInProgress.compareAndSet(false, true)) return; + logger.info("Shutting down the proxy..."); + + for (ConnectedPlayer player : ImmutableList.copyOf(connectionsByUuid.values())) { + player.close(TextComponent.of("Proxy shutting down.")); + } + this.cm.shutdown(); + + eventManager.fire(new ProxyShutdownEvent()); + try { + if (!eventManager.shutdown() || !scheduler.shutdown()) { + logger.error("Your plugins took over 10 seconds to shut down."); + } + } catch (InterruptedException e) { + // Not much we can do about this... + } + + shutdown = true; } public NettyHttpClient getHttpClient() { return httpClient; } + + public Ratelimiter getIpAttemptLimiter() { + return ipAttemptLimiter; + } + + public boolean registerConnection(ConnectedPlayer connection) { + String lowerName = connection.getUsername().toLowerCase(Locale.US); + if (connectionsByName.putIfAbsent(lowerName, connection) != null) { + return false; + } + if (connectionsByUuid.putIfAbsent(connection.getUniqueId(), connection) != null) { + connectionsByName.remove(lowerName, connection); + return false; + } + return true; + } + + public void unregisterConnection(ConnectedPlayer connection) { + connectionsByName.remove(connection.getUsername().toLowerCase(Locale.US), connection); + connectionsByUuid.remove(connection.getUniqueId(), connection); + } + + @Override + public Optional getPlayer(String username) { + Preconditions.checkNotNull(username, "username"); + return Optional.ofNullable(connectionsByName.get(username.toLowerCase(Locale.US))); + } + + @Override + public Optional getPlayer(UUID uuid) { + Preconditions.checkNotNull(uuid, "uuid"); + return Optional.ofNullable(connectionsByUuid.get(uuid)); + } + + @Override + public Collection getAllPlayers() { + return ImmutableList.copyOf(connectionsByUuid.values()); + } + + @Override + public int getPlayerCount() { + return connectionsByUuid.size(); + } + + @Override + public Optional getServerInfo(String name) { + Preconditions.checkNotNull(name, "name"); + return servers.getServer(name); + } + + @Override + public Collection getAllServers() { + return servers.getAllServers(); + } + + @Override + public void registerServer(ServerInfo server) { + servers.register(server); + } + + @Override + public void unregisterServer(ServerInfo server) { + servers.unregister(server); + } + + @Override + public CommandSource getConsoleCommandSource() { + return consoleCommandSource; + } + + @Override + public PluginManager getPluginManager() { + return pluginManager; + } + + @Override + public EventManager getEventManager() { + return eventManager; + } + + @Override + public VelocityScheduler getScheduler() { + return scheduler; + } + + @Override + public VelocityChannelRegistrar getChannelRegistrar() { + return channelRegistrar; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java new file mode 100644 index 000000000..695aa5094 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java @@ -0,0 +1,58 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.ServerInfo; +import com.velocitypowered.proxy.VelocityServer; +import net.kyori.text.TextComponent; +import net.kyori.text.format.TextColor; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class ServerCommand implements Command { + @Override + public void execute(CommandSource source, String[] args) { + if (!(source instanceof Player)) { + source.sendMessage(TextComponent.of("Only players may run this command.", TextColor.RED)); + return; + } + + Player player = (Player) source; + if (args.length == 1) { + // Trying to connect to a server. + String serverName = args[0]; + Optional server = VelocityServer.getServer().getServerInfo(serverName); + if (!server.isPresent()) { + player.sendMessage(TextComponent.of("Server " + serverName + " doesn't exist.", TextColor.RED)); + return; + } + + player.createConnectionRequest(server.get()).fireAndForget(); + } else { + String serverList = VelocityServer.getServer().getAllServers().stream() + .map(ServerInfo::getName) + .collect(Collectors.joining(", ")); + player.sendMessage(TextComponent.of("Available servers: " + serverList, TextColor.YELLOW)); + } + } + + @Override + public List suggest(CommandSource source, String[] currentArgs) { + if (currentArgs.length == 0) { + return VelocityServer.getServer().getAllServers().stream() + .map(ServerInfo::getName) + .collect(Collectors.toList()); + } else if (currentArgs.length == 1) { + return VelocityServer.getServer().getAllServers().stream() + .map(ServerInfo::getName) + .filter(name -> name.regionMatches(true, 0, currentArgs[0], 0, currentArgs[0].length())) + .collect(Collectors.toList()); + } else { + return ImmutableList.of(); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java new file mode 100644 index 000000000..41eb3135a --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java @@ -0,0 +1,18 @@ +package com.velocitypowered.proxy.command; + +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.proxy.VelocityServer; +import net.kyori.text.TextComponent; +import net.kyori.text.format.TextColor; + +public class ShutdownCommand implements Command { + @Override + public void execute(CommandSource source, String[] args) { + if (source != VelocityServer.getServer().getConsoleCommandSource()) { + source.sendMessage(TextComponent.of("You are not allowed to use this command.", TextColor.RED)); + return; + } + VelocityServer.getServer().shutdown(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java new file mode 100644 index 000000000..94825af55 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java @@ -0,0 +1,40 @@ +package com.velocitypowered.proxy.command; + +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.proxy.VelocityServer; +import net.kyori.text.TextComponent; +import net.kyori.text.event.ClickEvent; +import net.kyori.text.format.TextColor; + +public class VelocityCommand implements Command { + @Override + public void execute(CommandSource source, String[] args) { + String implVersion = VelocityServer.class.getPackage().getImplementationVersion(); + TextComponent thisIsVelocity = TextComponent.builder() + .content("This is ") + .append(TextComponent.of("Velocity " + implVersion, TextColor.DARK_AQUA)) + .append(TextComponent.of(", the next generation Minecraft: Java Edition proxy.").resetStyle()) + .build(); + TextComponent velocityInfo = TextComponent.builder() + .content("Copyright 2018 Velocity Contributors. Velocity is freely licensed under the terms of the " + + "MIT License.") + .build(); + TextComponent velocityWebsite = TextComponent.builder() + .content("Visit the ") + .append(TextComponent.builder("Velocity website") + .color(TextColor.GREEN) + .clickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://www.velocitypowered.com")) + .build()) + .append(TextComponent.of(" or the ").resetStyle()) + .append(TextComponent.builder("Velocity GitHub") + .color(TextColor.GREEN) + .clickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://github.com/astei/velocity")) + .build()) + .build(); + + source.sendMessage(thisIsVelocity); + source.sendMessage(velocityInfo); + source.sendMessage(velocityWebsite); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java new file mode 100644 index 000000000..38c3759ea --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java @@ -0,0 +1,85 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.CommandManager; + +import java.util.*; +import java.util.stream.Collectors; + +public class VelocityCommandManager implements CommandManager { + private final Map commands = new HashMap<>(); + + @Override + public void register(final Command command, final String... aliases) { + Preconditions.checkNotNull(aliases, "aliases"); + Preconditions.checkNotNull(command, "executor"); + for (int i = 0, length = aliases.length; i < length; i++) { + final String alias = aliases[i]; + Preconditions.checkNotNull(aliases, "alias at index %s", i); + this.commands.put(alias.toLowerCase(Locale.ENGLISH), command); + } + } + + @Override + public void unregister(final String alias) { + Preconditions.checkNotNull(alias, "name"); + this.commands.remove(alias.toLowerCase(Locale.ENGLISH)); + } + + @Override + public boolean execute(CommandSource source, String cmdLine) { + Preconditions.checkNotNull(source, "invoker"); + Preconditions.checkNotNull(cmdLine, "cmdLine"); + + String[] split = cmdLine.split(" ", -1); + if (split.length == 0) { + return false; + } + + String alias = split[0]; + String[] actualArgs = Arrays.copyOfRange(split, 1, split.length); + Command command = commands.get(alias.toLowerCase(Locale.ENGLISH)); + if (command == null) { + return false; + } + + try { + command.execute(source, actualArgs); + return true; + } catch (Exception e) { + throw new RuntimeException("Unable to invoke command " + cmdLine + " for " + source, e); + } + } + + public Optional> offerSuggestions(CommandSource source, String cmdLine) { + Preconditions.checkNotNull(source, "source"); + Preconditions.checkNotNull(cmdLine, "cmdLine"); + + String[] split = cmdLine.split(" ", -1); + if (split.length == 0) { + return Optional.empty(); + } + + String command = split[0]; + if (split.length == 1) { + return Optional.of(commands.keySet().stream() + .filter(cmd -> cmd.regionMatches(true, 0, command, 0, command.length())) + .collect(Collectors.toList())); + } + + String[] actualArgs = Arrays.copyOfRange(split, 1, split.length); + Command executor = commands.get(command); + if (executor == null) { + return Optional.empty(); + } + + try { + return Optional.of(executor.suggest(source, actualArgs)); + } catch (Exception e) { + throw new RuntimeException("Unable to invoke suggestions for command " + command + " for " + source, e); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/IPForwardingMode.java b/proxy/src/main/java/com/velocitypowered/proxy/config/PlayerInfoForwarding.java similarity index 69% rename from proxy/src/main/java/com/velocitypowered/proxy/config/IPForwardingMode.java rename to proxy/src/main/java/com/velocitypowered/proxy/config/PlayerInfoForwarding.java index 87d449c2e..078c239c5 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/IPForwardingMode.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/PlayerInfoForwarding.java @@ -1,6 +1,6 @@ package com.velocitypowered.proxy.config; -public enum IPForwardingMode { +public enum PlayerInfoForwarding { NONE, LEGACY, MODERN diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java index 5e3959246..5843cb59a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -2,8 +2,10 @@ package com.velocitypowered.proxy.config; import com.google.common.collect.ImmutableMap; import com.moandjiezana.toml.Toml; +import com.velocitypowered.api.util.Favicon; import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.api.util.LegacyChatColorUtils; +import io.netty.buffer.ByteBufUtil; import net.kyori.text.Component; import net.kyori.text.serializer.ComponentSerializers; import org.apache.logging.log4j.LogManager; @@ -15,6 +17,7 @@ import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -26,27 +29,39 @@ public class VelocityConfiguration { private final String motd; private final int showMaxPlayers; private final boolean onlineMode; - private final IPForwardingMode ipForwardingMode; + private final PlayerInfoForwarding playerInfoForwardingMode; private final Map servers; private final List attemptConnectionOrder; private final int compressionThreshold; private final int compressionLevel; + private final int loginRatelimit; + + private final boolean queryEnabled; + private final int queryPort; private Component motdAsComponent; + private Favicon favicon; + + private final byte[] forwardingSecret; private VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, - IPForwardingMode ipForwardingMode, Map servers, + PlayerInfoForwarding playerInfoForwardingMode, Map servers, List attemptConnectionOrder, int compressionThreshold, - int compressionLevel) { + int compressionLevel, int loginRatelimit, boolean queryEnabled, + int queryPort, byte[] forwardingSecret) { this.bind = bind; this.motd = motd; this.showMaxPlayers = showMaxPlayers; this.onlineMode = onlineMode; - this.ipForwardingMode = ipForwardingMode; + this.playerInfoForwardingMode = playerInfoForwardingMode; this.servers = servers; this.attemptConnectionOrder = attemptConnectionOrder; this.compressionThreshold = compressionThreshold; this.compressionLevel = compressionLevel; + this.loginRatelimit = loginRatelimit; + this.queryEnabled = queryEnabled; + this.queryPort = queryPort; + this.forwardingSecret = forwardingSecret; } public boolean validate() { @@ -68,9 +83,15 @@ public class VelocityConfiguration { logger.info("Proxy is running in offline mode!"); } - switch (ipForwardingMode) { + switch (playerInfoForwardingMode) { case NONE: - logger.info("IP forwarding is disabled! All players will appear to be connecting from the proxy and will have offline-mode UUIDs."); + logger.info("Player info forwarding is disabled! All players will appear to be connecting from the proxy and will have offline-mode UUIDs."); + break; + case MODERN: + if (forwardingSecret.length == 0) { + logger.error("You don't have a forwarding secret set."); + valid = false; + } break; } @@ -109,23 +130,51 @@ public class VelocityConfiguration { if (compressionLevel < -1 || compressionLevel > 9) { logger.error("Invalid compression level {}", compressionLevel); + valid = false; } else if (compressionLevel == 0) { logger.warn("ALL packets going through the proxy are going to be uncompressed. This will increase bandwidth usage."); } if (compressionThreshold < -1) { logger.error("Invalid compression threshold {}", compressionLevel); + valid = false; } else if (compressionThreshold == 0) { logger.warn("ALL packets going through the proxy are going to be compressed. This may hurt performance."); } + if (loginRatelimit < 0) { + logger.error("Invalid login ratelimit {}", loginRatelimit); + valid = false; + } + + loadFavicon(); + return valid; } + private void loadFavicon() { + Path faviconPath = Paths.get("server-icon.png"); + if (Files.exists(faviconPath)) { + try { + this.favicon = Favicon.create(faviconPath); + } catch (Exception e) { + logger.info("Unable to load your server-icon.png, continuing without it.", e); + } + } + } + public InetSocketAddress getBind() { return AddressUtil.parseAddress(bind); } + public boolean isQueryEnabled() { + return queryEnabled; + } + + public int getQueryPort() { + return queryPort; + } + public String getMotd() { return motd; } @@ -149,8 +198,8 @@ public class VelocityConfiguration { return onlineMode; } - public IPForwardingMode getIpForwardingMode() { - return ipForwardingMode; + public PlayerInfoForwarding getPlayerInfoForwardingMode() { + return playerInfoForwardingMode; } public Map getServers() { @@ -169,6 +218,18 @@ public class VelocityConfiguration { return compressionLevel; } + public int getLoginRatelimit() { + return loginRatelimit; + } + + public Favicon getFavicon() { + return favicon; + } + + public byte[] getForwardingSecret() { + return forwardingSecret; + } + @Override public String toString() { return "VelocityConfiguration{" + @@ -176,12 +237,17 @@ public class VelocityConfiguration { ", motd='" + motd + '\'' + ", showMaxPlayers=" + showMaxPlayers + ", onlineMode=" + onlineMode + - ", ipForwardingMode=" + ipForwardingMode + + ", playerInfoForwardingMode=" + playerInfoForwardingMode + ", servers=" + servers + ", attemptConnectionOrder=" + attemptConnectionOrder + ", compressionThreshold=" + compressionThreshold + ", compressionLevel=" + compressionLevel + + ", loginRatelimit=" + loginRatelimit + + ", queryEnabled=" + queryEnabled + + ", queryPort=" + queryPort + ", motdAsComponent=" + motdAsComponent + + ", favicon=" + favicon + + ", forwardingSecret=" + ByteBufUtil.hexDump(forwardingSecret) + '}'; } @@ -200,16 +266,23 @@ public class VelocityConfiguration { } } + byte[] forwardingSecret = toml.getString("player-info-forwarding-secret", "5up3r53cr3t") + .getBytes(StandardCharsets.UTF_8); + return new VelocityConfiguration( - toml.getString("bind"), - toml.getString("motd"), - toml.getLong("show-max-players").intValue(), - toml.getBoolean("online-mode"), - IPForwardingMode.valueOf(toml.getString("ip-forwarding").toUpperCase()), + toml.getString("bind", "0.0.0.0:25577"), + toml.getString("motd", "&3A Velocity Server"), + toml.getLong("show-max-players", 500L).intValue(), + toml.getBoolean("online-mode", true), + PlayerInfoForwarding.valueOf(toml.getString("player-info-forwarding", "MODERN").toUpperCase()), ImmutableMap.copyOf(servers), toml.getTable("servers").getList("try"), toml.getTable("advanced").getLong("compression-threshold", 1024L).intValue(), - toml.getTable("advanced").getLong("compression-level", -1L).intValue()); + toml.getTable("advanced").getLong("compression-level", -1L).intValue(), + toml.getTable("advanced").getLong("login-ratelimit", 3000L).intValue(), + toml.getTable("query").getBoolean("enabled", false), + toml.getTable("query").getLong("port", 25577L).intValue(), + forwardingSecret); } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java index a5375bf25..a6ec95604 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -5,11 +5,12 @@ import com.velocitypowered.natives.compression.VelocityCompressor; import com.velocitypowered.natives.encryption.VelocityCipherFactory; import com.velocitypowered.natives.util.Natives; import com.velocitypowered.proxy.VelocityServer; -import com.velocitypowered.proxy.protocol.PacketWrapper; +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.StateRegistry; -import com.velocitypowered.natives.encryption.JavaVelocityCipher; import com.velocitypowered.natives.encryption.VelocityCipher; import com.velocitypowered.proxy.protocol.netty.*; +import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -23,14 +24,14 @@ import javax.crypto.spec.SecretKeySpec; import java.security.GeneralSecurityException; -import static com.velocitypowered.network.Connections.CIPHER_DECODER; -import static com.velocitypowered.network.Connections.CIPHER_ENCODER; -import static com.velocitypowered.network.Connections.COMPRESSION_DECODER; -import static com.velocitypowered.network.Connections.COMPRESSION_ENCODER; -import static com.velocitypowered.network.Connections.FRAME_DECODER; -import static com.velocitypowered.network.Connections.FRAME_ENCODER; -import static com.velocitypowered.network.Connections.MINECRAFT_DECODER; -import static com.velocitypowered.network.Connections.MINECRAFT_ENCODER; +import static com.velocitypowered.proxy.network.Connections.CIPHER_DECODER; +import static com.velocitypowered.proxy.network.Connections.CIPHER_ENCODER; +import static com.velocitypowered.proxy.network.Connections.COMPRESSION_DECODER; +import static com.velocitypowered.proxy.network.Connections.COMPRESSION_ENCODER; +import static com.velocitypowered.proxy.network.Connections.FRAME_DECODER; +import static com.velocitypowered.proxy.network.Connections.FRAME_ENCODER; +import static com.velocitypowered.proxy.network.Connections.MINECRAFT_DECODER; +import static com.velocitypowered.proxy.network.Connections.MINECRAFT_ENCODER; /** * A utility class to make working with the pipeline a little less painful and transparently handles certain Minecraft @@ -40,7 +41,6 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { private static final Logger logger = LogManager.getLogger(MinecraftConnection.class); private final Channel channel; - private boolean closed; private StateRegistry state; private MinecraftSessionHandler sessionHandler; private int protocolVersion; @@ -48,7 +48,6 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { public MinecraftConnection(Channel channel) { this.channel = channel; - this.closed = false; this.state = StateRegistry.HANDSHAKE; } @@ -72,24 +71,17 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { if (association != null) { logger.info("{} has disconnected", association); } - - teardown(); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { - if (msg instanceof PacketWrapper) { - PacketWrapper pw = (PacketWrapper) msg; + if (msg instanceof MinecraftPacket) { + sessionHandler.handle((MinecraftPacket) msg); + } else if (msg instanceof ByteBuf) { try { - if (sessionHandler != null) { - if (pw.getPacket() == null) { - sessionHandler.handleUnknown(pw.getBuffer()); - } else { - sessionHandler.handle(pw.getPacket()); - } - } + sessionHandler.handleUnknown((ByteBuf) msg); } finally { - ReferenceCountUtil.release(pw.getBuffer()); + ReferenceCountUtil.release(msg); } } } @@ -107,40 +99,38 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { logger.error("{} encountered an exception", ctx.channel().remoteAddress(), cause); } - closed = true; ctx.close(); } } public void write(Object msg) { - ensureOpen(); - channel.writeAndFlush(msg, channel.voidPromise()); + if (channel.isActive()) { + channel.writeAndFlush(msg, channel.voidPromise()); + } } public void delayedWrite(Object msg) { - ensureOpen(); - channel.write(msg, channel.voidPromise()); + if (channel.isActive()) { + channel.write(msg, channel.voidPromise()); + } } public void flush() { - ensureOpen(); - channel.flush(); + if (channel.isActive()) { + channel.flush(); + } } public void closeWith(Object msg) { - ensureOpen(); - teardown(); - channel.writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE); + if (channel.isActive()) { + channel.writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE); + } } public void close() { - ensureOpen(); - teardown(); - channel.close(); - } - - public void teardown() { - closed = true; + if (channel.isActive()) { + channel.close(); + } } public Channel getChannel() { @@ -148,7 +138,7 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { } public boolean isClosed() { - return closed; + return !channel.isActive(); } public StateRegistry getState() { @@ -167,8 +157,14 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { public void setProtocolVersion(int protocolVersion) { this.protocolVersion = protocolVersion; - this.channel.pipeline().get(MinecraftEncoder.class).setProtocolVersion(protocolVersion); - this.channel.pipeline().get(MinecraftDecoder.class).setProtocolVersion(protocolVersion); + if (protocolVersion != ProtocolConstants.LEGACY) { + this.channel.pipeline().get(MinecraftEncoder.class).setProtocolVersion(protocolVersion); + this.channel.pipeline().get(MinecraftDecoder.class).setProtocolVersion(protocolVersion); + } else { + // Legacy handshake handling + this.channel.pipeline().remove(MINECRAFT_ENCODER); + this.channel.pipeline().remove(MINECRAFT_DECODER); + } } public MinecraftSessionHandler getSessionHandler() { @@ -184,10 +180,12 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { } private void ensureOpen() { - Preconditions.checkState(!closed, "Connection is closed."); + Preconditions.checkState(!isClosed(), "Connection is closed."); } public void setCompressionThreshold(int threshold) { + ensureOpen(); + if (threshold == -1) { channel.pipeline().remove(COMPRESSION_DECODER); channel.pipeline().remove(COMPRESSION_ENCODER); @@ -204,6 +202,8 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { } public void enableEncryption(byte[] secret) throws GeneralSecurityException { + ensureOpen(); + SecretKey key = new SecretKeySpec(secret, "AES"); VelocityCipherFactory factory = Natives.cipher.get(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java index d445fb51c..4f13190de 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java @@ -4,7 +4,7 @@ import com.velocitypowered.proxy.protocol.MinecraftPacket; import io.netty.buffer.ByteBuf; public interface MinecraftSessionHandler { - void handle(MinecraftPacket packet) throws Exception; + void handle(MinecraftPacket packet); default void handleUnknown(ByteBuf buf) { // No-op: we'll release the buffer later. diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index bbf407952..be62948cc 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -1,5 +1,9 @@ package com.velocitypowered.proxy.connection.backend; +import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.proxy.messages.ChannelSide; +import com.velocitypowered.api.proxy.messages.MessageHandler; +import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; @@ -7,31 +11,40 @@ import com.velocitypowered.proxy.protocol.packet.*; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import com.velocitypowered.proxy.protocol.util.PluginMessageUtil; import io.netty.buffer.ByteBuf; -import io.netty.util.ReferenceCountUtil; public class BackendPlaySessionHandler implements MinecraftSessionHandler { - private final ServerConnection connection; + private final VelocityServerConnection connection; - public BackendPlaySessionHandler(ServerConnection connection) { + public BackendPlaySessionHandler(VelocityServerConnection connection) { this.connection = connection; } + @Override + public void activated() { + VelocityServer.getServer().getEventManager().fireAndForget(new ServerConnectedEvent(connection.getPlayer(), + connection.getServerInfo())); + } + @Override public void handle(MinecraftPacket packet) { + if (!connection.getPlayer().isActive()) { + // Connection was left open accidentally. Close it so as to avoid "You logged in from another location" + // errors. + connection.getMinecraftConnection().close(); + return; + } + ClientPlaySessionHandler playerHandler = - (ClientPlaySessionHandler) connection.getProxyPlayer().getConnection().getSessionHandler(); + (ClientPlaySessionHandler) connection.getPlayer().getConnection().getSessionHandler(); if (packet instanceof KeepAlive) { - // Forward onto the server - connection.getMinecraftConnection().write(packet); + // Forward onto the player + playerHandler.setLastPing(((KeepAlive) packet).getRandomId()); + connection.getPlayer().getConnection().write(packet); } else if (packet instanceof Disconnect) { Disconnect original = (Disconnect) packet; - connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), original); + connection.getPlayer().handleConnectionException(connection.getServerInfo(), original); } else if (packet instanceof JoinGame) { playerHandler.handleBackendJoinGame((JoinGame) packet); - } else if (packet instanceof Respawn) { - // Record the dimension switch, and then forward the packet on. - playerHandler.setCurrentDimension(((Respawn) packet).getDimension()); - connection.getProxyPlayer().getConnection().write(packet); } else if (packet instanceof BossBar) { BossBar bossBar = (BossBar) packet; switch (bossBar.getAction()) { @@ -42,7 +55,7 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { playerHandler.getServerBossBars().remove(bossBar.getUuid()); break; } - connection.getProxyPlayer().getConnection().write(packet); + connection.getPlayer().getConnection().write(packet); } else if (packet instanceof PluginMessage) { PluginMessage pm = (PluginMessage) packet; if (!canForwardPluginMessage(pm)) { @@ -50,45 +63,52 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { } if (PluginMessageUtil.isMCBrand(pm)) { - connection.getProxyPlayer().getConnection().write(PluginMessageUtil.rewriteMCBrand(pm)); + connection.getPlayer().getConnection().write(PluginMessageUtil.rewriteMCBrand(pm)); return; } - connection.getProxyPlayer().getConnection().write(pm); + MessageHandler.ForwardStatus status = VelocityServer.getServer().getChannelRegistrar().handlePluginMessage( + connection, ChannelSide.FROM_SERVER, pm); + if (status == MessageHandler.ForwardStatus.FORWARD) { + connection.getPlayer().getConnection().write(pm); + } } else { // Just forward the packet on. We don't have anything to handle at this time. - if (packet instanceof ScoreboardTeam || - packet instanceof ScoreboardObjective || - packet instanceof ScoreboardSetScore || - packet instanceof ScoreboardDisplay) { - playerHandler.handleServerScoreboardPacket(packet); - } - connection.getProxyPlayer().getConnection().write(packet); + connection.getPlayer().getConnection().write(packet); } } @Override public void handleUnknown(ByteBuf buf) { + if (!connection.getPlayer().isActive()) { + // Connection was left open accidentally. Close it so as to avoid "You logged in from another location" + // errors. + connection.getMinecraftConnection().close(); + return; + } + ClientPlaySessionHandler playerHandler = - (ClientPlaySessionHandler) connection.getProxyPlayer().getConnection().getSessionHandler(); + (ClientPlaySessionHandler) connection.getPlayer().getConnection().getSessionHandler(); ByteBuf remapped = playerHandler.getIdRemapper().remap(buf, ProtocolConstants.Direction.CLIENTBOUND); - connection.getProxyPlayer().getConnection().write(remapped); + connection.getPlayer().getConnection().write(remapped); } @Override public void exception(Throwable throwable) { - connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), throwable); + connection.getPlayer().handleConnectionException(connection.getServerInfo(), throwable); } private boolean canForwardPluginMessage(PluginMessage message) { ClientPlaySessionHandler playerHandler = - (ClientPlaySessionHandler) connection.getProxyPlayer().getConnection().getSessionHandler(); + (ClientPlaySessionHandler) connection.getPlayer().getConnection().getSessionHandler(); if (connection.getMinecraftConnection().getProtocolVersion() <= ProtocolConstants.MINECRAFT_1_12_2) { return message.getChannel().startsWith("MC|") || - playerHandler.getClientPluginMsgChannels().contains(message.getChannel()); + playerHandler.getClientPluginMsgChannels().contains(message.getChannel()) || + VelocityServer.getServer().getChannelRegistrar().registered(message.getChannel()); } else { return message.getChannel().startsWith("minecraft:") || - playerHandler.getClientPluginMsgChannels().contains(message.getChannel()); + playerHandler.getClientPluginMsgChannels().contains(message.getChannel()) || + VelocityServer.getServer().getChannelRegistrar().registered(message.getChannel()); } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java index e4219dd16..cd0513582 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java @@ -2,11 +2,12 @@ package com.velocitypowered.proxy.connection.backend; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; import com.velocitypowered.proxy.VelocityServer; -import com.velocitypowered.proxy.config.IPForwardingMode; +import com.velocitypowered.proxy.config.PlayerInfoForwarding; +import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; -import com.velocitypowered.proxy.data.GameProfile; +import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.StateRegistry; @@ -14,49 +15,40 @@ import com.velocitypowered.proxy.protocol.packet.*; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelPipeline; import net.kyori.text.TextComponent; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CompletableFuture; public class LoginSessionHandler implements MinecraftSessionHandler { - private final ServerConnection connection; - private ScheduledFuture forwardingCheckTask; + private final VelocityServerConnection connection; + private boolean informationForwarded; - public LoginSessionHandler(ServerConnection connection) { + public LoginSessionHandler(VelocityServerConnection connection) { this.connection = connection; } - @Override - public void activated() { - if (VelocityServer.getServer().getConfiguration().getIpForwardingMode() == IPForwardingMode.MODERN) { - forwardingCheckTask = connection.getMinecraftConnection().getChannel().eventLoop().schedule(() -> { - connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), - TextComponent.of("Your server did not send the forwarding request in time. Is it set up correctly?")); - }, 1, TimeUnit.SECONDS); - } - } - @Override public void handle(MinecraftPacket packet) { if (packet instanceof EncryptionRequest) { throw new IllegalStateException("Backend server is online-mode!"); } else if (packet instanceof LoginPluginMessage) { LoginPluginMessage message = (LoginPluginMessage) packet; - if (VelocityServer.getServer().getConfiguration().getIpForwardingMode() == IPForwardingMode.MODERN && + VelocityConfiguration configuration = VelocityServer.getServer().getConfiguration(); + if (configuration.getPlayerInfoForwardingMode() == PlayerInfoForwarding.MODERN && message.getChannel().equals(VelocityConstants.VELOCITY_IP_FORWARDING_CHANNEL)) { LoginPluginResponse response = new LoginPluginResponse(); response.setSuccess(true); response.setId(message.getId()); - response.setData(createForwardingData(connection.getProxyPlayer().getRemoteAddress().getHostString(), - connection.getProxyPlayer().getProfile())); + response.setData(createForwardingData(configuration.getForwardingSecret(), + connection.getPlayer().getRemoteAddress().getHostString(), + connection.getPlayer().getProfile())); connection.getMinecraftConnection().write(response); - cancelForwardingCheck(); - - ServerLogin login = new ServerLogin(); - login.setUsername(connection.getProxyPlayer().getUsername()); - connection.getMinecraftConnection().write(login); + informationForwarded = true; } else { // Don't understand LoginPluginResponse response = new LoginPluginResponse(); @@ -67,78 +59,92 @@ public class LoginSessionHandler implements MinecraftSessionHandler { } } else if (packet instanceof Disconnect) { Disconnect disconnect = (Disconnect) packet; - connection.disconnect(); - // Do we have an outstanding notification? If so, fulfill it. doNotify(ConnectionRequestResults.forDisconnect(disconnect)); - - connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), disconnect); + connection.disconnect(); } else if (packet instanceof SetCompression) { SetCompression sc = (SetCompression) packet; connection.getMinecraftConnection().setCompressionThreshold(sc.getThreshold()); } else if (packet instanceof ServerLoginSuccess) { + if (VelocityServer.getServer().getConfiguration().getPlayerInfoForwardingMode() == PlayerInfoForwarding.MODERN && + !informationForwarded) { + doNotify(ConnectionRequestResults.forDisconnect( + TextComponent.of("Your server did not send a forwarding request to the proxy. Is it set up correctly?"))); + connection.disconnect(); + return; + } + // The player has been logged on to the backend server. connection.getMinecraftConnection().setState(StateRegistry.PLAY); - ServerConnection existingConnection = connection.getProxyPlayer().getConnectedServer(); + VelocityServerConnection existingConnection = connection.getPlayer().getConnectedServer(); if (existingConnection == null) { // Strap on the play session handler - connection.getProxyPlayer().getConnection().setSessionHandler(new ClientPlaySessionHandler(connection.getProxyPlayer())); + connection.getPlayer().getConnection().setSessionHandler(new ClientPlaySessionHandler(connection.getPlayer())); } else { // The previous server connection should become obsolete. existingConnection.disconnect(); } - // Do we have an outstanding notification? If so, fulfill it. doNotify(ConnectionRequestResults.SUCCESSFUL); - connection.getMinecraftConnection().setSessionHandler(new BackendPlaySessionHandler(connection)); - connection.getProxyPlayer().setConnectedServer(connection); + connection.getPlayer().setConnectedServer(connection); } } - @Override - public void deactivated() { - cancelForwardingCheck(); - } - @Override public void exception(Throwable throwable) { - connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), throwable); + CompletableFuture future = connection.getMinecraftConnection().getChannel() + .attr(VelocityServerConnection.CONNECTION_NOTIFIER).getAndSet(null); + if (future != null) { + future.completeExceptionally(throwable); + } } private void doNotify(ConnectionRequestBuilder.Result result) { - ChannelPipeline pipeline = connection.getMinecraftConnection().getChannel().pipeline(); - ServerConnection.ConnectionNotifier n = pipeline.get(ServerConnection.ConnectionNotifier.class); - if (n != null) { - n.getResult().complete(result); - pipeline.remove(ServerConnection.ConnectionNotifier.class); + CompletableFuture future = connection.getMinecraftConnection().getChannel() + .attr(VelocityServerConnection.CONNECTION_NOTIFIER).getAndSet(null); + if (future != null) { + future.complete(result); } } - private void cancelForwardingCheck() { - if (forwardingCheckTask != null) { - forwardingCheckTask.cancel(false); - forwardingCheckTask = null; - } - } - - private static ByteBuf createForwardingData(String address, GameProfile profile) { - ByteBuf buf = Unpooled.buffer(); - ProtocolUtils.writeString(buf, address); - ProtocolUtils.writeUuid(buf, profile.idAsUuid()); - ProtocolUtils.writeString(buf, profile.getName()); - ProtocolUtils.writeVarInt(buf, profile.getProperties().size()); - for (GameProfile.Property property : profile.getProperties()) { - ProtocolUtils.writeString(buf, property.getName()); - ProtocolUtils.writeString(buf, property.getValue()); - String signature = property.getSignature(); - if (signature != null) { - buf.writeBoolean(true); - ProtocolUtils.writeString(buf, signature); - } else { - buf.writeBoolean(false); + static ByteBuf createForwardingData(byte[] hmacSecret, String address, GameProfile profile) { + ByteBuf dataToForward = Unpooled.buffer(); + ByteBuf finalData = Unpooled.buffer(); + try { + ProtocolUtils.writeString(dataToForward, address); + ProtocolUtils.writeUuid(dataToForward, profile.idAsUuid()); + ProtocolUtils.writeString(dataToForward, profile.getName()); + ProtocolUtils.writeVarInt(dataToForward, profile.getProperties().size()); + for (GameProfile.Property property : profile.getProperties()) { + ProtocolUtils.writeString(dataToForward, property.getName()); + ProtocolUtils.writeString(dataToForward, property.getValue()); + String signature = property.getSignature(); + if (signature != null) { + dataToForward.writeBoolean(true); + ProtocolUtils.writeString(dataToForward, signature); + } else { + dataToForward.writeBoolean(false); + } } + + SecretKey key = new SecretKeySpec(hmacSecret, "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(key); + mac.update(dataToForward.array(), dataToForward.arrayOffset(), dataToForward.readableBytes()); + byte[] sig = mac.doFinal(); + finalData.writeBytes(sig); + finalData.writeBytes(dataToForward); + return finalData; + } catch (InvalidKeyException e) { + finalData.release(); + throw new RuntimeException("Unable to authenticate data", e); + } catch (NoSuchAlgorithmException e) { + // Should never happen + finalData.release(); + throw new AssertionError(e); + } finally { + dataToForward.release(); } - return buf; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java similarity index 67% rename from proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java rename to proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java index 08f58325f..23e4f6062 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java @@ -1,44 +1,49 @@ package com.velocitypowered.proxy.connection.backend; +import com.google.common.base.Preconditions; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; -import com.velocitypowered.proxy.config.IPForwardingMode; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; +import com.velocitypowered.proxy.config.PlayerInfoForwarding; import com.velocitypowered.proxy.connection.MinecraftConnectionAssociation; -import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.netty.MinecraftDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftEncoder; import com.velocitypowered.proxy.protocol.netty.MinecraftVarintFrameDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftVarintLengthEncoder; import com.velocitypowered.proxy.protocol.packet.Handshake; +import com.velocitypowered.proxy.protocol.packet.PluginMessage; import com.velocitypowered.proxy.protocol.packet.ServerLogin; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.protocol.StateRegistry; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import io.netty.channel.*; import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.util.AttributeKey; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import static com.velocitypowered.network.Connections.FRAME_DECODER; -import static com.velocitypowered.network.Connections.FRAME_ENCODER; -import static com.velocitypowered.network.Connections.HANDLER; -import static com.velocitypowered.network.Connections.MINECRAFT_DECODER; -import static com.velocitypowered.network.Connections.MINECRAFT_ENCODER; -import static com.velocitypowered.network.Connections.READ_TIMEOUT; -import static com.velocitypowered.network.Connections.SERVER_READ_TIMEOUT_SECONDS; +import static com.velocitypowered.proxy.network.Connections.FRAME_DECODER; +import static com.velocitypowered.proxy.network.Connections.FRAME_ENCODER; +import static com.velocitypowered.proxy.network.Connections.HANDLER; +import static com.velocitypowered.proxy.network.Connections.MINECRAFT_DECODER; +import static com.velocitypowered.proxy.network.Connections.MINECRAFT_ENCODER; +import static com.velocitypowered.proxy.network.Connections.READ_TIMEOUT; +import static com.velocitypowered.proxy.network.Connections.SERVER_READ_TIMEOUT_SECONDS; -public class ServerConnection implements MinecraftConnectionAssociation { - static final String CONNECTION_NOTIFIER = "connection-notifier"; +public class VelocityServerConnection implements MinecraftConnectionAssociation, ServerConnection { + static final AttributeKey> CONNECTION_NOTIFIER = + AttributeKey.newInstance("connection-notification-result"); private final ServerInfo serverInfo; private final ConnectedPlayer proxyPlayer; private final VelocityServer server; private MinecraftConnection minecraftConnection; - public ServerConnection(ServerInfo target, ConnectedPlayer proxyPlayer, VelocityServer server) { + public VelocityServerConnection(ServerInfo target, ConnectedPlayer proxyPlayer, VelocityServer server) { this.serverInfo = target; this.proxyPlayer = proxyPlayer; this.server = server; @@ -55,12 +60,12 @@ public class ServerConnection implements MinecraftConnectionAssociation { .addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder()) .addLast(FRAME_ENCODER, MinecraftVarintLengthEncoder.INSTANCE) .addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolConstants.Direction.CLIENTBOUND)) - .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolConstants.Direction.SERVERBOUND)) - .addLast(CONNECTION_NOTIFIER, new ConnectionNotifier(result)); + .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolConstants.Direction.SERVERBOUND)); + ch.attr(CONNECTION_NOTIFIER).set(result); MinecraftConnection connection = new MinecraftConnection(ch); connection.setState(StateRegistry.HANDSHAKE); - connection.setAssociation(ServerConnection.this); + connection.setAssociation(VelocityServerConnection.this); ch.pipeline().addLast(HANDLER, connection); } }) @@ -72,7 +77,7 @@ public class ServerConnection implements MinecraftConnectionAssociation { minecraftConnection = future.channel().pipeline().get(MinecraftConnection.class); // Kick off the connection process - minecraftConnection.setSessionHandler(new LoginSessionHandler(ServerConnection.this)); + minecraftConnection.setSessionHandler(new LoginSessionHandler(VelocityServerConnection.this)); startHandshake(); } else { result.completeExceptionally(future.cause()); @@ -93,11 +98,13 @@ public class ServerConnection implements MinecraftConnectionAssociation { } private void startHandshake() { + PlayerInfoForwarding forwardingMode = VelocityServer.getServer().getConfiguration().getPlayerInfoForwardingMode(); + // Initiate a handshake. Handshake handshake = new Handshake(); handshake.setNextStatus(StateRegistry.LOGIN_ID); handshake.setProtocolVersion(proxyPlayer.getConnection().getProtocolVersion()); - if (VelocityServer.getServer().getConfiguration().getIpForwardingMode() == IPForwardingMode.LEGACY) { + if (forwardingMode == PlayerInfoForwarding.LEGACY) { handshake.setServerAddress(createBungeeForwardingAddress()); } else { handshake.setServerAddress(serverInfo.getAddress().getHostString()); @@ -109,17 +116,9 @@ public class ServerConnection implements MinecraftConnectionAssociation { minecraftConnection.setProtocolVersion(protocolVersion); minecraftConnection.setState(StateRegistry.LOGIN); - // Send the server login packet for <=1.12.2 and for 1.13+ servers not using "modern" forwarding. - if (protocolVersion <= ProtocolConstants.MINECRAFT_1_12_2 || - VelocityServer.getServer().getConfiguration().getIpForwardingMode() != IPForwardingMode.MODERN) { - ServerLogin login = new ServerLogin(); - login.setUsername(proxyPlayer.getUsername()); - minecraftConnection.write(login); - } - } - - public ConnectedPlayer getProxyPlayer() { - return proxyPlayer; + ServerLogin login = new ServerLogin(); + login.setUsername(proxyPlayer.getUsername()); + minecraftConnection.write(login); } public MinecraftConnection getMinecraftConnection() { @@ -130,6 +129,11 @@ public class ServerConnection implements MinecraftConnectionAssociation { return serverInfo; } + @Override + public ConnectedPlayer getPlayer() { + return proxyPlayer; + } + public void disconnect() { minecraftConnection.close(); minecraftConnection = null; @@ -140,24 +144,13 @@ public class ServerConnection implements MinecraftConnectionAssociation { return "[server connection] " + proxyPlayer.getProfile().getName() + " -> " + serverInfo.getName(); } - static class ConnectionNotifier extends ChannelInboundHandlerAdapter { - private final CompletableFuture result; - - public ConnectionNotifier(CompletableFuture result) { - this.result = result; - } - - public CompletableFuture getResult() { - return result; - } - - public void onComplete() { - result.complete(ConnectionRequestResults.SUCCESSFUL); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { - result.completeExceptionally(cause); - } + @Override + public void sendPluginMessage(ChannelIdentifier identifier, byte[] data) { + Preconditions.checkNotNull(identifier, "identifier"); + Preconditions.checkNotNull(data, "data"); + PluginMessage message = new PluginMessage(); + message.setChannel(identifier.getId()); + message.setData(data); + minecraftConnection.write(message); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index bc9c11986..d5f040d2c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -1,47 +1,41 @@ package com.velocitypowered.proxy.connection.client; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.proxy.messages.ChannelSide; +import com.velocitypowered.api.proxy.messages.MessageHandler; import com.velocitypowered.proxy.VelocityServer; -import com.velocitypowered.proxy.connection.backend.ServerConnection; -import com.velocitypowered.api.server.ServerInfo; -import com.velocitypowered.proxy.data.scoreboard.Objective; -import com.velocitypowered.proxy.data.scoreboard.Score; -import com.velocitypowered.proxy.data.scoreboard.Scoreboard; -import com.velocitypowered.proxy.data.scoreboard.Team; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; -import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.packet.*; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import com.velocitypowered.proxy.protocol.remap.EntityIdRemapper; import com.velocitypowered.proxy.protocol.util.PluginMessageUtil; import com.velocitypowered.proxy.util.ThrowableUtils; import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; import io.netty.channel.EventLoop; -import io.netty.util.ReferenceCountUtil; import net.kyori.text.TextComponent; import net.kyori.text.format.TextColor; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.net.InetSocketAddress; import java.util.*; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +/** + * Handles communication with the connected Minecraft client. This is effectively the primary nerve center that + * joins backend servers with players. + */ public class ClientPlaySessionHandler implements MinecraftSessionHandler { private static final Logger logger = LogManager.getLogger(ClientPlaySessionHandler.class); private static final int MAX_PLUGIN_CHANNELS = 128; private final ConnectedPlayer player; - private ScheduledFuture pingTask; private long lastPing = -1; private boolean spawned = false; private final List serverBossBars = new ArrayList<>(); private final Set clientPluginMsgChannels = new HashSet<>(); - private int currentDimension; - private Scoreboard serverScoreboard = new Scoreboard(); private EntityIdRemapper idRemapper; public ClientPlaySessionHandler(ConnectedPlayer player) { @@ -50,16 +44,13 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { @Override public void activated() { - EventLoop loop = player.getConnection().getChannel().eventLoop(); - pingTask = loop.scheduleAtFixedRate(this::ping, 5, 15, TimeUnit.SECONDS); - } - - private void ping() { - long randomId = ThreadLocalRandom.current().nextInt(); - lastPing = randomId; - KeepAlive keepAlive = new KeepAlive(); - keepAlive.setRandomId(randomId); - player.getConnection().write(keepAlive); + PluginMessage message; + if (player.getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_13) { + message = PluginMessageUtil.constructChannelsPacket("minecraft:register", VelocityServer.getServer().getChannelRegistrar().getModernChannelIds()); + } else { + message = PluginMessageUtil.constructChannelsPacket("REGISTER", VelocityServer.getServer().getChannelRegistrar().getLegacyChannelIds()); + } + player.getConnection().write(message); } @Override @@ -67,11 +58,10 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { if (packet instanceof KeepAlive) { KeepAlive keepAlive = (KeepAlive) packet; if (keepAlive.getRandomId() != lastPing) { - throw new IllegalStateException("Client sent invalid keepAlive; expected " + lastPing + ", got " + keepAlive.getRandomId()); + // The last keep alive we got was probably from a different server. Let's ignore it, and hope the next + // ping is alright. + return; } - - // Do not forward the packet to the player's server, because we handle pings for all servers already. - return; } if (packet instanceof ClientSettings) { @@ -80,10 +70,49 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } if (packet instanceof Chat) { + // Try to handle any commands on the proxy. If that fails, send it onto the client. Chat chat = (Chat) packet; - if (chat.getMessage().equals("/connect")) { - ServerInfo info = new ServerInfo("test", new InetSocketAddress("localhost", 25566)); - player.createConnectionRequest(info).fireAndForget(); + String msg = ((Chat) packet).getMessage(); + if (msg.startsWith("/")) { + try { + if (!VelocityServer.getServer().getCommandManager().execute(player, msg.substring(1))) { + player.getConnectedServer().getMinecraftConnection().write(chat); + } + } catch (Exception e) { + logger.info("Exception occurred while running command for {}", player.getProfile().getName(), e); + player.sendMessage(TextComponent.of("An error occurred while running this command.", TextColor.RED)); + return; + } + } else { + player.getConnectedServer().getMinecraftConnection().write(chat); + } + return; + } + + if (packet instanceof TabCompleteRequest) { + TabCompleteRequest req = (TabCompleteRequest) packet; + int lastSpace = req.getCommand().indexOf(' '); + if (!req.isAssumeCommand() && lastSpace != -1) { + String command = req.getCommand().substring(1); + try { + Optional> offers = VelocityServer.getServer().getCommandManager().offerSuggestions(player, command); + if (offers.isPresent()) { + TabCompleteResponse response = new TabCompleteResponse(); + response.setTransactionId(req.getTransactionId()); + response.setStart(lastSpace); + response.setLength(req.getCommand().length() - lastSpace); + + for (String s : offers.get()) { + response.getOffers().add(new TabCompleteResponse.Offer(s, null)); + } + + player.getConnection().write(response); + } else { + player.getConnectedServer().getMinecraftConnection().write(packet); + } + } catch (Exception e) { + logger.error("Unable to provide tab list completions for " + player.getUsername() + " for command '" + req.getCommand() + "'", e); + } return; } } @@ -106,11 +135,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { @Override public void disconnected() { player.teardown(); - - if (pingTask != null && !pingTask.isCancelled()) { - pingTask.cancel(false); - pingTask = null; - } + VelocityServer.getServer().getEventManager().fireAndForget(new DisconnectEvent(player)); } @Override @@ -123,21 +148,22 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } public void handleBackendJoinGame(JoinGame joinGame) { + lastPing = Long.MIN_VALUE; // reset last ping if (!spawned) { // nothing special to do here spawned = true; - currentDimension = joinGame.getDimension(); player.getConnection().delayedWrite(joinGame); idRemapper = EntityIdRemapper.getMapper(joinGame.getEntityId(), player.getConnection().getProtocolVersion()); } else { - // In order to handle switching to another server we will need send three packets: + // Ah, this is the meat and potatoes of the whole venture! + // + // In order to handle switching to another server, you will need to send three packets: // // - The join game packet from the backend server // - A respawn packet with a different dimension // - Another respawn with the correct dimension // - // We can't simply ignore the packet with the different dimension. If you try to be smart about it it doesn't - // work. + // The two respawns with different dimensions are required, otherwise the client gets confused. // // Most notably, by having the client accept the join game packet, we can work around the need to perform // entity ID rewrites, eliminating potential issues from rewriting packets and improving compatibility with @@ -147,13 +173,6 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { int tempDim = joinGame.getDimension() == 0 ? -1 : 0; player.getConnection().delayedWrite(new Respawn(tempDim, joinGame.getDifficulty(), joinGame.getGamemode(), joinGame.getLevelType())); player.getConnection().delayedWrite(new Respawn(joinGame.getDimension(), joinGame.getDifficulty(), joinGame.getGamemode(), joinGame.getLevelType())); - currentDimension = joinGame.getDimension(); - } - - // Resend client settings packet to remote server if we have it, this preserves client settings across - // transitions. - if (player.getClientSettings() != null) { - player.getConnectedServer().getMinecraftConnection().delayedWrite(player.getClientSettings()); } // Remove old boss bars. @@ -165,15 +184,18 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } serverBossBars.clear(); - // Remove scoreboard junk. - clearServerScoreboard(); - // Tell the server about this client's plugin messages. Velocity will forward them on to the client. - if (!clientPluginMsgChannels.isEmpty()) { + Collection toRegister = new HashSet<>(clientPluginMsgChannels); + if (player.getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_13) { + toRegister.addAll(VelocityServer.getServer().getChannelRegistrar().getModernChannelIds()); + } else { + toRegister.addAll(VelocityServer.getServer().getChannelRegistrar().getLegacyChannelIds()); + } + if (!toRegister.isEmpty()) { String channel = player.getConnection().getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_13 ? "minecraft:register" : "REGISTER"; - player.getConnectedServer().getMinecraftConnection().delayedWrite( - PluginMessageUtil.constructChannelsPacket(channel, clientPluginMsgChannels)); + player.getConnectedServer().getMinecraftConnection().delayedWrite(PluginMessageUtil.constructChannelsPacket( + channel, toRegister)); } // Flush everything @@ -181,10 +203,6 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { player.getConnectedServer().getMinecraftConnection().flush(); } - public void setCurrentDimension(int currentDimension) { - this.currentDimension = currentDimension; - } - public List getServerBossBars() { return serverBossBars; } @@ -204,7 +222,6 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } if (actuallyRegistered.size() > 0) { - logger.info("Rewritten register packet: {}", actuallyRegistered); PluginMessage newRegisterPacket = PluginMessageUtil.constructChannelsPacket(packet.getChannel(), actuallyRegistered); player.getConnectedServer().getMinecraftConnection().write(newRegisterPacket); } @@ -222,88 +239,12 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { return; } - // We're going to forward on the original packet. - player.getConnectedServer().getMinecraftConnection().write(packet); - } - - public void handleServerScoreboardPacket(MinecraftPacket packet) { - if (packet instanceof ScoreboardDisplay) { - ScoreboardDisplay sd = (ScoreboardDisplay) packet; - serverScoreboard.setPosition(sd.getPosition()); - serverScoreboard.setDisplayName(sd.getDisplayName()); + MessageHandler.ForwardStatus status = VelocityServer.getServer().getChannelRegistrar().handlePluginMessage( + player, ChannelSide.FROM_CLIENT, packet); + if (status == MessageHandler.ForwardStatus.FORWARD) { + // We're going to forward on the original packet. + player.getConnectedServer().getMinecraftConnection().write(packet); } - - if (packet instanceof ScoreboardObjective) { - ScoreboardObjective so = (ScoreboardObjective) packet; - switch (so.getMode()) { - case ScoreboardObjective.ADD: - Objective o = new Objective(so.getId()); - o.setDisplayName(so.getDisplayName()); - o.setType(so.getType()); - serverScoreboard.getObjectives().put(so.getId(), o); - break; - case ScoreboardObjective.REMOVE: - serverScoreboard.getObjectives().remove(so.getId()); - break; - } - } - - if (packet instanceof ScoreboardSetScore) { - ScoreboardSetScore sss = (ScoreboardSetScore) packet; - Objective objective = serverScoreboard.getObjectives().get(sss.getObjective()); - if (objective == null) { - return; - } - switch (sss.getAction()) { - case ScoreboardSetScore.CHANGE: - Score score = new Score(sss.getEntity(), sss.getValue()); - objective.getScores().put(sss.getEntity(), score); - break; - case ScoreboardSetScore.REMOVE: - objective.getScores().remove(sss.getEntity()); - break; - } - } - - if (packet instanceof ScoreboardTeam) { - ScoreboardTeam st = (ScoreboardTeam) packet; - switch (st.getMode()) { - case ScoreboardTeam.ADD: - // TODO: Preserve other team information? We might not need to... - Team team = new Team(st.getId()); - serverScoreboard.getTeams().put(st.getId(), team); - break; - case ScoreboardTeam.REMOVE: - serverScoreboard.getTeams().remove(st.getId()); - break; - } - } - } - - private void clearServerScoreboard() { - for (Objective objective : serverScoreboard.getObjectives().values()) { - for (Score score : objective.getScores().values()) { - ScoreboardSetScore sss = new ScoreboardSetScore(); - sss.setObjective(objective.getId()); - sss.setAction(ScoreboardSetScore.REMOVE); - sss.setEntity(score.getTarget()); - player.getConnection().delayedWrite(sss); - } - - ScoreboardObjective so = new ScoreboardObjective(); - so.setId(objective.getId()); - so.setMode(ScoreboardObjective.REMOVE); - player.getConnection().delayedWrite(so); - } - - for (Team team : serverScoreboard.getTeams().values()) { - ScoreboardTeam st = new ScoreboardTeam(); - st.setId(team.getId()); - st.setMode(ScoreboardTeam.REMOVE); - player.getConnection().delayedWrite(st); - } - - serverScoreboard = new Scoreboard(); } public Set getClientPluginMsgChannels() { @@ -313,4 +254,8 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { public EntityIdRemapper getIdRemapper() { return idRemapper; } + + public void setLastPing(long lastPing) { + this.lastPing = lastPing; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index 55f89951a..1660f68b4 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -2,21 +2,29 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; import com.google.gson.JsonObject; +import com.velocitypowered.api.event.player.ServerPreConnectEvent; +import com.velocitypowered.api.permission.PermissionFunction; +import com.velocitypowered.api.permission.PermissionProvider; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; import com.velocitypowered.api.util.MessagePosition; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.MinecraftConnectionAssociation; import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; -import com.velocitypowered.proxy.data.GameProfile; +import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.proxy.protocol.packet.Chat; import com.velocitypowered.proxy.connection.MinecraftConnection; -import com.velocitypowered.proxy.connection.backend.ServerConnection; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.protocol.packet.ClientSettings; +import com.velocitypowered.proxy.protocol.packet.PluginMessage; import com.velocitypowered.proxy.util.ThrowableUtils; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.proxy.protocol.packet.Disconnect; +import com.velocitypowered.proxy.protocol.packet.HeaderAndFooter; + import net.kyori.text.Component; import net.kyori.text.TextComponent; import net.kyori.text.TranslatableComponent; @@ -25,8 +33,8 @@ import net.kyori.text.serializer.ComponentSerializers; import net.kyori.text.serializer.PlainComponentSerializer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.checkerframework.checker.nullness.qual.NonNull; -import javax.annotation.Nonnull; import java.net.InetSocketAddress; import java.util.List; import java.util.Optional; @@ -35,19 +43,23 @@ import java.util.concurrent.CompletableFuture; public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { private static final PlainComponentSerializer PASS_THRU_TRANSLATE = new PlainComponentSerializer((c) -> "", TranslatableComponent::key); + public static final PermissionProvider DEFAULT_PERMISSIONS = s -> PermissionFunction.ALWAYS_UNDEFINED; private static final Logger logger = LogManager.getLogger(ConnectedPlayer.class); - private final GameProfile profile; private final MinecraftConnection connection; + private final InetSocketAddress virtualHost; + private final GameProfile profile; + private PermissionFunction permissionFunction = null; private int tryIndex = 0; - private ServerConnection connectedServer; + private VelocityServerConnection connectedServer; private ClientSettings clientSettings; - private ServerConnection connectionInFlight; + private VelocityServerConnection connectionInFlight; - public ConnectedPlayer(GameProfile profile, MinecraftConnection connection) { + public ConnectedPlayer(GameProfile profile, MinecraftConnection connection, InetSocketAddress virtualHost) { this.profile = profile; this.connection = connection; + this.virtualHost = virtualHost; } @Override @@ -61,8 +73,8 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { } @Override - public Optional getCurrentServer() { - return connectedServer != null ? Optional.of(connectedServer.getServerInfo()) : Optional.empty(); + public Optional getCurrentServer() { + return Optional.ofNullable(connectedServer); } public GameProfile getProfile() { @@ -78,13 +90,27 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { return (InetSocketAddress) connection.getChannel().remoteAddress(); } + @Override + public Optional getVirtualHost() { + return Optional.ofNullable(virtualHost); + } + + public void setPermissionFunction(PermissionFunction permissionFunction) { + this.permissionFunction = permissionFunction; + } + @Override public boolean isActive() { return connection.getChannel().isActive(); } @Override - public void sendMessage(@Nonnull Component component, @Nonnull MessagePosition position) { + public int getProtocolVersion() { + return connection.getProtocolVersion(); + } + + @Override + public void sendMessage(@NonNull Component component, @NonNull MessagePosition position) { Preconditions.checkNotNull(component, "component"); Preconditions.checkNotNull(position, "position"); @@ -107,11 +133,28 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { } @Override - public ConnectionRequestBuilder createConnectionRequest(@Nonnull ServerInfo info) { + public ConnectionRequestBuilder createConnectionRequest(@NonNull ServerInfo info) { return new ConnectionRequestBuilderImpl(info); } - public ServerConnection getConnectedServer() { + @Override + public void setHeaderAndFooter(@NonNull Component header, @NonNull Component footer) { + Preconditions.checkNotNull(header, "header"); + Preconditions.checkNotNull(footer, "footer"); + connection.write(HeaderAndFooter.create(header, footer)); + } + + @Override + public void clearHeaderAndFooter() { + connection.write(HeaderAndFooter.reset()); + } + + @Override + public void disconnect(Component reason) { + connection.closeWith(Disconnect.create(reason)); + } + + public VelocityServerConnection getConnectedServer() { return connectedServer; } @@ -127,7 +170,6 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { String error = ThrowableUtils.briefDescription(throwable); String userMessage; if (connectedServer != null && connectedServer.getServerInfo().equals(info)) { - logger.error("{}: exception occurred in connection to {}", this, info.getName(), throwable); userMessage = "Exception in server " + info.getName(); } else { logger.error("{}: unable to connect to server {}", this, info.getName(), throwable); @@ -153,9 +195,17 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { public void handleConnectionException(ServerInfo info, Component disconnectReason) { connectionInFlight = null; - if (connectedServer == null || connectedServer.getServerInfo().equals(info)) { - // The player isn't yet connected to a server or they are already connected to the server - // they're disconnected from. + if (connectedServer == null) { + // The player isn't yet connected to a server. + Optional nextServer = getNextServerToTry(); + if (nextServer.isPresent()) { + createConnectionRequest(nextServer.get()).fireAndForget(); + } else { + connection.closeWith(Disconnect.create(disconnectReason)); + } + } else if (connectedServer.getServerInfo().equals(info)) { + // Already connected to the server being disconnected from. + // TODO: ServerKickEvent connection.closeWith(Disconnect.create(disconnectReason)); } else { connection.write(Chat.create(disconnectReason)); @@ -187,11 +237,20 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { } // Otherwise, initiate the connection. - ServerConnection connection = new ServerConnection(request.getServer(), this, VelocityServer.getServer()); - return connection.connect(); + ServerPreConnectEvent event = new ServerPreConnectEvent(this, ServerPreConnectEvent.ServerResult.allowed(request.getServer())); + return VelocityServer.getServer().getEventManager().fire(event) + .thenCompose((newEvent) -> { + if (!newEvent.getResult().isAllowed()) { + return CompletableFuture.completedFuture( + ConnectionRequestResults.plainResult(ConnectionRequestBuilder.Status.CONNECTION_CANCELLED) + ); + } + + return new VelocityServerConnection(newEvent.getResult().getInfo().get(), this, VelocityServer.getServer()).connect(); + }); } - public void setConnectedServer(ServerConnection serverConnection) { + public void setConnectedServer(VelocityServerConnection serverConnection) { if (this.connectedServer != null && !serverConnection.getServerInfo().equals(connectedServer.getServerInfo())) { this.tryIndex = 0; } @@ -209,6 +268,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { if (connectedServer != null) { connectedServer.disconnect(); } + VelocityServer.getServer().unregisterConnection(this); } @Override @@ -216,10 +276,25 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { return "[connected player] " + getProfile().getName() + " (" + getRemoteAddress() + ")"; } + @Override + public boolean hasPermission(String permission) { + return permissionFunction.getPermissionSetting(permission).asBoolean(); + } + + @Override + public void sendPluginMessage(ChannelIdentifier identifier, byte[] data) { + Preconditions.checkNotNull(identifier, "identifier"); + Preconditions.checkNotNull(data, "data"); + PluginMessage message = new PluginMessage(); + message.setChannel(identifier.getId()); + message.setData(data); + connection.write(message); + } + private class ConnectionRequestBuilderImpl implements ConnectionRequestBuilder { private final ServerInfo info; - public ConnectionRequestBuilderImpl(ServerInfo info) { + ConnectionRequestBuilderImpl(ServerInfo info) { this.info = Preconditions.checkNotNull(info, "info"); } @@ -236,7 +311,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { @Override public void fireAndForget() { connect() - .whenComplete((status, throwable) -> { + .whenCompleteAsync((status, throwable) -> { if (throwable != null) { handleConnectionException(info, throwable); return; @@ -256,7 +331,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { handleConnectionException(info, Disconnect.create(status.getReason().orElse(ConnectionMessages.INTERNAL_SERVER_CONNECTION_ERROR))); break; } - }); + }, connection.getChannel().eventLoop()); } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java index 1b9ca2ba7..58ce7bff5 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java @@ -1,14 +1,28 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.event.connection.ConnectionHandshakeEvent; +import com.velocitypowered.api.event.proxy.ProxyPingEvent; +import com.velocitypowered.api.proxy.InboundConnection; +import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; +import com.velocitypowered.api.proxy.server.ServerPing; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.StateRegistry; -import com.velocitypowered.proxy.protocol.packet.Disconnect; -import com.velocitypowered.proxy.protocol.packet.Handshake; +import com.velocitypowered.proxy.protocol.packet.*; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import net.kyori.text.TextComponent; import net.kyori.text.TranslatableComponent; +import net.kyori.text.format.TextColor; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Optional; public class HandshakeSessionHandler implements MinecraftSessionHandler { private final MinecraftConnection connection; @@ -19,16 +33,24 @@ public class HandshakeSessionHandler implements MinecraftSessionHandler { @Override public void handle(MinecraftPacket packet) { + if (packet instanceof LegacyPing || packet instanceof LegacyHandshake) { + connection.setProtocolVersion(ProtocolConstants.LEGACY); + handleLegacy(packet); + return; + } + if (!(packet instanceof Handshake)) { throw new IllegalArgumentException("Did not expect packet " + packet.getClass().getName()); } + InitialInboundConnection ic = new InitialInboundConnection(connection, (Handshake) packet); + Handshake handshake = (Handshake) packet; switch (handshake.getNextStatus()) { case StateRegistry.STATUS_ID: connection.setState(StateRegistry.STATUS); connection.setProtocolVersion(handshake.getProtocolVersion()); - connection.setSessionHandler(new StatusSessionHandler(connection)); + connection.setSessionHandler(new StatusSessionHandler(connection, ic)); break; case StateRegistry.LOGIN_ID: connection.setState(StateRegistry.LOGIN); @@ -37,12 +59,70 @@ public class HandshakeSessionHandler implements MinecraftSessionHandler { connection.closeWith(Disconnect.create(TranslatableComponent.of("multiplayer.disconnect.outdated_client"))); return; } else { - connection.setSessionHandler(new LoginSessionHandler(connection)); + InetAddress address = ((InetSocketAddress) connection.getChannel().remoteAddress()).getAddress(); + if (!VelocityServer.getServer().getIpAttemptLimiter().attempt(address)) { + connection.closeWith(Disconnect.create(TextComponent.of("You are logging in too fast, try again later."))); + return; + } + VelocityServer.getServer().getEventManager().fireAndForget(new ConnectionHandshakeEvent(ic)); + connection.setSessionHandler(new LoginSessionHandler(connection, ic)); } break; default: throw new IllegalArgumentException("Invalid state " + handshake.getNextStatus()); } + } + @Override + public void handleUnknown(ByteBuf buf) { + throw new IllegalStateException("Unknown data " + ByteBufUtil.hexDump(buf)); + } + + private void handleLegacy(MinecraftPacket packet) { + if (packet instanceof LegacyPing) { + VelocityConfiguration configuration = VelocityServer.getServer().getConfiguration(); + ServerPing ping = new ServerPing( + new ServerPing.Version(ProtocolConstants.MAXIMUM_GENERIC_VERSION, "Velocity " + ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING), + new ServerPing.Players(VelocityServer.getServer().getPlayerCount(), configuration.getShowMaxPlayers(), ImmutableList.of()), + configuration.getMotdComponent(), + null + ); + ProxyPingEvent event = new ProxyPingEvent(new LegacyInboundConnection(connection), ping); + VelocityServer.getServer().getEventManager().fire(event) + .thenRunAsync(() -> { + // The disconnect packet is the same as the server response one. + connection.closeWith(LegacyDisconnect.fromPingResponse(LegacyPingResponse.from(event.getPing()))); + }, connection.getChannel().eventLoop()); + } else if (packet instanceof LegacyHandshake) { + connection.closeWith(LegacyDisconnect.from(TextComponent.of("Your client is old, please upgrade!", TextColor.RED))); + } + } + + private static class LegacyInboundConnection implements InboundConnection { + private final MinecraftConnection connection; + + private LegacyInboundConnection(MinecraftConnection connection) { + this.connection = connection; + } + + @Override + public InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) connection.getChannel().remoteAddress(); + } + + @Override + public Optional getVirtualHost() { + return Optional.empty(); + } + + @Override + public boolean isActive() { + return !connection.isClosed(); + } + + @Override + public int getProtocolVersion() { + return 0; + } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialInboundConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialInboundConnection.java new file mode 100644 index 000000000..6c4ba9069 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialInboundConnection.java @@ -0,0 +1,38 @@ +package com.velocitypowered.proxy.connection.client; + +import com.velocitypowered.api.proxy.InboundConnection; +import com.velocitypowered.proxy.connection.MinecraftConnection; +import com.velocitypowered.proxy.protocol.packet.Handshake; + +import java.net.InetSocketAddress; +import java.util.Optional; + +class InitialInboundConnection implements InboundConnection { + private final MinecraftConnection connection; + private final Handshake handshake; + + InitialInboundConnection(MinecraftConnection connection, Handshake handshake) { + this.connection = connection; + this.handshake = handshake; + } + + @Override + public InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) connection.getChannel().remoteAddress(); + } + + @Override + public Optional getVirtualHost() { + return Optional.of(InetSocketAddress.createUnresolved(handshake.getServerAddress(), handshake.getPort())); + } + + @Override + public boolean isActive() { + return connection.getChannel().isActive(); + } + + @Override + public int getProtocolVersion() { + return connection.getProtocolVersion(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java index 12e69a4eb..67b3cdf82 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java @@ -1,8 +1,15 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; +import com.velocitypowered.api.event.connection.LoginEvent; +import com.velocitypowered.api.event.connection.PreLoginEvent; +import com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult; +import com.velocitypowered.api.event.permission.PermissionsSetupEvent; +import com.velocitypowered.api.event.player.GameProfileRequestEvent; +import com.velocitypowered.api.proxy.InboundConnection; +import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.proxy.connection.VelocityConstants; -import com.velocitypowered.proxy.data.GameProfile; +import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.StateRegistry; @@ -10,8 +17,9 @@ import com.velocitypowered.proxy.protocol.packet.*; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import com.velocitypowered.proxy.VelocityServer; -import com.velocitypowered.api.server.ServerInfo; import com.velocitypowered.proxy.util.EncryptionUtils; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; import net.kyori.text.TextComponent; import net.kyori.text.format.TextColor; @@ -19,6 +27,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.net.InetSocketAddress; +import java.net.MalformedURLException; import java.net.URL; import java.security.GeneralSecurityException; import java.security.KeyPair; @@ -30,82 +39,115 @@ public class LoginSessionHandler implements MinecraftSessionHandler { private static final Logger logger = LogManager.getLogger(LoginSessionHandler.class); private static final String MOJANG_SERVER_AUTH_URL = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username=%s&serverId=%s&ip=%s"; - + private final MinecraftConnection inbound; + private final InboundConnection apiInbound; private ServerLogin login; private byte[] verify; private int playerInfoId; - public LoginSessionHandler(MinecraftConnection inbound) { + public LoginSessionHandler(MinecraftConnection inbound, InboundConnection apiInbound) { this.inbound = Preconditions.checkNotNull(inbound, "inbound"); + this.apiInbound = Preconditions.checkNotNull(apiInbound, "apiInbound"); } @Override - public void activated() { - if (inbound.getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_13) { - LoginPluginMessage message = new LoginPluginMessage(); - playerInfoId = ThreadLocalRandom.current().nextInt(); - message.setId(playerInfoId); - message.setChannel(VelocityConstants.VELOCITY_IP_FORWARDING_CHANNEL); - message.setData(Unpooled.EMPTY_BUFFER); - inbound.write(message); - } - } - - @Override - public void handle(MinecraftPacket packet) throws Exception { + public void handle(MinecraftPacket packet) { if (packet instanceof LoginPluginResponse) { LoginPluginResponse lpr = (LoginPluginResponse) packet; - if (lpr.getId() == playerInfoId && lpr.isSuccess()) { - // Uh oh, someone's trying to run Velocity behind Velocity. We don't want that happening. - inbound.closeWith(Disconnect.create( - TextComponent.of("Running Velocity behind Velocity isn't supported.", TextColor.RED) - )); + if (lpr.getId() == playerInfoId) { + if (lpr.isSuccess()) { + // Uh oh, someone's trying to run Velocity behind Velocity. We don't want that happening. + inbound.closeWith(Disconnect.create( + TextComponent.of("Running Velocity behind Velocity isn't supported.", TextColor.RED) + )); + } else { + // Proceed with the regular login process. + beginPreLogin(); + } } } else if (packet instanceof ServerLogin) { this.login = (ServerLogin) packet; - if (VelocityServer.getServer().getConfiguration().isOnlineMode()) { - // Request encryption. - EncryptionRequest request = generateRequest(); - this.verify = Arrays.copyOf(request.getVerifyToken(), 4); - inbound.write(request); + if (inbound.getProtocolVersion() >= ProtocolConstants.MINECRAFT_1_13) { + LoginPluginMessage message = new LoginPluginMessage(); + playerInfoId = ThreadLocalRandom.current().nextInt(); + message.setId(playerInfoId); + message.setChannel(VelocityConstants.VELOCITY_IP_FORWARDING_CHANNEL); + message.setData(Unpooled.EMPTY_BUFFER); + inbound.write(message); } else { - // Offline-mode, don't try to request encryption. - handleSuccessfulLogin(GameProfile.forOfflinePlayer(login.getUsername())); + beginPreLogin(); } } else if (packet instanceof EncryptionResponse) { - KeyPair serverKeyPair = VelocityServer.getServer().getServerKeyPair(); - EncryptionResponse response = (EncryptionResponse) packet; - byte[] decryptedVerifyToken = EncryptionUtils.decryptRsa(serverKeyPair, response.getVerifyToken()); - if (!Arrays.equals(verify, decryptedVerifyToken)) { - throw new IllegalStateException("Unable to successfully decrypt the verification token."); + try { + KeyPair serverKeyPair = VelocityServer.getServer().getServerKeyPair(); + EncryptionResponse response = (EncryptionResponse) packet; + byte[] decryptedVerifyToken = EncryptionUtils.decryptRsa(serverKeyPair, response.getVerifyToken()); + if (!Arrays.equals(verify, decryptedVerifyToken)) { + throw new IllegalStateException("Unable to successfully decrypt the verification token."); + } + + byte[] decryptedSharedSecret = EncryptionUtils.decryptRsa(serverKeyPair, response.getSharedSecret()); + String serverId = EncryptionUtils.generateServerId(decryptedSharedSecret, serverKeyPair.getPublic()); + + String playerIp = ((InetSocketAddress) inbound.getChannel().remoteAddress()).getHostString(); + VelocityServer.getServer().getHttpClient() + .get(new URL(String.format(MOJANG_SERVER_AUTH_URL, login.getUsername(), serverId, playerIp))) + .thenAcceptAsync(profileResponse -> { + if (inbound.isClosed()) { + // The player disconnected after we authenticated them. + return; + } + + try { + inbound.enableEncryption(decryptedSharedSecret); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + + initializePlayer(VelocityServer.GSON.fromJson(profileResponse, GameProfile.class), true); + }, inbound.getChannel().eventLoop()) + .exceptionally(exception -> { + logger.error("Unable to enable encryption", exception); + inbound.close(); + return null; + }); + } catch (GeneralSecurityException e) { + logger.error("Unable to enable encryption", e); + inbound.close(); + } catch (MalformedURLException e) { + throw new AssertionError(e); } - - byte[] decryptedSharedSecret = EncryptionUtils.decryptRsa(serverKeyPair, response.getSharedSecret()); - String serverId = EncryptionUtils.generateServerId(decryptedSharedSecret, serverKeyPair.getPublic()); - - String playerIp = ((InetSocketAddress) inbound.getChannel().remoteAddress()).getHostString(); - VelocityServer.getServer().getHttpClient() - .get(new URL(String.format(MOJANG_SERVER_AUTH_URL, login.getUsername(), serverId, playerIp))) - .thenAcceptAsync(profileResponse -> { - try { - inbound.enableEncryption(decryptedSharedSecret); - } catch (GeneralSecurityException e) { - throw new RuntimeException(e); - } - - GameProfile profile = VelocityServer.GSON.fromJson(profileResponse, GameProfile.class); - handleSuccessfulLogin(profile); - }, inbound.getChannel().eventLoop()) - .exceptionally(exception -> { - logger.error("Unable to enable encryption", exception); - inbound.close(); - return null; - }); } } + private void beginPreLogin() { + PreLoginEvent event = new PreLoginEvent(apiInbound, login.getUsername()); + VelocityServer.getServer().getEventManager().fire(event) + .thenRunAsync(() -> { + if (inbound.isClosed()) { + // The player was disconnected + return; + } + PreLoginComponentResult result = event.getResult(); + if (!result.isAllowed()) { + // The component is guaranteed to be provided if the connection was denied. + inbound.closeWith(Disconnect.create(event.getResult().getReason().get())); + return; + } + + if (VelocityServer.getServer().getConfiguration().isOnlineMode() || result.isOnlineModeAllowed()) { + // Request encryption. + EncryptionRequest request = generateRequest(); + this.verify = Arrays.copyOf(request.getVerifyToken(), 4); + inbound.write(request); + } else { + initializePlayer(GameProfile.forOfflinePlayer(login.getUsername()), false); + } + }, inbound.getChannel().eventLoop()); + } + private EncryptionRequest generateRequest() { byte[] verify = new byte[4]; ThreadLocalRandom.current().nextBytes(verify); @@ -116,9 +158,40 @@ public class LoginSessionHandler implements MinecraftSessionHandler { return request; } - private void handleSuccessfulLogin(GameProfile profile) { - // Initiate a regular connection and move over to it. - ConnectedPlayer player = new ConnectedPlayer(profile, inbound); + private void initializePlayer(GameProfile profile, boolean onlineMode) { + GameProfileRequestEvent profileRequestEvent = new GameProfileRequestEvent(apiInbound, profile, onlineMode); + + VelocityServer.getServer().getEventManager().fire(profileRequestEvent).thenCompose(profileEvent -> { + // Initiate a regular connection and move over to it. + ConnectedPlayer player = new ConnectedPlayer(profileEvent.getGameProfile(), inbound, + apiInbound.getVirtualHost().orElse(null)); + + return VelocityServer.getServer().getEventManager().fire(new PermissionsSetupEvent(player, ConnectedPlayer.DEFAULT_PERMISSIONS)) + .thenCompose(event -> { + // wait for permissions to load, then set the players permission function + player.setPermissionFunction(event.createFunction(player)); + // then call & wait for the login event + return VelocityServer.getServer().getEventManager().fire(new LoginEvent(player)); + }) + // then complete the connection + .thenAcceptAsync(event -> { + if (inbound.isClosed()) { + // The player was disconnected + return; + } + if (!event.getResult().isAllowed()) { + // The component is guaranteed to be provided if the connection was denied. + inbound.closeWith(Disconnect.create(event.getResult().getReason().get())); + return; + } + + handleProxyLogin(player); + }, inbound.getChannel().eventLoop()); + }); + + } + + private void handleProxyLogin(ConnectedPlayer player) { Optional toTry = player.getNextServerToTry(); if (!toTry.isPresent()) { player.close(TextComponent.of("No available servers", TextColor.RED)); @@ -132,14 +205,24 @@ public class LoginSessionHandler implements MinecraftSessionHandler { } ServerLoginSuccess success = new ServerLoginSuccess(); - success.setUsername(profile.getName()); - success.setUuid(profile.idAsUuid()); + success.setUsername(player.getUsername()); + success.setUuid(player.getUniqueId()); inbound.write(success); - logger.info("{} has connected", player); inbound.setAssociation(player); inbound.setState(StateRegistry.PLAY); + + if (!VelocityServer.getServer().registerConnection(player)) { + inbound.closeWith(Disconnect.create(TextComponent.of("You are already on this proxy!", TextColor.RED))); + } + + logger.info("{} has connected", player); inbound.setSessionHandler(new InitialConnectSessionHandler(player)); player.createConnectionRequest(toTry.get()).fireAndForget(); } + + @Override + public void handleUnknown(ByteBuf buf) { + throw new IllegalStateException("Unknown data " + ByteBufUtil.hexDump(buf)); + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java index 1dee26ce4..fb70edd02 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java @@ -1,28 +1,34 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.event.proxy.ProxyPingEvent; +import com.velocitypowered.api.proxy.InboundConnection; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.packet.StatusPing; import com.velocitypowered.proxy.protocol.packet.StatusRequest; import com.velocitypowered.proxy.protocol.packet.StatusResponse; import com.velocitypowered.proxy.connection.MinecraftConnection; -import com.velocitypowered.proxy.data.ServerPing; +import com.velocitypowered.api.proxy.server.ServerPing; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; public class StatusSessionHandler implements MinecraftSessionHandler { private final MinecraftConnection connection; + private final InboundConnection inboundWrapper; - public StatusSessionHandler(MinecraftConnection connection) { + public StatusSessionHandler(MinecraftConnection connection, InboundConnection inboundWrapper) { this.connection = connection; + this.inboundWrapper = inboundWrapper; } @Override public void handle(MinecraftPacket packet) { - Preconditions.checkArgument(packet instanceof StatusPing|| packet instanceof StatusRequest, + Preconditions.checkArgument(packet instanceof StatusPing || packet instanceof StatusRequest, "Unrecognized packet type " + packet.getClass().getName()); if (packet instanceof StatusPing) { @@ -34,15 +40,22 @@ public class StatusSessionHandler implements MinecraftSessionHandler { VelocityConfiguration configuration = VelocityServer.getServer().getConfiguration(); // Status request - ServerPing ping = new ServerPing( - new ServerPing.Version(connection.getProtocolVersion(), "Velocity 1.9-1.13"), - new ServerPing.Players(0, configuration.getShowMaxPlayers()), + int shownVersion = ProtocolConstants.isSupported(connection.getProtocolVersion()) ? connection.getProtocolVersion() : + ProtocolConstants.MAXIMUM_GENERIC_VERSION; + ServerPing initialPing = new ServerPing( + new ServerPing.Version(shownVersion, "Velocity " + ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING), + new ServerPing.Players(VelocityServer.getServer().getPlayerCount(), configuration.getShowMaxPlayers(), ImmutableList.of()), configuration.getMotdComponent(), - null + configuration.getFavicon() ); - StatusResponse response = new StatusResponse(); - response.setStatus(VelocityServer.GSON.toJson(ping)); - connection.write(response); + + ProxyPingEvent event = new ProxyPingEvent(inboundWrapper, initialPing); + VelocityServer.getServer().getEventManager().fire(event) + .thenRunAsync(() -> { + StatusResponse response = new StatusResponse(); + response.setStatus(VelocityServer.GSON.toJson(event.getPing())); + connection.write(response); + }, connection.getChannel().eventLoop()); } @Override diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java index 8e2d8ec8d..0f6811fde 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java @@ -3,6 +3,7 @@ package com.velocitypowered.proxy.connection.util; import com.velocitypowered.api.proxy.ConnectionRequestBuilder; import com.velocitypowered.proxy.protocol.packet.Disconnect; import net.kyori.text.Component; +import net.kyori.text.TextComponent; import net.kyori.text.serializer.ComponentSerializers; import java.util.Optional; @@ -42,4 +43,18 @@ public class ConnectionRequestResults { } }; } + + public static ConnectionRequestBuilder.Result forDisconnect(TextComponent component) { + return new ConnectionRequestBuilder.Result() { + @Override + public ConnectionRequestBuilder.Status getStatus() { + return ConnectionRequestBuilder.Status.SERVER_DISCONNECTED; + } + + @Override + public Optional getReason() { + return Optional.of(component); + } + }; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java b/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java new file mode 100644 index 000000000..fcb1d34f9 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java @@ -0,0 +1,52 @@ +package com.velocitypowered.proxy.console; + +import com.velocitypowered.proxy.VelocityServer; +import net.kyori.text.TextComponent; +import net.kyori.text.format.TextColor; +import net.minecrell.terminalconsole.SimpleTerminalConsole; +import org.jline.reader.*; + +import java.util.List; +import java.util.Optional; + +public final class VelocityConsole extends SimpleTerminalConsole { + + private final VelocityServer server; + + public VelocityConsole(VelocityServer server) { + this.server = server; + } + + @Override + protected LineReader buildReader(LineReaderBuilder builder) { + return super.buildReader(builder + .appName("Velocity") + .completer((reader, parsedLine, list) -> { + Optional> o = server.getCommandManager().offerSuggestions(server.getConsoleCommandSource(), parsedLine.line()); + o.ifPresent(offers -> { + for (String offer : offers) { + list.add(new Candidate(offer)); + } + }); + }) + ); + } + + @Override + protected boolean isRunning() { + return !this.server.isShutdown(); + } + + @Override + protected void runCommand(String command) { + if (!this.server.getCommandManager().execute(this.server.getConsoleCommandSource(), command)) { + server.getConsoleCommandSource().sendMessage(TextComponent.of("Command not found.", TextColor.RED)); + } + } + + @Override + protected void shutdown() { + this.server.shutdown(); + } + +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/data/ServerPing.java b/proxy/src/main/java/com/velocitypowered/proxy/data/ServerPing.java deleted file mode 100644 index 72494eaf1..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/data/ServerPing.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.velocitypowered.proxy.data; - -import net.kyori.text.Component; - -public class ServerPing { - private final Version version; - private final Players players; - private final Component description; - private final String favicon; - - public ServerPing(Version version, Players players, Component description, String favicon) { - this.version = version; - this.players = players; - this.description = description; - this.favicon = favicon; - } - - public Version getVersion() { - return version; - } - - public Players getPlayers() { - return players; - } - - public Component getDescription() { - return description; - } - - public String getFavicon() { - return favicon; - } - - @Override - public String toString() { - return "ServerPing{" + - "version=" + version + - ", players=" + players + - ", description=" + description + - ", favicon='" + favicon + '\'' + - '}'; - } - - public static class Version { - private final int protocol; - private final String name; - - public Version(int protocol, String name) { - this.protocol = protocol; - this.name = name; - } - - public int getProtocol() { - return protocol; - } - - public String getName() { - return name; - } - - @Override - public String toString() { - return "Version{" + - "protocol=" + protocol + - ", name='" + name + '\'' + - '}'; - } - } - - public static class Players { - private final int online; - private final int max; - - public Players(int online, int max) { - this.online = online; - this.max = max; - } - - public int getOnline() { - return online; - } - - public int getMax() { - return max; - } - - @Override - public String toString() { - return "Players{" + - "online=" + online + - ", max=" + max + - '}'; - } - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/messages/VelocityChannelRegistrar.java b/proxy/src/main/java/com/velocitypowered/proxy/messages/VelocityChannelRegistrar.java new file mode 100644 index 000000000..dff2f5d3e --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/messages/VelocityChannelRegistrar.java @@ -0,0 +1,78 @@ +package com.velocitypowered.proxy.messages; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.messages.*; +import com.velocitypowered.proxy.protocol.packet.PluginMessage; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class VelocityChannelRegistrar implements ChannelRegistrar { + private static final Logger logger = LogManager.getLogger(VelocityChannelRegistrar.class); + private final Map handlers = new ConcurrentHashMap<>(); + private final Map identifierMap = new ConcurrentHashMap<>(); + + @Override + public void register(MessageHandler handler, ChannelIdentifier... identifiers) { + for (ChannelIdentifier identifier : identifiers) { + Preconditions.checkArgument(identifier instanceof LegacyChannelIdentifier || identifier instanceof MinecraftChannelIdentifier, + "identifier is unknown"); + } + + for (ChannelIdentifier identifier : identifiers) { + handlers.put(identifier.getId(), handler); + identifierMap.put(identifier.getId(), identifier); + } + } + + public MessageHandler.ForwardStatus handlePluginMessage(ChannelMessageSource source, ChannelSide side, PluginMessage message) { + MessageHandler handler = handlers.get(message.getChannel()); + ChannelIdentifier identifier = identifierMap.get(message.getChannel()); + if (handler == null || identifier == null) { + return MessageHandler.ForwardStatus.FORWARD; + } + + try { + return handler.handle(source, side, identifier, message.getData()); + } catch (Exception e) { + logger.info("Unable to handle plugin message on channel {} for {}", message.getChannel(), source); + // In case of doubt, do not forward the message on. + return MessageHandler.ForwardStatus.HANDLED; + } + } + + @Override + public void unregister(ChannelIdentifier... identifiers) { + for (ChannelIdentifier identifier : identifiers) { + Preconditions.checkArgument(identifier instanceof LegacyChannelIdentifier || identifier instanceof MinecraftChannelIdentifier, + "identifier is unknown"); + } + + for (ChannelIdentifier identifier : identifiers) { + handlers.remove(identifier.getId()); + identifierMap.remove(identifier.getId()); + } + } + + public Collection getLegacyChannelIds() { + return identifierMap.values().stream() + .filter(i -> i instanceof LegacyChannelIdentifier) + .map(ChannelIdentifier::getId) + .collect(Collectors.toList()); + } + + public Collection getModernChannelIds() { + return identifierMap.values().stream() + .filter(i -> i instanceof MinecraftChannelIdentifier) + .map(ChannelIdentifier::getId) + .collect(Collectors.toList()); + } + + public boolean registered(String id) { + return identifierMap.containsKey(id); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/network/ConnectionManager.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java similarity index 54% rename from proxy/src/main/java/com/velocitypowered/network/ConnectionManager.java rename to proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java index 9b8676db8..732b8b331 100644 --- a/proxy/src/main/java/com/velocitypowered/network/ConnectionManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java @@ -1,18 +1,18 @@ -package com.velocitypowered.network; +package com.velocitypowered.proxy.network; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.velocitypowered.natives.util.Natives; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.StateRegistry; +import com.velocitypowered.proxy.protocol.netty.GS4QueryHandler; import com.velocitypowered.proxy.protocol.netty.LegacyPingDecoder; import com.velocitypowered.proxy.protocol.netty.LegacyPingEncoder; import com.velocitypowered.proxy.protocol.netty.MinecraftDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftEncoder; import com.velocitypowered.proxy.protocol.netty.MinecraftVarintFrameDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftVarintLengthEncoder; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.LogManager; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.Channel; @@ -21,71 +21,53 @@ import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.epoll.Epoll; +import io.netty.channel.epoll.EpollDatagramChannel; import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.channel.epoll.EpollServerSocketChannel; import io.netty.channel.epoll.EpollSocketChannel; +import io.netty.channel.kqueue.*; import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramChannel; import io.netty.channel.socket.ServerSocketChannel; import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioDatagramChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.timeout.ReadTimeoutHandler; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.net.InetSocketAddress; import java.util.HashSet; +import java.util.Locale; import java.util.Set; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; -import static com.velocitypowered.network.Connections.CLIENT_READ_TIMEOUT_SECONDS; -import static com.velocitypowered.network.Connections.FRAME_DECODER; -import static com.velocitypowered.network.Connections.FRAME_ENCODER; -import static com.velocitypowered.network.Connections.LEGACY_PING_DECODER; -import static com.velocitypowered.network.Connections.LEGACY_PING_ENCODER; -import static com.velocitypowered.network.Connections.MINECRAFT_DECODER; -import static com.velocitypowered.network.Connections.MINECRAFT_ENCODER; -import static com.velocitypowered.network.Connections.READ_TIMEOUT; +import static com.velocitypowered.proxy.network.Connections.*; public final class ConnectionManager { private static final Logger logger = LogManager.getLogger(ConnectionManager.class); - private static final String DISABLE_EPOLL_PROPERTY = "velocity.connection.disable-epoll"; - private static final boolean DISABLE_EPOLL = Boolean.getBoolean(DISABLE_EPOLL_PROPERTY); private final Set endpoints = new HashSet<>(); - private final Class serverSocketChannelClass; - private final Class socketChannelClass; + private final TransportType transportType; private final EventLoopGroup bossGroup; private final EventLoopGroup workerGroup; public ConnectionManager() { - final boolean epoll = canUseEpoll(); - if (epoll) { - this.serverSocketChannelClass = EpollServerSocketChannel.class; - this.socketChannelClass = EpollSocketChannel.class; - this.bossGroup = new EpollEventLoopGroup(0, createThreadFactory("Netty Epoll Boss #%d")); - this.workerGroup = new EpollEventLoopGroup(0, createThreadFactory("Netty Epoll Worker #%d")); - } else { - this.serverSocketChannelClass = NioServerSocketChannel.class; - this.socketChannelClass = NioSocketChannel.class; - this.bossGroup = new NioEventLoopGroup(0, createThreadFactory("Netty Nio Boss #%d")); - this.workerGroup = new NioEventLoopGroup(0, createThreadFactory("Netty Nio Worker #%d")); - } - this.logChannelInformation(epoll); + this.transportType = TransportType.bestType(); + this.bossGroup = transportType.createEventLoopGroup(true); + this.workerGroup = transportType.createEventLoopGroup(false); + this.logChannelInformation(); } - private void logChannelInformation(final boolean epoll) { - final StringBuilder sb = new StringBuilder(); - sb.append("Using channel type "); - sb.append(epoll ? "epoll": "nio"); - if(DISABLE_EPOLL) { - sb.append(String.format(" - epoll explicitly disabled using -D%s=true", DISABLE_EPOLL_PROPERTY)); - } - logger.info(sb.toString()); // TODO: move to logger + private void logChannelInformation() { + logger.info("Connections will use {} channels, {} compression, {} ciphers", transportType, Natives.compressor.getLoadedVariant(), Natives.cipher.getLoadedVariant()); } public void bind(final InetSocketAddress address) { final ServerBootstrap bootstrap = new ServerBootstrap() - .channel(this.serverSocketChannelClass) + .channel(this.transportType.serverSocketChannelClass) .group(this.bossGroup, this.workerGroup) .childHandler(new ChannelInitializer() { @Override @@ -120,9 +102,27 @@ public final class ConnectionManager { }); } + public void queryBind(final String hostname, final int port) { + Bootstrap bootstrap = new Bootstrap() + .channel(transportType.datagramChannelClass) + .group(this.workerGroup) + .handler(new GS4QueryHandler()) + .localAddress(hostname, port); + bootstrap.bind() + .addListener((ChannelFutureListener) future -> { + final Channel channel = future.channel(); + if (future.isSuccess()) { + this.endpoints.add(channel); + logger.info("Listening for GS4 query on {}", channel.localAddress()); + } else { + logger.error("Can't bind to {}", bootstrap.config().localAddress(), future.cause()); + } + }); + } + public Bootstrap createWorker() { return new Bootstrap() - .channel(this.socketChannelClass) + .channel(this.transportType.socketChannelClass) .group(this.workerGroup); } @@ -137,14 +137,61 @@ public final class ConnectionManager { } } - private static boolean canUseEpoll() { - return Epoll.isAvailable() && !DISABLE_EPOLL; - } - private static ThreadFactory createThreadFactory(final String nameFormat) { return new ThreadFactoryBuilder() .setNameFormat(nameFormat) .setDaemon(true) .build(); } + + private enum TransportType { + NIO(NioServerSocketChannel.class, NioSocketChannel.class, NioDatagramChannel.class) { + @Override + public EventLoopGroup createEventLoopGroup(boolean boss) { + String name = "Netty NIO " + (boss ? "Boss" : "Worker") + " #%d"; + return new NioEventLoopGroup(0, createThreadFactory(name)); + } + }, + EPOLL(EpollServerSocketChannel.class, EpollSocketChannel.class, EpollDatagramChannel.class) { + @Override + public EventLoopGroup createEventLoopGroup(boolean boss) { + String name = "Netty Epoll " + (boss ? "Boss" : "Worker") + " #%d"; + return new EpollEventLoopGroup(0, createThreadFactory(name)); + } + }, + KQUEUE(KQueueServerSocketChannel.class, KQueueSocketChannel.class, KQueueDatagramChannel.class) { + @Override + public EventLoopGroup createEventLoopGroup(boolean boss) { + String name = "Netty KQueue " + (boss ? "Boss" : "Worker") + " #%d"; + return new KQueueEventLoopGroup(0, createThreadFactory(name)); + } + }; + + private final Class serverSocketChannelClass; + private final Class socketChannelClass; + private final Class datagramChannelClass; + + TransportType(Class serverSocketChannelClass, Class socketChannelClass, Class datagramChannelClass) { + this.serverSocketChannelClass = serverSocketChannelClass; + this.socketChannelClass = socketChannelClass; + this.datagramChannelClass = datagramChannelClass; + } + + @Override + public String toString() { + return name().toLowerCase(Locale.US); + } + + public abstract EventLoopGroup createEventLoopGroup(boolean boss); + + public static TransportType bestType() { + if (Epoll.isAvailable()) { + return EPOLL; + } else if (KQueue.isAvailable()) { + return KQUEUE; + } else { + return NIO; + } + } + } } diff --git a/proxy/src/main/java/com/velocitypowered/network/Connections.java b/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java similarity index 94% rename from proxy/src/main/java/com/velocitypowered/network/Connections.java rename to proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java index 3ca2ff9dc..518a3acbe 100644 --- a/proxy/src/main/java/com/velocitypowered/network/Connections.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java @@ -1,4 +1,4 @@ -package com.velocitypowered.network; +package com.velocitypowered.proxy.network; public interface Connections { String CIPHER_DECODER = "cipher-decoder"; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/PluginClassLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/PluginClassLoader.java new file mode 100644 index 000000000..db2d2ad92 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/PluginClassLoader.java @@ -0,0 +1,56 @@ +package com.velocitypowered.proxy.plugin; + +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +public class PluginClassLoader extends URLClassLoader { + private static final Set loaders = new CopyOnWriteArraySet<>(); + + static { + ClassLoader.registerAsParallelCapable(); + } + + public PluginClassLoader(URL[] urls) { + super(urls); + loaders.add(this); + } + + public void addPath(Path path) { + try { + addURL(path.toUri().toURL()); + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + return loadClass0(name, resolve, true); + } + + private Class loadClass0(String name, boolean resolve, boolean checkOther) throws ClassNotFoundException { + try { + return super.loadClass(name, resolve); + } catch (ClassNotFoundException ignored) { + // Ignored: we'll try others + } + + if (checkOther) { + for (PluginClassLoader loader : loaders) { + if (loader != this) { + try { + return loader.loadClass0(name, resolve, false); + } catch (ClassNotFoundException ignored) { + // We're trying others, safe to ignore + } + } + } + } + + throw new ClassNotFoundException(name); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityEventManager.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityEventManager.java new file mode 100644 index 000000000..3f84e6279 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityEventManager.java @@ -0,0 +1,192 @@ +package com.velocitypowered.proxy.plugin; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimaps; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.velocitypowered.api.event.EventHandler; +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.event.PostOrder; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.proxy.util.concurrency.RecordingThreadFactory; +import net.kyori.event.EventSubscriber; +import net.kyori.event.PostResult; +import net.kyori.event.SimpleEventBus; +import net.kyori.event.method.*; +import net.kyori.event.method.asm.ASMEventExecutorFactory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.lang.reflect.Method; +import java.net.URL; +import java.util.*; +import java.util.concurrent.*; + +public class VelocityEventManager implements EventManager { + private static final Logger logger = LogManager.getLogger(VelocityEventManager.class); + + private final ListMultimap registeredListenersByPlugin = Multimaps + .synchronizedListMultimap(Multimaps.newListMultimap(new IdentityHashMap<>(), ArrayList::new)); + private final ListMultimap> registeredHandlersByPlugin = Multimaps + .synchronizedListMultimap(Multimaps.newListMultimap(new IdentityHashMap<>(), ArrayList::new)); + private final VelocityEventBus bus = new VelocityEventBus( + new ASMEventExecutorFactory<>(new PluginClassLoader(new URL[0])), + new VelocityMethodScanner()); + private final ExecutorService service; + private final RecordingThreadFactory recordingThreadFactory; + private final PluginManager pluginManager; + + public VelocityEventManager(PluginManager pluginManager) { + this.pluginManager = pluginManager; + this.recordingThreadFactory = new RecordingThreadFactory(new ThreadFactoryBuilder() + .setNameFormat("Velocity Event Executor - #%d").setDaemon(true).build()); + this.service = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), recordingThreadFactory); + } + + @Override + public void register(@NonNull Object plugin, @NonNull Object listener) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkNotNull(listener, "listener"); + Preconditions.checkArgument(pluginManager.fromInstance(plugin).isPresent(), "Specified plugin is not loaded"); + registeredListenersByPlugin.put(plugin, listener); + bus.register(listener); + } + + @Override + public void register(@NonNull Object plugin, @NonNull Class eventClass, @NonNull PostOrder postOrder, @NonNull EventHandler handler) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkNotNull(eventClass, "eventClass"); + Preconditions.checkNotNull(postOrder, "postOrder"); + Preconditions.checkNotNull(handler, "listener"); + bus.register(eventClass, new KyoriToVelocityHandler<>(handler, postOrder)); + } + + @Override + public @NonNull CompletableFuture fire(@NonNull E event) { + Preconditions.checkNotNull(event, "event"); + if (!bus.hasSubscribers(event.getClass())) { + // Optimization: nobody's listening. + return CompletableFuture.completedFuture(event); + } + + Runnable runEvent = () -> { + PostResult result = bus.post(event); + if (!result.exceptions().isEmpty()) { + logger.error("Some errors occurred whilst posting event {}.", event); + int i = 0; + for (Throwable exception : result.exceptions().values()) { + logger.error("#{}: \n", i++, exception); + } + } + }; + + if (recordingThreadFactory.currentlyInFactory()) { + // Optimization: fire the event immediately, we are on the event handling thread. + runEvent.run(); + return CompletableFuture.completedFuture(event); + } + + CompletableFuture eventFuture = new CompletableFuture<>(); + service.execute(() -> { + runEvent.run(); + eventFuture.complete(event); + }); + return eventFuture; + } + + @Override + public void unregisterListeners(@NonNull Object plugin) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkArgument(pluginManager.fromInstance(plugin).isPresent(), "Specified plugin is not loaded"); + Collection listeners = registeredListenersByPlugin.removeAll(plugin); + listeners.forEach(bus::unregister); + Collection> handlers = registeredHandlersByPlugin.removeAll(plugin); + handlers.forEach(bus::unregister); + } + + @Override + public void unregisterListener(@NonNull Object plugin, @NonNull Object listener) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkNotNull(listener, "listener"); + Preconditions.checkArgument(pluginManager.fromInstance(plugin).isPresent(), "Specified plugin is not loaded"); + registeredListenersByPlugin.remove(plugin, listener); + bus.unregister(listener); + } + + @Override + public void unregister(@NonNull Object plugin, @NonNull EventHandler handler) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkNotNull(handler, "listener"); + registeredHandlersByPlugin.remove(plugin, handler); + bus.unregister(handler); + } + + public boolean shutdown() throws InterruptedException { + service.shutdown(); + return service.awaitTermination(10, TimeUnit.SECONDS); + } + + private static class VelocityEventBus extends SimpleEventBus { + private final MethodSubscriptionAdapter methodAdapter; + + VelocityEventBus(EventExecutor.@NonNull Factory factory, @NonNull MethodScanner methodScanner) { + super(Object.class); + this.methodAdapter = new SimpleMethodSubscriptionAdapter<>(this, factory, methodScanner); + } + + void register(Object listener) { + this.methodAdapter.register(listener); + } + + void unregister(Object listener) { + this.methodAdapter.unregister(listener); + } + + void unregister(EventHandler handler) { + this.unregister(s -> s instanceof KyoriToVelocityHandler && ((KyoriToVelocityHandler) s).getHandler().equals(handler)); + } + } + + private static class VelocityMethodScanner implements MethodScanner { + @Override + public boolean shouldRegister(@NonNull Object listener, @NonNull Method method) { + return method.isAnnotationPresent(Subscribe.class); + } + + @Override + public int postOrder(@NonNull Object listener, @NonNull Method method) { + return method.getAnnotation(Subscribe.class).order().ordinal(); + } + + @Override + public boolean consumeCancelledEvents(@NonNull Object listener, @NonNull Method method) { + return true; + } + } + + private static class KyoriToVelocityHandler implements EventSubscriber { + private final EventHandler handler; + private final int postOrder; + + private KyoriToVelocityHandler(EventHandler handler, PostOrder postOrder) { + this.handler = handler; + this.postOrder = postOrder.ordinal(); + } + + @Override + public void invoke(@NonNull E event) { + handler.execute(event); + } + + @Override + public int postOrder() { + return postOrder; + } + + public EventHandler getHandler() { + return handler; + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java new file mode 100644 index 000000000..074f8940b --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java @@ -0,0 +1,128 @@ +package com.velocitypowered.proxy.plugin; + +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.plugin.meta.PluginDependency; +import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.plugin.loader.JavaPluginLoader; +import com.velocitypowered.proxy.plugin.util.PluginDependencyUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +public class VelocityPluginManager implements PluginManager { + private static final Logger logger = LogManager.getLogger(VelocityPluginManager.class); + + private final Map plugins = new HashMap<>(); + private final Map pluginInstances = new IdentityHashMap<>(); + private final VelocityServer server; + + public VelocityPluginManager(VelocityServer server) { + this.server = checkNotNull(server, "server"); + } + + private void registerPlugin(@NonNull PluginContainer plugin) { + plugins.put(plugin.getDescription().getId(), plugin); + plugin.getInstance().ifPresent(instance -> pluginInstances.put(instance, plugin)); + } + + public void loadPlugins(@NonNull Path directory) throws IOException { + checkNotNull(directory, "directory"); + checkArgument(Files.isDirectory(directory), "provided path isn't a directory"); + + List found = new ArrayList<>(); + JavaPluginLoader loader = new JavaPluginLoader(server, directory); + + try (DirectoryStream stream = Files.newDirectoryStream(directory, p -> Files.isRegularFile(p) && p.toString().endsWith(".jar"))) { + for (Path path : stream) { + try { + found.add(loader.loadPlugin(path)); + } catch (Exception e) { + logger.error("Unable to load plugin {}", path, e); + } + } + } + + if (found.isEmpty()) { + // No plugins found + return; + } + + List sortedPlugins = PluginDependencyUtils.sortCandidates(found); + + // Now load the plugins + pluginLoad: + for (PluginDescription plugin : sortedPlugins) { + // Verify dependencies + for (PluginDependency dependency : plugin.getDependencies()) { + if (!dependency.isOptional() && !isLoaded(dependency.getId())) { + logger.error("Can't load plugin {} due to missing dependency {}", plugin.getId(), dependency.getId()); + continue pluginLoad; + } + } + + // Actually create the plugin + PluginContainer pluginObject; + + try { + pluginObject = loader.createPlugin(plugin); + } catch (Exception e) { + logger.error("Can't create plugin {}", plugin.getId(), e); + continue; + } + + registerPlugin(pluginObject); + } + } + + @Override + public @NonNull Optional fromInstance(@NonNull Object instance) { + checkNotNull(instance, "instance"); + + if (instance instanceof PluginContainer) { + return Optional.of((PluginContainer) instance); + } + + return Optional.ofNullable(pluginInstances.get(instance)); + } + + @Override + public @NonNull Optional getPlugin(@NonNull String id) { + checkNotNull(id, "id"); + return Optional.ofNullable(plugins.get(id)); + } + + @Override + public @NonNull Collection getPlugins() { + return Collections.unmodifiableCollection(plugins.values()); + } + + @Override + public boolean isLoaded(@NonNull String id) { + return plugins.containsKey(id); + } + + @Override + public void addToClasspath(@NonNull Object plugin, @NonNull Path path) { + checkNotNull(plugin, "instance"); + checkNotNull(path, "path"); + checkArgument(pluginInstances.containsKey(plugin), "plugin is not loaded"); + + ClassLoader pluginClassloader = plugin.getClass().getClassLoader(); + if (pluginClassloader instanceof PluginClassLoader) { + ((PluginClassLoader) pluginClassloader).addPath(path); + } else { + throw new UnsupportedOperationException("Operation is not supported on non-Java Velocity plugins."); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/JavaPluginLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/JavaPluginLoader.java new file mode 100644 index 000000000..dc26dec3e --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/JavaPluginLoader.java @@ -0,0 +1,124 @@ +package com.velocitypowered.proxy.plugin.loader; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.velocitypowered.api.plugin.*; +import com.velocitypowered.api.plugin.meta.PluginDependency; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.plugin.PluginClassLoader; +import com.velocitypowered.proxy.plugin.loader.java.JavaVelocityPluginDescription; +import com.velocitypowered.proxy.plugin.loader.java.SerializedPluginDescription; +import com.velocitypowered.proxy.plugin.loader.java.VelocityPluginModule; + +import java.io.BufferedInputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.regex.Pattern; + +public class JavaPluginLoader implements PluginLoader { + private final ProxyServer server; + private final Path baseDirectory; + + public JavaPluginLoader(ProxyServer server, Path baseDirectory) { + this.server = server; + this.baseDirectory = baseDirectory; + } + + @Override + public PluginDescription loadPlugin(Path source) throws Exception { + Optional serialized = getSerializedPluginInfo(source); + + if (!serialized.isPresent()) { + throw new InvalidPluginException("Did not find a valid velocity-info.json."); + } + + PluginClassLoader loader = new PluginClassLoader( + new URL[] {source.toUri().toURL() } + ); + + Class mainClass = loader.loadClass(serialized.get().getMain()); + VelocityPluginDescription description = createDescription(serialized.get(), source, mainClass); + + String pluginId = description.getId(); + Pattern pattern = PluginDescription.ID_PATTERN; + + if (!pattern.matcher(pluginId).matches()) { + throw new InvalidPluginException("Plugin ID '" + pluginId + "' must match pattern " + pattern.pattern()); + } + + return description; + } + + @Override + public PluginContainer createPlugin(PluginDescription description) throws Exception { + if (!(description instanceof JavaVelocityPluginDescription)) { + throw new IllegalArgumentException("Description provided isn't of the Java plugin loader"); + } + + JavaVelocityPluginDescription javaDescription = (JavaVelocityPluginDescription) description; + Optional source = javaDescription.getSource(); + + if (!source.isPresent()) { + throw new IllegalArgumentException("No path in plugin description"); + } + + Injector injector = Guice.createInjector(new VelocityPluginModule(server, javaDescription, baseDirectory)); + Object instance = injector.getInstance(javaDescription.getMainClass()); + + return new VelocityPluginContainer(description, instance); + } + + private Optional getSerializedPluginInfo(Path source) throws Exception { + try (JarInputStream in = new JarInputStream(new BufferedInputStream(Files.newInputStream(source)))) { + JarEntry entry; + while ((entry = in.getNextJarEntry()) != null) { + if (entry.getName().equals("velocity-plugin.json")) { + try (Reader pluginInfoReader = new InputStreamReader(in)) { + return Optional.of(VelocityServer.GSON.fromJson(pluginInfoReader, SerializedPluginDescription.class)); + } + } + } + + return Optional.empty(); + } + } + + private VelocityPluginDescription createDescription(SerializedPluginDescription description, Path source, Class mainClass) { + Set dependencies = new HashSet<>(); + + if (description.getDependencies() != null) { + for (SerializedPluginDescription.Dependency dependency : description.getDependencies()) { + dependencies.add(toDependencyMeta(dependency)); + } + } + + return new JavaVelocityPluginDescription( + description.getId(), + description.getName(), + description.getVersion(), + description.getDescription(), + description.getUrl(), + description.getAuthors(), + dependencies, + source, + mainClass + ); + } + + private static PluginDependency toDependencyMeta(SerializedPluginDescription.Dependency dependency) { + return new PluginDependency( + dependency.getId(), + null, // TODO Implement version matching in dependency annotation + dependency.isOptional() + ); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/PluginLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/PluginLoader.java new file mode 100644 index 000000000..b16e58d0a --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/PluginLoader.java @@ -0,0 +1,15 @@ +package com.velocitypowered.proxy.plugin.loader; + +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; + +import java.nio.file.Path; + +/** + * This interface is used for loading plugins. + */ +public interface PluginLoader { + PluginDescription loadPlugin(Path source) throws Exception; + + PluginContainer createPlugin(PluginDescription plugin) throws Exception; +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginContainer.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginContainer.java new file mode 100644 index 000000000..d7cea4c4a --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginContainer.java @@ -0,0 +1,26 @@ +package com.velocitypowered.proxy.plugin.loader; + +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; + +import java.util.Optional; + +public class VelocityPluginContainer implements PluginContainer { + private final PluginDescription description; + private final Object instance; + + public VelocityPluginContainer(PluginDescription description, Object instance) { + this.description = description; + this.instance = instance; + } + + @Override + public PluginDescription getDescription() { + return this.description; + } + + @Override + public Optional getInstance() { + return Optional.ofNullable(instance); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java new file mode 100644 index 000000000..0ee55c112 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java @@ -0,0 +1,98 @@ +package com.velocitypowered.proxy.plugin.loader; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Maps; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.meta.PluginDependency; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class VelocityPluginDescription implements PluginDescription { + private final String id; + private final @Nullable String name; + private final @Nullable String version; + private final @Nullable String description; + private final @Nullable String url; + private final List authors; + private final Map dependencies; + private final Path source; + + public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, + @Nullable List authors, Collection dependencies, Path source) { + this.id = checkNotNull(id, "id"); + this.name = Strings.emptyToNull(name); + this.version = Strings.emptyToNull(version); + this.description = Strings.emptyToNull(description); + this.url = Strings.emptyToNull(url); + this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors); + this.dependencies = Maps.uniqueIndex(dependencies, PluginDependency::getId); + this.source = source; + } + + @Override + public String getId() { + return id; + } + + @Override + public Optional getName() { + return Optional.ofNullable(name); + } + + @Override + public Optional getVersion() { + return Optional.ofNullable(version); + } + + @Override + public Optional getDescription() { + return Optional.ofNullable(description); + } + + @Override + public Optional getUrl() { + return Optional.ofNullable(url); + } + + @Override + public List getAuthors() { + return authors; + } + + @Override + public Collection getDependencies() { + return dependencies.values(); + } + + @Override + public Optional getDependency(String id) { + return Optional.ofNullable(dependencies.get(id)); + } + + @Override + public Optional getSource() { + return Optional.ofNullable(source); + } + + @Override + public String toString() { + return "VelocityPluginDescription{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", version='" + version + '\'' + + ", description='" + description + '\'' + + ", url='" + url + '\'' + + ", authors=" + authors + + ", dependencies=" + dependencies + + ", source=" + source + + '}'; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java new file mode 100644 index 000000000..a73aaec78 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java @@ -0,0 +1,25 @@ +package com.velocitypowered.proxy.plugin.loader.java; + +import com.velocitypowered.api.plugin.meta.PluginDependency; +import com.velocitypowered.proxy.plugin.loader.VelocityPluginDescription; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class JavaVelocityPluginDescription extends VelocityPluginDescription { + private final Class mainClass; + + public JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, + @Nullable List authors, Collection dependencies, Path source, Class mainClass) { + super(id, name, version, description, url, authors, dependencies, source); + this.mainClass = checkNotNull(mainClass); + } + + public Class getMainClass() { + return mainClass; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/SerializedPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/SerializedPluginDescription.java new file mode 100644 index 000000000..b12b1694e --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/SerializedPluginDescription.java @@ -0,0 +1,151 @@ +package com.velocitypowered.proxy.plugin.loader.java; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.velocitypowered.api.plugin.Plugin; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class SerializedPluginDescription { + // @Nullable is used here to make GSON skip these in the serialized file + private final String id; + private final @Nullable String name; + private final @Nullable String version; + private final @Nullable String description; + private final @Nullable String url; + private final @Nullable List authors; + private final @Nullable List dependencies; + private final String main; + + public SerializedPluginDescription(String id, String name, String version, String description, String url, + List authors, List dependencies, String main) { + this.id = Preconditions.checkNotNull(id, "id"); + this.name = Strings.emptyToNull(name); + this.version = Strings.emptyToNull(version); + this.description = Strings.emptyToNull(description); + this.url = Strings.emptyToNull(url); + this.authors = authors == null || authors.isEmpty() ? null : authors; + this.dependencies = dependencies == null || dependencies.isEmpty() ? null : dependencies; + this.main = Preconditions.checkNotNull(main, "main"); + } + + public static SerializedPluginDescription from(Plugin plugin, String qualifiedName) { + List dependencies = new ArrayList<>(); + for (com.velocitypowered.api.plugin.Dependency dependency : plugin.dependencies()) { + dependencies.add(new Dependency(dependency.id(), dependency.optional())); + } + return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(), plugin.description(), plugin.url(), + Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty()).collect(Collectors.toList()), dependencies, qualifiedName); + } + + public String getId() { + return id; + } + + public @Nullable String getName() { + return name; + } + + public @Nullable String getVersion() { + return version; + } + + public @Nullable String getDescription() { + return description; + } + + public @Nullable String getUrl() { + return url; + } + + public @Nullable List getAuthors() { + return authors; + } + + public @Nullable List getDependencies() { + return dependencies; + } + + public String getMain() { + return main; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SerializedPluginDescription that = (SerializedPluginDescription) o; + return Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(version, that.version) && + Objects.equals(description, that.description) && + Objects.equals(url, that.url) && + Objects.equals(authors, that.authors) && + Objects.equals(dependencies, that.dependencies) && + Objects.equals(main, that.main); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, version, description, url, authors, dependencies); + } + + @Override + public String toString() { + return "SerializedPluginDescription{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", version='" + version + '\'' + + ", description='" + description + '\'' + + ", url='" + url + '\'' + + ", authors=" + authors + + ", dependencies=" + dependencies + + ", main='" + main + '\'' + + '}'; + } + + public static class Dependency { + private final String id; + private final boolean optional; + + public Dependency(String id, boolean optional) { + this.id = id; + this.optional = optional; + } + + public String getId() { + return id; + } + + public boolean isOptional() { + return optional; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Dependency that = (Dependency) o; + return optional == that.optional && + Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, optional); + } + + @Override + public String toString() { + return "Dependency{" + + "id='" + id + '\'' + + ", optional=" + optional + + '}'; + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/VelocityPluginModule.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/VelocityPluginModule.java new file mode 100644 index 000000000..cc3ddadc2 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/VelocityPluginModule.java @@ -0,0 +1,38 @@ +package com.velocitypowered.proxy.plugin.loader.java; + +import com.google.inject.Binder; +import com.google.inject.Module; +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.proxy.VelocityServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; + +public class VelocityPluginModule implements Module { + private final ProxyServer server; + private final JavaVelocityPluginDescription description; + private final Path basePluginPath; + + public VelocityPluginModule(ProxyServer server, JavaVelocityPluginDescription description, Path basePluginPath) { + this.server = server; + this.description = description; + this.basePluginPath = basePluginPath; + } + + @Override + public void configure(Binder binder) { + binder.bind(Logger.class).toInstance(LoggerFactory.getLogger(description.getId())); + binder.bind(ProxyServer.class).toInstance(server); + binder.bind(Path.class).annotatedWith(DataDirectory.class).toInstance(basePluginPath.resolve(description.getId())); + binder.bind(PluginDescription.class).toInstance(description); + binder.bind(PluginManager.class).toInstance(server.getPluginManager()); + binder.bind(EventManager.class).toInstance(server.getEventManager()); + binder.bind(CommandManager.class).toInstance(server.getCommandManager()); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java new file mode 100644 index 000000000..2dcfc79f9 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java @@ -0,0 +1,69 @@ +package com.velocitypowered.proxy.plugin.util; + +import com.google.common.collect.Maps; +import com.google.common.graph.Graph; +import com.google.common.graph.GraphBuilder; +import com.google.common.graph.MutableGraph; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.meta.PluginDependency; + +import java.util.*; + +public class PluginDependencyUtils { + public static List sortCandidates(List candidates) { + // Create our graph, we're going to be using this for Kahn's algorithm. + MutableGraph graph = GraphBuilder.directed().allowsSelfLoops(false).build(); + Map candidateMap = Maps.uniqueIndex(candidates, PluginDescription::getId); + + // Add edges + for (PluginDescription description : candidates) { + graph.addNode(description); + + for (PluginDependency dependency : description.getDependencies()) { + PluginDescription in = candidateMap.get(dependency.getId()); + + if (in != null) { + graph.putEdge(description, in); + } + } + } + + // Find nodes that have no edges + Queue noEdges = getNoDependencyCandidates(graph); + + // Actually run Kahn's algorithm + List sorted = new ArrayList<>(); + while (!noEdges.isEmpty()) { + PluginDescription candidate = noEdges.poll(); + sorted.add(candidate); + + for (PluginDescription node : graph.successors(candidate)) { + graph.removeEdge(node, candidate); + + if (graph.adjacentNodes(node).isEmpty()) { + if (!noEdges.contains(node)) { + noEdges.add(node); + } + } + } + } + + if (!graph.edges().isEmpty()) { + throw new IllegalStateException("Plugin circular dependency found: " + graph.toString()); + } + + return sorted; + } + + public static Queue getNoDependencyCandidates(Graph graph) { + Queue found = new ArrayDeque<>(); + + for (PluginDescription node : graph.nodes()) { + if (graph.outDegree(node) == 0) { + found.add(node); + } + } + + return found; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/PacketWrapper.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/PacketWrapper.java deleted file mode 100644 index 5e1fa8bfb..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/PacketWrapper.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.velocitypowered.proxy.protocol; - -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufUtil; - -public class PacketWrapper { - private final MinecraftPacket packet; - private final ByteBuf buffer; - - public PacketWrapper(MinecraftPacket packet, ByteBuf buffer) { - this.packet = packet; - this.buffer = buffer; - } - - public MinecraftPacket getPacket() { - return packet; - } - - public ByteBuf getBuffer() { - return buffer; - } - - @Override - public String toString() { - return "PacketWrapper{" + - "packet=" + packet + - ", buffer=" + ByteBufUtil.hexDump(buffer) + - '}'; - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolConstants.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolConstants.java index 54b021775..85a11d557 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolConstants.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolConstants.java @@ -1,8 +1,11 @@ package com.velocitypowered.proxy.protocol; -import java.util.Arrays; +import com.google.common.primitives.ImmutableIntArray; public enum ProtocolConstants { ; + public static final int LEGACY = -1; + + public static final int MINECRAFT_1_8 = 47; public static final int MINECRAFT_1_9 = 107; public static final int MINECRAFT_1_9_1 = 108; public static final int MINECRAFT_1_9_2 = 109; @@ -14,10 +17,15 @@ public enum ProtocolConstants { ; public static final int MINECRAFT_1_12_1 = 338; public static final int MINECRAFT_1_12_2 = 340; public static final int MINECRAFT_1_13 = 393; + public static final int MINECRAFT_1_13_1 = 401; - public static final int MINIMUM_GENERIC_VERSION = MINECRAFT_1_9; + public static final int MINIMUM_GENERIC_VERSION = MINECRAFT_1_8; + public static final int MAXIMUM_GENERIC_VERSION = MINECRAFT_1_13_1; - public static final int[] SUPPORTED_VERSIONS = new int[] { + public static final String SUPPORTED_GENERIC_VERSION_STRING = "1.8-1.13.1"; + + public static final ImmutableIntArray SUPPORTED_VERSIONS = ImmutableIntArray.of( + MINECRAFT_1_8, MINECRAFT_1_9, MINECRAFT_1_9_1, MINECRAFT_1_9_2, @@ -28,11 +36,12 @@ public enum ProtocolConstants { ; MINECRAFT_1_12, MINECRAFT_1_12_1, MINECRAFT_1_12_2, - MINECRAFT_1_13 - }; + MINECRAFT_1_13, + MINECRAFT_1_13_1 + ); public static boolean isSupported(int version) { - return Arrays.binarySearch(SUPPORTED_VERSIONS, version) >= 0; + return SUPPORTED_VERSIONS.contains(version); } public enum Direction { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java index 891cdf8c9..701ec301d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -1,8 +1,11 @@ package com.velocitypowered.proxy.protocol; +import com.google.common.primitives.ImmutableIntArray; import com.velocitypowered.proxy.protocol.packet.*; import io.netty.util.collection.IntObjectHashMap; import io.netty.util.collection.IntObjectMap; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; import java.util.*; import java.util.function.Supplier; @@ -10,6 +13,7 @@ import java.util.function.Supplier; import static com.velocitypowered.proxy.protocol.ProtocolConstants.*; public enum StateRegistry { + HANDSHAKE { { SERVERBOUND.register(Handshake.class, Handshake::new, @@ -31,75 +35,108 @@ public enum StateRegistry { }, PLAY { { + SERVERBOUND.register(TabCompleteRequest.class, TabCompleteRequest::new, + map(0x14, MINECRAFT_1_8, false), + map(0x01, MINECRAFT_1_9, false), + map(0x02, MINECRAFT_1_12, false), + map(0x01, MINECRAFT_1_12_1, false), + map(0x05, MINECRAFT_1_13, false)); SERVERBOUND.register(Chat.class, Chat::new, - map(0x02, MINECRAFT_1_9), - map(0x03, MINECRAFT_1_12), - map(0x02, MINECRAFT_1_12_2), - map(0x02, MINECRAFT_1_13)); + map(0x01, MINECRAFT_1_8, false), + map(0x02, MINECRAFT_1_9, false), + map(0x03, MINECRAFT_1_12, false), + map(0x02, MINECRAFT_1_12_2, false), + map(0x02, MINECRAFT_1_13, false)); SERVERBOUND.register(ClientSettings.class, ClientSettings::new, - map(0x04, MINECRAFT_1_9), - map(0x05, MINECRAFT_1_12), - map(0x04, MINECRAFT_1_12_1), - map(0x04, MINECRAFT_1_13)); + map(0x15, MINECRAFT_1_8, false), + map(0x04, MINECRAFT_1_9, false), + map(0x05, MINECRAFT_1_12, false), + map(0x04, MINECRAFT_1_12_1, false), + map(0x04, MINECRAFT_1_13, false)); SERVERBOUND.register(PluginMessage.class, PluginMessage::new, - map(0x09, MINECRAFT_1_9), - map(0x0A, MINECRAFT_1_12), - map(0x09, MINECRAFT_1_12_1), - map(0x0A, MINECRAFT_1_13)); + map(0x17, MINECRAFT_1_8, false), + map(0x09, MINECRAFT_1_9, false), + map(0x0A, MINECRAFT_1_12, false), + map(0x09, MINECRAFT_1_12_1, false), + map(0x0A, MINECRAFT_1_13, false)); SERVERBOUND.register(KeepAlive.class, KeepAlive::new, - map(0x0B, MINECRAFT_1_9), - map(0x0C, MINECRAFT_1_12), - map(0x0B, MINECRAFT_1_12_1), - map(0x0E, MINECRAFT_1_13)); + map(0x00, MINECRAFT_1_8, false), + map(0x0B, MINECRAFT_1_9, false), + map(0x0C, MINECRAFT_1_12, false), + map(0x0B, MINECRAFT_1_12_1, false), + map(0x0E, MINECRAFT_1_13, false)); CLIENTBOUND.register(BossBar.class, BossBar::new, - map(0x0C, MINECRAFT_1_9), - map(0x0C, MINECRAFT_1_12)); + map(0x0C, MINECRAFT_1_9, false), + map(0x0C, MINECRAFT_1_12, false), + map(0x0C, MINECRAFT_1_13, false)); CLIENTBOUND.register(Chat.class, Chat::new, - map(0x0F, MINECRAFT_1_9), - map(0x0F, MINECRAFT_1_12), - map(0x0E, MINECRAFT_1_13)); + map(0x02, MINECRAFT_1_8, true), + map(0x0F, MINECRAFT_1_9, true), + map(0x0F, MINECRAFT_1_12, true), + map(0x0E, MINECRAFT_1_13, true)); + CLIENTBOUND.register(TabCompleteResponse.class, TabCompleteResponse::new, + map(0x3A, MINECRAFT_1_8, true), + map(0x0E, MINECRAFT_1_9, true), + map(0x0E, MINECRAFT_1_12, true), + map(0x10, MINECRAFT_1_13, true)); CLIENTBOUND.register(PluginMessage.class, PluginMessage::new, - map(0x18, MINECRAFT_1_9), - map(0x18, MINECRAFT_1_12), - map(0x19, MINECRAFT_1_13)); + map(0x3F, MINECRAFT_1_8, false), + map(0x18, MINECRAFT_1_9, false), + map(0x18, MINECRAFT_1_12, false), + map(0x19, MINECRAFT_1_13, false)); CLIENTBOUND.register(Disconnect.class, Disconnect::new, - map(0x1A, MINECRAFT_1_9), - map(0x1A, MINECRAFT_1_12), - map(0x1B, MINECRAFT_1_13)); + map(0x40, MINECRAFT_1_8, false), + map(0x1A, MINECRAFT_1_9, false), + map(0x1A, MINECRAFT_1_12, false), + map(0x1B, MINECRAFT_1_13, false)); CLIENTBOUND.register(KeepAlive.class, KeepAlive::new, - map(0x1F, MINECRAFT_1_9), - map(0x1F, MINECRAFT_1_12), - map(0x21, MINECRAFT_1_13)); + map(0x00, MINECRAFT_1_8, false), + map(0x1F, MINECRAFT_1_9, false), + map(0x1F, MINECRAFT_1_12, false), + map(0x21, MINECRAFT_1_13, false)); CLIENTBOUND.register(JoinGame.class, JoinGame::new, - map(0x23, MINECRAFT_1_9), - map(0x23, MINECRAFT_1_12), - map(0x25, MINECRAFT_1_13)); + map(0x01, MINECRAFT_1_8, false), + map(0x23, MINECRAFT_1_9, false), + map(0x23, MINECRAFT_1_12, false), + map(0x25, MINECRAFT_1_13, false)); CLIENTBOUND.register(Respawn.class, Respawn::new, - map(0x33, MINECRAFT_1_9), - map(0x34, MINECRAFT_1_12), - map(0x35, MINECRAFT_1_12_2), - map(0x38, MINECRAFT_1_13)); + map(0x07, MINECRAFT_1_8, true), + map(0x33, MINECRAFT_1_9, true), + map(0x34, MINECRAFT_1_12, true), + map(0x35, MINECRAFT_1_12_2, true), + map(0x38, MINECRAFT_1_13, true)); + CLIENTBOUND.register(HeaderAndFooter.class, HeaderAndFooter::new, + map(0x47, MINECRAFT_1_8, true), + map(0x48, MINECRAFT_1_9, true), + map(0x47, MINECRAFT_1_9_4, true), + map(0x49, MINECRAFT_1_12, true), + map(0x4A, MINECRAFT_1_12_1, true), + map(0x4E, MINECRAFT_1_13, true)); CLIENTBOUND.register(ScoreboardDisplay.class, ScoreboardDisplay::new, - map(0x38, MINECRAFT_1_9), - map(0x3A, MINECRAFT_1_12), - map(0x3B, MINECRAFT_1_12_1), - map(0x3E, MINECRAFT_1_13)); + map(0x3D, MINECRAFT_1_8, true), + map(0x38, MINECRAFT_1_9, true), + map(0x3A, MINECRAFT_1_12, true), + map(0x3B, MINECRAFT_1_12_1, true), + map(0x3E, MINECRAFT_1_13, true)); CLIENTBOUND.register(ScoreboardObjective.class, ScoreboardObjective::new, - map(0x3F, MINECRAFT_1_9), - map(0x41, MINECRAFT_1_12), - map(0x42, MINECRAFT_1_12_1), - map(0x45, MINECRAFT_1_13)); + map(0x3B, MINECRAFT_1_8, true), + map(0x3F, MINECRAFT_1_9, true), + map(0x41, MINECRAFT_1_12, true), + map(0x42, MINECRAFT_1_12_1, true), + map(0x45, MINECRAFT_1_13, true)); CLIENTBOUND.register(ScoreboardTeam.class, ScoreboardTeam::new, - map(0x41, MINECRAFT_1_9), - map(0x43, MINECRAFT_1_12), - map(0x44, MINECRAFT_1_12_1), - map(0x47, MINECRAFT_1_13)); + map(0x3E, MINECRAFT_1_8, true), + map(0x41, MINECRAFT_1_9, true), + map(0x43, MINECRAFT_1_12, true), + map(0x44, MINECRAFT_1_12_1, true), + map(0x47, MINECRAFT_1_13, true)); CLIENTBOUND.register(ScoreboardSetScore.class, ScoreboardSetScore::new, - map(0x42, MINECRAFT_1_9), - map(0x44, MINECRAFT_1_12), - map(0x45, MINECRAFT_1_12_1), - map(0x48, MINECRAFT_1_13)); + map(0x3C, MINECRAFT_1_8, true), + map(0x42, MINECRAFT_1_9, true), + map(0x44, MINECRAFT_1_12, true), + map(0x45, MINECRAFT_1_12_1, true), + map(0x48, MINECRAFT_1_13, true)); } }, LOGIN { @@ -109,7 +146,7 @@ public enum StateRegistry { SERVERBOUND.register(EncryptionResponse.class, EncryptionResponse::new, genericMappings(0x01)); SERVERBOUND.register(LoginPluginResponse.class, LoginPluginResponse::new, - map(0x02, MINECRAFT_1_13)); + map(0x02, MINECRAFT_1_13, false)); CLIENTBOUND.register(Disconnect.class, Disconnect::new, genericMappings(0x00)); @@ -120,7 +157,7 @@ public enum StateRegistry { CLIENTBOUND.register(SetCompression.class, SetCompression::new, genericMappings(0x03)); CLIENTBOUND.register(LoginPluginMessage.class, LoginPluginMessage::new, - map(0x04, MINECRAFT_1_13)); + map(0x04, MINECRAFT_1_13, false)); } }; @@ -130,26 +167,24 @@ public enum StateRegistry { public final PacketRegistry SERVERBOUND = new PacketRegistry(ProtocolConstants.Direction.SERVERBOUND, this); public static class PacketRegistry { - private static final IntObjectMap LINKED_PROTOCOL_VERSIONS = new IntObjectHashMap<>(); + private static final IntObjectMap LINKED_PROTOCOL_VERSIONS = new IntObjectHashMap<>(); static { - LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_9, new int[] { MINECRAFT_1_9_1, MINECRAFT_1_9_2, MINECRAFT_1_9_4 }); - LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_9_4, new int[] { MINECRAFT_1_10, MINECRAFT_1_11, MINECRAFT_1_11_1 }); - LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_12, new int[] { MINECRAFT_1_12_1 }); - LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_12_1, new int[] { MINECRAFT_1_12_2 }); + LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_9, ImmutableIntArray.of(MINECRAFT_1_9_1, MINECRAFT_1_9_2, MINECRAFT_1_9_4)); + LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_9_4, ImmutableIntArray.of(MINECRAFT_1_10, MINECRAFT_1_11, MINECRAFT_1_11_1)); + LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_12, ImmutableIntArray.of(MINECRAFT_1_12_1)); + LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_12_1, ImmutableIntArray.of(MINECRAFT_1_12_2)); + LINKED_PROTOCOL_VERSIONS.put(MINECRAFT_1_13, ImmutableIntArray.of(MINECRAFT_1_13_1)); } private final ProtocolConstants.Direction direction; private final StateRegistry state; - private final IntObjectMap versions = new IntObjectHashMap<>(); + private final IntObjectMap versions = new IntObjectHashMap<>(16); public PacketRegistry(Direction direction, StateRegistry state) { this.direction = direction; this.state = state; - for (int version : ProtocolConstants.SUPPORTED_VERSIONS) { - versions.put(version, new ProtocolVersion(version)); - } - versions.put(MINIMUM_GENERIC_VERSION, new ProtocolVersion(MINIMUM_GENERIC_VERSION)); + ProtocolConstants.SUPPORTED_VERSIONS.forEach(version -> versions.put(version, new ProtocolVersion(version))); } public ProtocolVersion getVersion(final int version) { @@ -163,6 +198,7 @@ public enum StateRegistry { return result; } + public

void register(Class

clazz, Supplier

packetSupplier, PacketMapping... mappings) { if (mappings.length == 0) { throw new IllegalArgumentException("At least one mapping must be provided."); @@ -173,18 +209,20 @@ public enum StateRegistry { if (version == null) { throw new IllegalArgumentException("Unknown protocol version " + mapping.protocolVersion); } - - version.packetIdToSupplier.put(mapping.id, packetSupplier); + if (!mapping.encodeOnly) { + version.packetIdToSupplier.put(mapping.id, packetSupplier); + } version.packetClassToId.put(clazz, mapping.id); - int[] linked = LINKED_PROTOCOL_VERSIONS.get(mapping.protocolVersion); + ImmutableIntArray linked = LINKED_PROTOCOL_VERSIONS.get(mapping.protocolVersion); if (linked != null) { - links: for (int i : linked) { + links: for (int i = 0; i < linked.length(); i++) { + int linkedVersion = linked.get(i); // Make sure that later mappings override this one. for (PacketMapping m : mappings) { - if (i == m.protocolVersion) continue links; + if (linkedVersion == m.protocolVersion) continue links; } - register(clazz, packetSupplier, map(mapping.id, i)); + register(clazz, packetSupplier, map(mapping.id, linkedVersion, mapping.encodeOnly)); } } } @@ -192,11 +230,12 @@ public enum StateRegistry { public class ProtocolVersion { public final int id; - final IntObjectMap> packetIdToSupplier = new IntObjectHashMap<>(); - final Map, Integer> packetClassToId = new HashMap<>(); + final IntObjectMap> packetIdToSupplier = new IntObjectHashMap<>(16, 0.5f); + final Object2IntMap> packetClassToId = new Object2IntOpenHashMap<>(16, 0.5f); ProtocolVersion(final int id) { this.id = id; + this.packetClassToId.defaultReturnValue(Integer.MIN_VALUE); } public MinecraftPacket createPacket(final int id) { @@ -208,8 +247,8 @@ public enum StateRegistry { } public int getPacketId(final MinecraftPacket packet) { - final Integer id = this.packetClassToId.get(packet.getClass()); - if (id == null) { + final int id = this.packetClassToId.getInt(packet.getClass()); + if (id == Integer.MIN_VALUE) { throw new IllegalArgumentException(String.format( "Unable to find id for packet of type %s in %s protocol %s", packet.getClass().getName(), PacketRegistry.this.direction, this.id @@ -231,10 +270,12 @@ public enum StateRegistry { public static class PacketMapping { private final int id; private final int protocolVersion; - - public PacketMapping(int id, int protocolVersion) { + private final boolean encodeOnly; + + public PacketMapping(int id, int protocolVersion, boolean packetDecoding) { this.id = id; this.protocolVersion = protocolVersion; + this.encodeOnly = packetDecoding; } @Override @@ -242,6 +283,7 @@ public enum StateRegistry { return "PacketMapping{" + "id=" + id + ", protocolVersion=" + protocolVersion + + ", encodeOnly=" + encodeOnly + '}'; } @@ -251,24 +293,33 @@ public enum StateRegistry { if (o == null || getClass() != o.getClass()) return false; PacketMapping that = (PacketMapping) o; return id == that.id && - protocolVersion == that.protocolVersion; + protocolVersion == that.protocolVersion && + encodeOnly == that.encodeOnly; } @Override public int hashCode() { - return Objects.hash(id, protocolVersion); + return Objects.hash(id, protocolVersion, encodeOnly); } } - private static PacketMapping map(int id, int version) { - return new PacketMapping(id, version); + /** + * Creates a PacketMapping using the provided arguments + * @param id Packet Id + * @param version Protocol version + * @param encodeOnly When true packet decoding will be disabled + * @return PacketMapping with the provided arguments + */ + private static PacketMapping map(int id, int version, boolean encodeOnly) { + return new PacketMapping(id, version, encodeOnly); } - + private static PacketMapping[] genericMappings(int id) { return new PacketMapping[]{ - map(id, MINECRAFT_1_9), - map(id, MINECRAFT_1_12), - map(id, MINECRAFT_1_13) + map(id, MINECRAFT_1_8, false), + map(id, MINECRAFT_1_9, false), + map(id, MINECRAFT_1_12, false), + map(id, MINECRAFT_1_13, false) }; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java new file mode 100644 index 000000000..b0c5a838b --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java @@ -0,0 +1,190 @@ +package com.velocitypowered.proxy.protocol.netty; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.ImmutableSet; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.socket.DatagramPacket; +import net.kyori.text.serializer.ComponentSerializers; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class GS4QueryHandler extends SimpleChannelInboundHandler { + private static final Logger logger = LogManager.getLogger(GS4QueryHandler.class); + + private final static short QUERY_MAGIC_FIRST = 0xFE; + private final static short QUERY_MAGIC_SECOND = 0xFD; + private final static byte QUERY_TYPE_HANDSHAKE = 0x09; + private final static byte QUERY_TYPE_STAT = 0x00; + private final static byte[] QUERY_RESPONSE_FULL_PADDING = new byte[] { 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, (byte) 0x80, 0x00 }; + private final static byte[] QUERY_RESPONSE_FULL_PADDING2 = new byte[] { 0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00 }; + + // Contents to add into basic stat response. See ResponseWriter class below + private final static Set QUERY_BASIC_RESPONSE_CONTENTS = ImmutableSet.of( + "hostname", + "gametype", + "map", + "numplayers", + "maxplayers", + "hostport", + "hostip" + ); + + private final static Cache sessions = CacheBuilder.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .build(); + + @Override + protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket msg) throws Exception { + ByteBuf queryMessage = msg.content(); + InetAddress senderAddress = msg.sender().getAddress(); + + // Allocate buffer for response + ByteBuf queryResponse = ctx.alloc().buffer(); + DatagramPacket responsePacket = new DatagramPacket(queryResponse, msg.sender()); + + try { + // Verify query packet magic + if (queryMessage.readUnsignedByte() != QUERY_MAGIC_FIRST || queryMessage.readUnsignedByte() != QUERY_MAGIC_SECOND) { + throw new IllegalStateException("Invalid query packet magic"); + } + + // Read packet header + short type = queryMessage.readUnsignedByte(); + int sessionId = queryMessage.readInt(); + + switch (type) { + case QUERY_TYPE_HANDSHAKE: { + // Generate new challenge token and put it into the sessions cache + int challengeToken = ThreadLocalRandom.current().nextInt(); + sessions.put(senderAddress, challengeToken); + + // Respond with challenge token + queryResponse.writeByte(QUERY_TYPE_HANDSHAKE); + queryResponse.writeInt(sessionId); + writeString(queryResponse, Integer.toString(challengeToken)); + break; + } + + case QUERY_TYPE_STAT: { + // Check if query was done with session previously generated using a handshake packet + int challengeToken = queryMessage.readInt(); + Integer session = sessions.getIfPresent(senderAddress); + if (session == null || session != challengeToken) { + throw new IllegalStateException("Invalid challenge token"); + } + + // Check which query response client expects + if (queryMessage.readableBytes() != 0 && queryMessage.readableBytes() != 4) { + throw new IllegalStateException("Invalid query packet"); + } + + // Packet header + queryResponse.writeByte(QUERY_TYPE_STAT); + queryResponse.writeInt(sessionId); + + // Fetch information + VelocityServer server = VelocityServer.getServer(); + Collection players = server.getAllPlayers(); + + // Start writing the response + ResponseWriter responseWriter = new ResponseWriter(queryResponse, queryMessage.readableBytes() == 0); + responseWriter.write("hostname", ComponentSerializers.PLAIN.serialize(server.getConfiguration().getMotdComponent())); + responseWriter.write("gametype", "SMP"); + + responseWriter.write("game_id", "MINECRAFT"); + responseWriter.write("version", ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING); + responseWriter.write("plugins", ""); + + responseWriter.write("map", "Velocity"); + responseWriter.write("numplayers", players.size()); + responseWriter.write("maxplayers", server.getConfiguration().getShowMaxPlayers()); + responseWriter.write("hostport", server.getConfiguration().getBind().getPort()); + responseWriter.write("hostip", server.getConfiguration().getBind().getHostString()); + + responseWriter.writePlayers(players); + break; + } + + default: { + throw new IllegalStateException("Invalid query type: " + type); + } + } + + // Send the response + ctx.writeAndFlush(responsePacket); + } catch (Exception e) { + logger.warn("Error while trying to handle a query packet from {}", msg.sender(), e); + responsePacket.release(); + } + } + + private static void writeString(ByteBuf buf, String string) { + buf.writeCharSequence(string, StandardCharsets.ISO_8859_1); + buf.writeByte(0x00); + } + + private static class ResponseWriter { + private final ByteBuf buf; + private final boolean isBasic; + + ResponseWriter(ByteBuf buf, boolean isBasic) { + this.buf = buf; + this.isBasic = isBasic; + + if (!isBasic) { + buf.writeBytes(QUERY_RESPONSE_FULL_PADDING); + } + } + + // Writes k/v to stat packet body if this writer is initialized + // for full stat response. Otherwise this follows + // GS4QueryHandler#QUERY_BASIC_RESPONSE_CONTENTS to decide what + // to write into packet body + void write(String key, Object value) { + if (isBasic) { + // Basic contains only specific set of data + if (!QUERY_BASIC_RESPONSE_CONTENTS.contains(key)) { + return; + } + + // Special case hostport + if (key.equals("hostport")) { + buf.writeShortLE((Integer) value); + } else { + writeString(buf, value.toString()); + } + } else { + writeString(buf, key); + writeString(buf, value.toString()); + } + } + + // Ends packet k/v body writing and writes stat player list to + // the packet if this writer is initialized for full stat response + void writePlayers(Collection players) { + if (isBasic) { + return; + } + + // Ends the full stat key-value body with \0 + buf.writeByte(0x00); + + buf.writeBytes(QUERY_RESPONSE_FULL_PADDING2); + players.forEach(player -> writeString(buf, player.getUsername())); + buf.writeByte(0x00); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingDecoder.java index 46e41a519..dfe396c7e 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingDecoder.java @@ -1,5 +1,6 @@ package com.velocitypowered.proxy.protocol.netty; +import com.velocitypowered.proxy.protocol.packet.LegacyHandshake; import com.velocitypowered.proxy.protocol.packet.LegacyPing; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; @@ -19,8 +20,11 @@ public class LegacyPingDecoder extends ByteToMessageDecoder { if (first == 0xfe && second == 0x01) { in.skipBytes(in.readableBytes()); out.add(new LegacyPing()); + } else if (first == 0x02) { + in.skipBytes(in.readableBytes()); + out.add(new LegacyHandshake()); + } else { + ctx.pipeline().remove(this); } - - ctx.pipeline().remove(this); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingEncoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingEncoder.java index 6ef41fe51..3b31305e1 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingEncoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/LegacyPingEncoder.java @@ -1,39 +1,27 @@ package com.velocitypowered.proxy.protocol.netty; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; -import com.velocitypowered.proxy.protocol.packet.LegacyPingResponse; +import com.velocitypowered.proxy.protocol.packet.LegacyDisconnect; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToByteEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; @ChannelHandler.Sharable -public class LegacyPingEncoder extends MessageToByteEncoder { +public class LegacyPingEncoder extends MessageToByteEncoder { public static final LegacyPingEncoder INSTANCE = new LegacyPingEncoder(); private LegacyPingEncoder() {} @Override - protected void encode(ChannelHandlerContext ctx, LegacyPingResponse msg, ByteBuf out) throws Exception { + protected void encode(ChannelHandlerContext ctx, LegacyDisconnect msg, ByteBuf out) throws Exception { out.writeByte(0xff); - String serializedResponse = serialize(msg); - out.writeShort(serializedResponse.length()); - out.writeBytes(serializedResponse.getBytes(StandardCharsets.UTF_16BE)); + writeLegacyString(out, msg.getReason()); } - private String serialize(LegacyPingResponse response) { - List parts = ImmutableList.of( - "§1", - Integer.toString(response.getProtocolVersion()), - response.getServerVersion(), - response.getMotd(), - Integer.toString(response.getPlayersOnline()), - Integer.toString(response.getPlayersMax()) - ); - return Joiner.on('\0').join(parts); + private static void writeLegacyString(ByteBuf out, String string) { + out.writeShort(string.length()); + out.writeBytes(string.getBytes(StandardCharsets.UTF_16BE)); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressEncoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressEncoder.java index f62834b93..802f45121 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressEncoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressEncoder.java @@ -28,6 +28,15 @@ public class MinecraftCompressEncoder extends MessageToByteEncoder { } } + @Override + protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, ByteBuf msg, boolean preferDirect) throws Exception { + if (msg.readableBytes() <= threshold) { + return ctx.alloc().directBuffer(msg.readableBytes() + 1); + } + // A reasonable assumption about compression savings + return ctx.alloc().directBuffer(msg.readableBytes() / 3); + } + @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { compressor.dispose(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java index d8768987a..4e05b3647 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java @@ -26,13 +26,13 @@ public class MinecraftDecoder extends MessageToMessageDecoder { return; } - ByteBuf slice = msg.retainedSlice(); + ByteBuf slice = msg.slice(); int packetId = ProtocolUtils.readVarInt(msg); MinecraftPacket packet = this.protocolVersion.createPacket(packetId); if (packet == null) { msg.skipBytes(msg.readableBytes()); - out.add(new PacketWrapper(null, slice)); + out.add(slice.retain()); } else { try { packet.decode(msg, direction, protocolVersion.id); @@ -40,7 +40,11 @@ public class MinecraftDecoder extends MessageToMessageDecoder { throw new CorruptedFrameException("Error decoding " + packet.getClass() + " Direction " + direction + " Protocol " + protocolVersion + " State " + state + " ID " + Integer.toHexString(packetId), e); } - out.add(new PacketWrapper(packet, slice)); + if (msg.isReadable()) { + throw new CorruptedFrameException("Did not read full packet for " + packet.getClass() + " Direction " + direction + + " Protocol " + protocolVersion + " State " + state + " ID " + Integer.toHexString(packetId)); + } + out.add(packet); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientSettings.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientSettings.java index 74ca21bf8..cd5a92bab 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientSettings.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ClientSettings.java @@ -80,7 +80,10 @@ public class ClientSettings implements MinecraftPacket { this.chatVisibility = ProtocolUtils.readVarInt(buf); this.chatColors = buf.readBoolean(); this.skinParts = buf.readUnsignedByte(); - this.mainHand = ProtocolUtils.readVarInt(buf); + + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9) { + this.mainHand = ProtocolUtils.readVarInt(buf); + } } @Override @@ -90,6 +93,9 @@ public class ClientSettings implements MinecraftPacket { ProtocolUtils.writeVarInt(buf, chatVisibility); buf.writeBoolean(chatColors); buf.writeByte(skinParts); - ProtocolUtils.writeVarInt(buf, mainHand); + + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9) { + ProtocolUtils.writeVarInt(buf, mainHand); + } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/HeaderAndFooter.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/HeaderAndFooter.java new file mode 100644 index 000000000..35c75af30 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/HeaderAndFooter.java @@ -0,0 +1,62 @@ +package com.velocitypowered.proxy.protocol.packet; + +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants.Direction; +import static com.velocitypowered.proxy.protocol.ProtocolUtils.writeString; + +import io.netty.buffer.ByteBuf; +import net.kyori.text.Component; +import net.kyori.text.serializer.ComponentSerializer; +import net.kyori.text.serializer.ComponentSerializers; + +public class HeaderAndFooter implements MinecraftPacket { + + private static final HeaderAndFooter RESET = new HeaderAndFooter("{\"translate\":\"\"}", "{\"translate\":\"\"}"); + + private String header; + private String footer; + + public HeaderAndFooter() { + } + + public HeaderAndFooter(String header, String footer) { + this.header = header; + this.footer = footer; + } + + public String getHeader() { + return header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getFooter() { + return footer; + } + + public void setFooter(String footer) { + this.footer = footer; + } + + @Override + public void decode(ByteBuf buf, Direction direction, int protocolVersion) { + throw new UnsupportedOperationException("Decode is not implemented"); + } + + @Override + public void encode(ByteBuf buf, Direction direction, int protocolVersion) { + writeString(buf, header); + writeString(buf, footer); + } + + public static HeaderAndFooter create(Component header, Component footer) { + ComponentSerializer json = ComponentSerializers.JSON; + return new HeaderAndFooter(json.serialize(header), json.serialize(footer)); + } + + public static HeaderAndFooter reset() { + return RESET; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/JoinGame.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/JoinGame.java index 100950a74..69aaa92da 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/JoinGame.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/JoinGame.java @@ -87,7 +87,11 @@ public class JoinGame implements MinecraftPacket { public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { this.entityId = buf.readInt(); this.gamemode = buf.readUnsignedByte(); - this.dimension = buf.readInt(); + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9_1) { + this.dimension = buf.readInt(); + } else { + this.dimension = buf.readByte(); + } this.difficulty = buf.readUnsignedByte(); this.maxPlayers = buf.readUnsignedByte(); this.levelType = ProtocolUtils.readString(buf, 16); @@ -98,7 +102,11 @@ public class JoinGame implements MinecraftPacket { public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { buf.writeInt(entityId); buf.writeByte(gamemode); - buf.writeInt(dimension); + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9_1) { + buf.writeInt(dimension); + } else { + buf.writeByte(dimension); + } buf.writeByte(difficulty); buf.writeByte(maxPlayers); ProtocolUtils.writeString(buf, levelType); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyDisconnect.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyDisconnect.java new file mode 100644 index 000000000..7a4ef0f0f --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyDisconnect.java @@ -0,0 +1,32 @@ +package com.velocitypowered.proxy.protocol.packet; + +import net.kyori.text.TextComponent; +import net.kyori.text.serializer.ComponentSerializers; + +public class LegacyDisconnect { + private final String reason; + + public LegacyDisconnect(String reason) { + this.reason = reason; + } + + public static LegacyDisconnect fromPingResponse(LegacyPingResponse response) { + String kickMessage = String.join("\0", + "§1", + Integer.toString(response.getProtocolVersion()), + response.getServerVersion(), + response.getMotd(), + Integer.toString(response.getPlayersOnline()), + Integer.toString(response.getPlayersMax()) + ); + return new LegacyDisconnect(kickMessage); + } + + public static LegacyDisconnect from(TextComponent component) { + return new LegacyDisconnect(ComponentSerializers.LEGACY.serialize(component)); + } + + public String getReason() { + return reason; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyHandshake.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyHandshake.java new file mode 100644 index 000000000..f6588674f --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyHandshake.java @@ -0,0 +1,17 @@ +package com.velocitypowered.proxy.protocol.packet; + +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import io.netty.buffer.ByteBuf; + +public class LegacyHandshake implements MinecraftPacket { + @Override + public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + throw new UnsupportedOperationException(); + } + + @Override + public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + throw new UnsupportedOperationException(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPing.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPing.java index ea75cd214..847160869 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPing.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPing.java @@ -1,4 +1,17 @@ package com.velocitypowered.proxy.protocol.packet; -public class LegacyPing { +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import io.netty.buffer.ByteBuf; + +public class LegacyPing implements MinecraftPacket { + @Override + public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + throw new UnsupportedOperationException(); + } + + @Override + public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + throw new UnsupportedOperationException(); + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPingResponse.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPingResponse.java index 7e0491b2a..2b733f5ef 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPingResponse.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LegacyPingResponse.java @@ -1,6 +1,6 @@ package com.velocitypowered.proxy.protocol.packet; -import com.velocitypowered.proxy.data.ServerPing; +import com.velocitypowered.api.proxy.server.ServerPing; import net.kyori.text.serializer.ComponentSerializers; public class LegacyPingResponse { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ScoreboardTeam.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ScoreboardTeam.java index 25a0b6bb3..e6a3d7421 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ScoreboardTeam.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ScoreboardTeam.java @@ -138,7 +138,9 @@ public class ScoreboardTeam implements MinecraftPacket { } this.flags = buf.readByte(); this.nameTagVisibility = ProtocolUtils.readString(buf, 32); - this.collisionRule = ProtocolUtils.readString(buf, 32); + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9) { + this.collisionRule = ProtocolUtils.readString(buf, 32); + } this.color = protocolVersion <= ProtocolConstants.MINECRAFT_1_12_2 ? buf.readByte() : ProtocolUtils.readVarInt(buf); if (protocolVersion >= ProtocolConstants.MINECRAFT_1_13) { @@ -172,7 +174,9 @@ public class ScoreboardTeam implements MinecraftPacket { } buf.writeByte(flags); ProtocolUtils.writeString(buf, nameTagVisibility); - ProtocolUtils.writeString(buf, collisionRule); + if (protocolVersion >= ProtocolConstants.MINECRAFT_1_9) { + ProtocolUtils.writeString(buf, collisionRule); + } if (protocolVersion >= ProtocolConstants.MINECRAFT_1_13) { ProtocolUtils.writeVarInt(buf, color); ProtocolUtils.writeScoreboardTextComponent(buf, protocolVersion, prefix); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteRequest.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteRequest.java new file mode 100644 index 000000000..10c14b400 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteRequest.java @@ -0,0 +1,102 @@ +package com.velocitypowered.proxy.protocol.packet; + +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import io.netty.buffer.ByteBuf; + +import static com.velocitypowered.proxy.protocol.ProtocolConstants.MINECRAFT_1_13; +import static com.velocitypowered.proxy.protocol.ProtocolConstants.MINECRAFT_1_9; + +public class TabCompleteRequest implements MinecraftPacket { + private int transactionId; + private String command; + private boolean assumeCommand; + private boolean hasPosition; + private long position; + + public int getTransactionId() { + return transactionId; + } + + public void setTransactionId(int transactionId) { + this.transactionId = transactionId; + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public boolean isAssumeCommand() { + return assumeCommand; + } + + public void setAssumeCommand(boolean assumeCommand) { + this.assumeCommand = assumeCommand; + } + + public boolean isHasPosition() { + return hasPosition; + } + + public void setHasPosition(boolean hasPosition) { + this.hasPosition = hasPosition; + } + + public long getPosition() { + return position; + } + + public void setPosition(long position) { + this.position = position; + } + + @Override + public String toString() { + return "TabCompleteRequest{" + + "transactionId=" + transactionId + + ", command='" + command + '\'' + + ", assumeCommand=" + assumeCommand + + ", hasPosition=" + hasPosition + + ", position=" + position + + '}'; + } + + @Override + public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + if (protocolVersion >= MINECRAFT_1_13) { + this.transactionId = ProtocolUtils.readVarInt(buf); + this.command = ProtocolUtils.readString(buf); + } else { + this.command = ProtocolUtils.readString(buf); + if (protocolVersion >= MINECRAFT_1_9) { + this.assumeCommand = buf.readBoolean(); + } + this.hasPosition = buf.readBoolean(); + if (hasPosition) { + this.position = buf.readLong(); + } + } + } + + @Override + public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + if (protocolVersion >= MINECRAFT_1_13) { + ProtocolUtils.writeVarInt(buf, transactionId); + ProtocolUtils.writeString(buf, command); + } else { + ProtocolUtils.writeString(buf, command); + if (protocolVersion >= MINECRAFT_1_9) { + buf.writeBoolean(assumeCommand); + } + buf.writeBoolean(hasPosition); + if (hasPosition) { + buf.writeLong(position); + } + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteResponse.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteResponse.java new file mode 100644 index 000000000..df80fb626 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/TabCompleteResponse.java @@ -0,0 +1,128 @@ +package com.velocitypowered.proxy.protocol.packet; + +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolConstants; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import io.netty.buffer.ByteBuf; +import net.kyori.text.Component; +import net.kyori.text.TextComponent; +import net.kyori.text.serializer.ComponentSerializers; + +import java.util.ArrayList; +import java.util.List; + +import static com.velocitypowered.proxy.protocol.ProtocolConstants.MINECRAFT_1_13; + +public class TabCompleteResponse implements MinecraftPacket { + private int transactionId; + private int start; + private int length; + private final List offers = new ArrayList<>(); + + public int getTransactionId() { + return transactionId; + } + + public void setTransactionId(int transactionId) { + this.transactionId = transactionId; + } + + public int getStart() { + return start; + } + + public void setStart(int start) { + this.start = start; + } + + public int getLength() { + return length; + } + + public void setLength(int length) { + this.length = length; + } + + public List getOffers() { + return offers; + } + + @Override + public String toString() { + return "TabCompleteResponse{" + + "transactionId=" + transactionId + + ", start=" + start + + ", length=" + length + + ", offers=" + offers + + '}'; + } + + @Override + public void decode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + if (protocolVersion >= MINECRAFT_1_13) { + this.transactionId = ProtocolUtils.readVarInt(buf); + this.start = ProtocolUtils.readVarInt(buf); + this.length = ProtocolUtils.readVarInt(buf); + int offersAvailable = ProtocolUtils.readVarInt(buf); + for (int i = 0; i < offersAvailable; i++) { + String entry = ProtocolUtils.readString(buf); + Component component = buf.readBoolean() ? ComponentSerializers.JSON.deserialize(ProtocolUtils.readString(buf)) : + null; + offers.add(new Offer(entry, component)); + } + } else { + int offersAvailable = ProtocolUtils.readVarInt(buf); + for (int i = 0; i < offersAvailable; i++) { + offers.add(new Offer(ProtocolUtils.readString(buf), null)); + } + } + } + + @Override + public void encode(ByteBuf buf, ProtocolConstants.Direction direction, int protocolVersion) { + if (protocolVersion >= MINECRAFT_1_13) { + ProtocolUtils.writeVarInt(buf, transactionId); + ProtocolUtils.writeVarInt(buf, start); + ProtocolUtils.writeVarInt(buf, length); + ProtocolUtils.writeVarInt(buf, offers.size()); + for (Offer offer : offers) { + ProtocolUtils.writeString(buf, offer.entry); + buf.writeBoolean(offer.tooltip != null); + if (offer.tooltip != null) { + ProtocolUtils.writeString(buf, ComponentSerializers.JSON.serialize(offer.tooltip)); + } + } + } else { + ProtocolUtils.writeVarInt(buf, offers.size()); + for (Offer offer : offers) { + ProtocolUtils.writeString(buf, offer.entry); + } + } + } + + public static class Offer { + private final String entry; + private final Component tooltip; + + public Offer(String entry, Component tooltip) { + this.entry = entry; + this.tooltip = tooltip; + } + + public String getEntry() { + return entry; + } + + public Component getTooltip() { + return tooltip; + } + + @Override + public String toString() { + return "Offer{" + + "entry='" + entry + '\'' + + ", tooltip=" + tooltip + + '}'; + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/remap/EntityIdRemapper.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/remap/EntityIdRemapper.java index ad277d762..c112b344f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/remap/EntityIdRemapper.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/remap/EntityIdRemapper.java @@ -5,7 +5,7 @@ import io.netty.buffer.ByteBuf; /** * Represents a protocol-specific entity ID remapper for certain Minecraft packets. This is mostly required to support - * old versions of Minecraft. For Minecraft 1.9 clients and above, Velocity can use a more efficient method based on + * old versions of Minecraft. For Minecraft 1.8 clients and above, Velocity can use a more efficient method based on * sending JoinGame packets multiple times. */ public interface EntityIdRemapper { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/FaviconSerializer.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/FaviconSerializer.java new file mode 100644 index 000000000..012c9fca0 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/FaviconSerializer.java @@ -0,0 +1,18 @@ +package com.velocitypowered.proxy.protocol.util; + +import com.google.gson.*; +import com.velocitypowered.api.util.Favicon; + +import java.lang.reflect.Type; + +public class FaviconSerializer implements JsonSerializer, JsonDeserializer { + @Override + public Favicon deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return new Favicon(json.getAsString()); + } + + @Override + public JsonElement serialize(Favicon src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.getBase64Url()); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/scheduler/Sleeper.java b/proxy/src/main/java/com/velocitypowered/proxy/scheduler/Sleeper.java new file mode 100644 index 000000000..b090ad110 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/scheduler/Sleeper.java @@ -0,0 +1,7 @@ +package com.velocitypowered.proxy.scheduler; + +public interface Sleeper { + void sleep(long ms) throws InterruptedException; + + Sleeper SYSTEM = Thread::sleep; +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/scheduler/VelocityScheduler.java b/proxy/src/main/java/com/velocitypowered/proxy/scheduler/VelocityScheduler.java new file mode 100644 index 000000000..fdd577f34 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/scheduler/VelocityScheduler.java @@ -0,0 +1,177 @@ +package com.velocitypowered.proxy.scheduler; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.velocitypowered.api.plugin.PluginManager; +import com.velocitypowered.api.scheduler.ScheduledTask; +import com.velocitypowered.api.scheduler.Scheduler; +import com.velocitypowered.api.scheduler.TaskStatus; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class VelocityScheduler implements Scheduler { + private final PluginManager pluginManager; + private final ExecutorService taskService; + private final Sleeper sleeper; + private final Multimap tasksByPlugin = Multimaps.synchronizedListMultimap( + Multimaps.newListMultimap(new IdentityHashMap<>(), ArrayList::new)); + + public VelocityScheduler(PluginManager pluginManager, Sleeper sleeper) { + this.pluginManager = pluginManager; + this.sleeper = sleeper; + this.taskService = Executors.newCachedThreadPool(new ThreadFactoryBuilder().setDaemon(true) + .setNameFormat("Velocity Task Scheduler - #%d").build()); + } + + @Override + public TaskBuilder buildTask(Object plugin, Runnable runnable) { + Preconditions.checkNotNull(plugin, "plugin"); + Preconditions.checkNotNull(runnable, "runnable"); + Preconditions.checkArgument(pluginManager.fromInstance(plugin).isPresent(), "plugin is not registered"); + return new TaskBuilderImpl(plugin, runnable); + } + + public boolean shutdown() throws InterruptedException { + for (ScheduledTask task : ImmutableList.copyOf(tasksByPlugin.values())) { + task.cancel(); + } + taskService.shutdown(); + return taskService.awaitTermination(10, TimeUnit.SECONDS); + } + + private class TaskBuilderImpl implements TaskBuilder { + private final Object plugin; + private final Runnable runnable; + private long delay; // ms + private long repeat; // ms + + private TaskBuilderImpl(Object plugin, Runnable runnable) { + this.plugin = plugin; + this.runnable = runnable; + } + + @Override + public TaskBuilder delay(int time, TimeUnit unit) { + this.delay = unit.toMillis(time); + return this; + } + + @Override + public TaskBuilder repeat(int time, TimeUnit unit) { + this.repeat = unit.toMillis(time); + return this; + } + + @Override + public TaskBuilder clearDelay() { + this.delay = 0; + return this; + } + + @Override + public TaskBuilder clearRepeat() { + this.repeat = 0; + return this; + } + + @Override + public ScheduledTask schedule() { + VelocityTask task = new VelocityTask(plugin, runnable, delay, repeat); + taskService.execute(task); + tasksByPlugin.put(plugin, task); + return task; + } + } + + private class VelocityTask implements Runnable, ScheduledTask { + private final Object plugin; + private final Runnable runnable; + private final long delay; + private final long repeat; + private volatile TaskStatus status; + private Thread taskThread; + + private VelocityTask(Object plugin, Runnable runnable, long delay, long repeat) { + this.plugin = plugin; + this.runnable = runnable; + this.delay = delay; + this.repeat = repeat; + this.status = TaskStatus.SCHEDULED; + } + + @Override + public Object plugin() { + return plugin; + } + + @Override + public TaskStatus status() { + return status; + } + + @Override + public void cancel() { + if (status == TaskStatus.SCHEDULED) { + status = TaskStatus.CANCELLED; + if (taskThread != null) { + taskThread.interrupt(); + } + } + } + + @Override + public void run() { + taskThread = Thread.currentThread(); + if (delay > 0) { + try { + sleeper.sleep(delay); + } catch (InterruptedException e) { + if (status == TaskStatus.CANCELLED) { + onFinish(); + return; + } + } + } + + while (status != TaskStatus.CANCELLED) { + try { + runnable.run(); + } catch (Exception e) { + Log.logger.error("Exception in task {} by plugin {}", runnable, plugin); + } + + if (repeat > 0) { + try { + sleeper.sleep(repeat); + } catch (InterruptedException e) { + if (status == TaskStatus.CANCELLED) { + break; + } + } + } else { + status = TaskStatus.FINISHED; + break; + } + } + + onFinish(); + } + + private void onFinish() { + tasksByPlugin.remove(plugin, this); + } + } + + private static class Log { + private static final Logger logger = LogManager.getLogger(VelocityTask.class); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java b/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java new file mode 100644 index 000000000..6095a56cd --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java @@ -0,0 +1,47 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Ticker; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import java.net.InetAddress; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +public class Ratelimiter { + private final Cache expiringCache; + private final long timeoutNanos; + + public Ratelimiter(long timeoutMs) { + this(timeoutMs, Ticker.systemTicker()); + } + + @VisibleForTesting + Ratelimiter(long timeoutMs, Ticker ticker) { + if (timeoutMs == 0) { + this.timeoutNanos = timeoutMs; + this.expiringCache = null; + } else { + this.timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs); + this.expiringCache = CacheBuilder.newBuilder() + .ticker(ticker) + .concurrencyLevel(Runtime.getRuntime().availableProcessors()) + .expireAfterWrite(timeoutMs, TimeUnit.MILLISECONDS) + .build(); + } + } + + public boolean attempt(InetAddress address) { + if (timeoutNanos == 0) return true; + long expectedNewValue = System.nanoTime() + timeoutNanos; + long last; + try { + last = expiringCache.get(address, () -> expectedNewValue); + } catch (ExecutionException e) { + // It should be impossible for this to fail. + throw new AssertionError(e); + } + return expectedNewValue == last; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/ServerMap.java b/proxy/src/main/java/com/velocitypowered/proxy/util/ServerMap.java index eb0948c26..75784ef0b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/util/ServerMap.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/ServerMap.java @@ -2,27 +2,55 @@ package com.velocitypowered.proxy.util; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; -import com.velocitypowered.api.server.ServerInfo; +import com.velocitypowered.api.proxy.server.ServerInfo; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; public class ServerMap { private final Map servers = new HashMap<>(); + private final ReadWriteLock lock = new ReentrantReadWriteLock(); - public Optional getServer(String name) { - Preconditions.checkNotNull(name, "name"); - return Optional.ofNullable(servers.get(name.toLowerCase())); + public Optional getServer(String server) { + Preconditions.checkNotNull(server, "server"); + String lowerName = server.toLowerCase(Locale.US); + lock.readLock().lock(); + try { + return Optional.ofNullable(servers.get(lowerName)); + } finally { + lock.readLock().unlock(); + } } public Collection getAllServers() { - return ImmutableList.copyOf(servers.values()); + lock.readLock().lock(); + try { + return ImmutableList.copyOf(servers.values()); + } finally { + lock.readLock().unlock(); + } } - public void register(ServerInfo info) { - Preconditions.checkNotNull(info, "info"); - servers.put(info.getName(), info); + public void register(ServerInfo server) { + Preconditions.checkNotNull(server, "server"); + String lowerName = server.getName().toLowerCase(Locale.US); + lock.writeLock().lock(); + try { + Preconditions.checkArgument(servers.putIfAbsent(lowerName, server) == null, "Server with name %s already registered", server.getName()); + } finally { + lock.writeLock().unlock(); + } + } + + public void unregister(ServerInfo server) { + Preconditions.checkNotNull(server, "server"); + String lowerName = server.getName().toLowerCase(Locale.US); + lock.writeLock().lock(); + try { + Preconditions.checkArgument(servers.remove(lowerName, server), "Server with this name is not registered!"); + } finally { + lock.writeLock().unlock(); + } } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/UuidUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/util/UuidUtils.java deleted file mode 100644 index d42810ab2..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/util/UuidUtils.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.velocitypowered.proxy.util; - -import com.google.common.base.Preconditions; - -import java.nio.charset.StandardCharsets; -import java.util.Objects; -import java.util.UUID; - -public enum UuidUtils { - ; - - public static UUID fromUndashed(final String string) { - Objects.requireNonNull(string, "string"); - Preconditions.checkArgument(string.length() == 32, "Length is incorrect"); - return new UUID( - Long.parseUnsignedLong(string.substring(0, 16), 16), - Long.parseUnsignedLong(string.substring(16), 16) - ); - } - - public static String toUndashed(final UUID uuid) { - Preconditions.checkNotNull(uuid, "uuid"); - return Long.toUnsignedString(uuid.getMostSignificantBits(), 16) + Long.toUnsignedString(uuid.getLeastSignificantBits(), 16); - } - - public static UUID generateOfflinePlayerUuid(String username) { - return UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactory.java new file mode 100644 index 000000000..ae289bb1c --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactory.java @@ -0,0 +1,45 @@ +package com.velocitypowered.proxy.util.concurrency; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.collect.MapMaker; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadFactory; + +/** + * A {@link ThreadFactory} that records the threads it has created. Once a thread terminates, it is automatically removed + * from the recorder. + */ +public class RecordingThreadFactory implements ThreadFactory { + private final ThreadFactory backing; + private final Set threads = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap()); + + public RecordingThreadFactory(ThreadFactory backing) { + this.backing = Preconditions.checkNotNull(backing, "backing"); + } + + @Override + public Thread newThread(Runnable runnable) { + Preconditions.checkNotNull(runnable, "runnable"); + return backing.newThread(() -> { + threads.add(Thread.currentThread()); + try { + runnable.run(); + } finally { + threads.remove(Thread.currentThread()); + } + }); + } + + public boolean currentlyInFactory() { + return threads.contains(Thread.currentThread()); + } + + @VisibleForTesting + int size() { + return threads.size(); + } +} diff --git a/proxy/src/main/resources/log4j2.component.properties b/proxy/src/main/resources/log4j2.component.properties new file mode 100644 index 000000000..6ed08f31f --- /dev/null +++ b/proxy/src/main/resources/log4j2.component.properties @@ -0,0 +1 @@ +log4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector diff --git a/proxy/src/main/resources/log4j2.xml b/proxy/src/main/resources/log4j2.xml index bddab2847..0dc941c11 100644 --- a/proxy/src/main/resources/log4j2.xml +++ b/proxy/src/main/resources/log4j2.xml @@ -1,12 +1,18 @@ - - - + + + + + + + + - + @@ -16,7 +22,7 @@ - + diff --git a/proxy/src/main/resources/velocity.toml b/proxy/src/main/resources/velocity.toml new file mode 100644 index 000000000..24a7921bd --- /dev/null +++ b/proxy/src/main/resources/velocity.toml @@ -0,0 +1,55 @@ +# What port should the proxy be bound to? By default, we'll bind to all addresses on port 25577. +bind = "0.0.0.0:25577" + +# What should be the MOTD? Legacy color codes and JSON are accepted. +motd = "&3A Velocity Server" + +# What should we display for the maximum number of players? (Velocity does not support a cap +# on the number of players online.) +show-max-players = 500 + +# Should we authenticate players with Mojang? By default, this is on. +online-mode = true + +# Should we forward IP addresses and other data to backend servers? +# Available options: +# - "none": No forwarding will be done. All players will appear to be connecting from the proxy +# and will have offline-mode UUIDs. +# - "legacy": Forward player IPs and UUIDs in BungeeCord-compatible fashion. Use this if you run +# servers using Minecraft 1.12 or lower. +# - "modern": Forward player IPs and UUIDs as part of the login process using Velocity's native +# forwarding. Only applicable for Minecraft 1.13 or higher. +player-info-forwarding = "modern" + +# If you are using modern IP forwarding, configure an unique secret here. +player-info-forwarding-secret = "5up3r53cr3t" + +[servers] +# Configure your servers here. +lobby = "127.0.0.1:30066" +factions = "127.0.0.1:30067" +minigames = "127.0.0.1:30068" + +# In what order we should try servers when a player logs in or is kicked from a server. +try = [ + "lobby" +] + +[advanced] +# How large a Minecraft packet has to be before we compress it. Setting this to zero will compress all packets, and +# setting it to -1 will disable compression entirely. +compression-threshold = 1024 + +# How much compression should be done (from 0-9). The default is -1, which uses zlib's default level of 6. +compression-level = -1 + +# How fast (in miliseconds) are clients allowed to connect after the last connection? Default: 3000 +# Disable by setting to 0 +login-ratelimit = 3000 + +[query] +# Whether to enable responding to GameSpy 4 query responses or not +enabled = false + +# If query responding is enabled, on what port should query response listener listen on? +port = 25577 \ No newline at end of file diff --git a/proxy/src/test/java/com/velocitypowered/proxy/protocol/PacketRegistryTest.java b/proxy/src/test/java/com/velocitypowered/proxy/protocol/PacketRegistryTest.java index 187679f6c..dac88e563 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/protocol/PacketRegistryTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/protocol/PacketRegistryTest.java @@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.*; class PacketRegistryTest { private StateRegistry.PacketRegistry setupRegistry() { StateRegistry.PacketRegistry registry = new StateRegistry.PacketRegistry(ProtocolConstants.Direction.CLIENTBOUND, StateRegistry.HANDSHAKE); - registry.register(Handshake.class, Handshake::new, new StateRegistry.PacketMapping(0x00, MINECRAFT_1_12)); + registry.register(Handshake.class, Handshake::new, new StateRegistry.PacketMapping(0x00, MINECRAFT_1_12, false)); return registry; } @@ -44,8 +44,8 @@ class PacketRegistryTest { @Test void registrySuppliesCorrectPacketsByProtocol() { StateRegistry.PacketRegistry registry = new StateRegistry.PacketRegistry(ProtocolConstants.Direction.CLIENTBOUND, StateRegistry.HANDSHAKE); - registry.register(Handshake.class, Handshake::new, new StateRegistry.PacketMapping(0x00, MINECRAFT_1_12), - new StateRegistry.PacketMapping(0x01, MINECRAFT_1_12_1)); + registry.register(Handshake.class, Handshake::new, new StateRegistry.PacketMapping(0x00, MINECRAFT_1_12, false), + new StateRegistry.PacketMapping(0x01, MINECRAFT_1_12_1, false)); assertEquals(Handshake.class, registry.getVersion(MINECRAFT_1_12).createPacket(0x00).getClass()); assertEquals(Handshake.class, registry.getVersion(MINECRAFT_1_12_1).createPacket(0x01).getClass()); assertEquals(Handshake.class, registry.getVersion(MINECRAFT_1_12_2).createPacket(0x01).getClass()); diff --git a/proxy/src/test/java/com/velocitypowered/proxy/scheduler/VelocitySchedulerTest.java b/proxy/src/test/java/com/velocitypowered/proxy/scheduler/VelocitySchedulerTest.java new file mode 100644 index 000000000..d4d7a951a --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/scheduler/VelocitySchedulerTest.java @@ -0,0 +1,51 @@ +package com.velocitypowered.proxy.scheduler; + +import com.velocitypowered.api.scheduler.ScheduledTask; +import com.velocitypowered.api.scheduler.TaskStatus; +import com.velocitypowered.proxy.testutil.FakePluginManager; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class VelocitySchedulerTest { + // TODO: The timings here will be inaccurate on slow systems. Need to find a testing-friendly replacement for Thread.sleep() + + @Test + void buildTask() throws Exception { + VelocityScheduler scheduler = new VelocityScheduler(new FakePluginManager(), Sleeper.SYSTEM); + CountDownLatch latch = new CountDownLatch(1); + ScheduledTask task = scheduler.buildTask(FakePluginManager.PLUGIN_A, latch::countDown).schedule(); + latch.await(); + assertEquals(TaskStatus.FINISHED, task.status()); + } + + @Test + void cancelWorks() throws Exception { + VelocityScheduler scheduler = new VelocityScheduler(new FakePluginManager(), Sleeper.SYSTEM); + AtomicInteger i = new AtomicInteger(3); + ScheduledTask task = scheduler.buildTask(FakePluginManager.PLUGIN_A, i::decrementAndGet) + .delay(100, TimeUnit.SECONDS) + .schedule(); + task.cancel(); + Thread.sleep(200); + assertEquals(3, i.get()); + assertEquals(TaskStatus.CANCELLED, task.status()); + } + + @Test + void repeatTaskWorks() throws Exception { + VelocityScheduler scheduler = new VelocityScheduler(new FakePluginManager(), Sleeper.SYSTEM); + CountDownLatch latch = new CountDownLatch(3); + ScheduledTask task = scheduler.buildTask(FakePluginManager.PLUGIN_A, latch::countDown) + .delay(100, TimeUnit.MILLISECONDS) + .repeat(100, TimeUnit.MILLISECONDS) + .schedule(); + latch.await(); + task.cancel(); + } + +} \ No newline at end of file diff --git a/proxy/src/test/java/com/velocitypowered/proxy/testutil/FakePluginManager.java b/proxy/src/test/java/com/velocitypowered/proxy/testutil/FakePluginManager.java new file mode 100644 index 000000000..86fb450ea --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/testutil/FakePluginManager.java @@ -0,0 +1,77 @@ +package com.velocitypowered.proxy.testutil; + +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginManager; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Optional; + +public class FakePluginManager implements PluginManager { + public static final Object PLUGIN_A = new Object(); + public static final Object PLUGIN_B = new Object(); + + public static final PluginContainer PC_A = new FakePluginContainer("a", PLUGIN_A); + public static final PluginContainer PC_B = new FakePluginContainer("b", PLUGIN_B); + + @Override + public @NonNull Optional fromInstance(@NonNull Object instance) { + if (instance == PLUGIN_A) { + return Optional.of(PC_A); + } else if (instance == PLUGIN_B) { + return Optional.of(PC_B); + } else { + return Optional.empty(); + } + } + + @Override + public @NonNull Optional getPlugin(@NonNull String id) { + switch (id) { + case "a": + return Optional.of(PC_A); + case "b": + return Optional.of(PC_B); + default: + return Optional.empty(); + } + } + + @Override + public @NonNull Collection getPlugins() { + return ImmutableList.of(PC_A, PC_B); + } + + @Override + public boolean isLoaded(@NonNull String id) { + return id.equals("a") || id.equals("b"); + } + + @Override + public void addToClasspath(@NonNull Object plugin, @NonNull Path path) { + throw new UnsupportedOperationException(); + } + + private static class FakePluginContainer implements PluginContainer { + private final String id; + private final Object instance; + + private FakePluginContainer(String id, Object instance) { + this.id = id; + this.instance = instance; + } + + @Override + public @NonNull PluginDescription getDescription() { + return () -> id; + } + + @Override + public Optional getInstance() { + return Optional.of(instance); + } + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java new file mode 100644 index 000000000..b9f9ff204 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java @@ -0,0 +1,38 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.base.Ticker; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class RatelimiterTest { + + @Test + void attemptZero() { + Ratelimiter noRatelimiter = new Ratelimiter(0); + assertTrue(noRatelimiter.attempt(InetAddress.getLoopbackAddress())); + assertTrue(noRatelimiter.attempt(InetAddress.getLoopbackAddress())); + } + + @Test + void attemptOne() { + long base = System.nanoTime(); + AtomicLong extra = new AtomicLong(); + Ticker testTicker = new Ticker() { + @Override + public long read() { + return base + extra.get(); + } + }; + Ratelimiter ratelimiter = new Ratelimiter(1000, testTicker); + assertTrue(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + assertFalse(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + extra.addAndGet(TimeUnit.SECONDS.toNanos(2)); + assertTrue(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + } + +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java new file mode 100644 index 000000000..e332ed85f --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java @@ -0,0 +1,35 @@ +package com.velocitypowered.proxy.util; + +import com.velocitypowered.api.proxy.server.ServerInfo; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class ServerMapTest { + private static final InetSocketAddress TEST_ADDRESS = new InetSocketAddress(InetAddress.getLoopbackAddress(), 25565); + + @Test + void respectsCaseInsensitivity() { + ServerMap map = new ServerMap(); + ServerInfo info = new ServerInfo("TestServer", TEST_ADDRESS); + map.register(info); + + assertEquals(Optional.of(info), map.getServer("TestServer")); + assertEquals(Optional.of(info), map.getServer("testserver")); + assertEquals(Optional.of(info), map.getServer("TESTSERVER")); + } + + @Test + void rejectsRepeatedRegisterAttempts() { + ServerMap map = new ServerMap(); + ServerInfo info = new ServerInfo("TestServer", TEST_ADDRESS); + map.register(info); + + ServerInfo willReject = new ServerInfo("TESTSERVER", TEST_ADDRESS); + assertThrows(IllegalArgumentException.class, () -> map.register(willReject)); + } +} \ No newline at end of file diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/UuidUtilsTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/UuidUtilsTest.java index 94f0b90cb..36cef2a1e 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/util/UuidUtilsTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/UuidUtilsTest.java @@ -1,5 +1,6 @@ package com.velocitypowered.proxy.util; +import com.velocitypowered.api.util.UuidUtils; import org.junit.jupiter.api.Test; import java.util.UUID; diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java new file mode 100644 index 000000000..cac1ef295 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java @@ -0,0 +1,36 @@ +package com.velocitypowered.proxy.util.concurrency; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; + +class RecordingThreadFactoryTest { + + @Test + void newThread() throws Exception { + RecordingThreadFactory factory = new RecordingThreadFactory(Executors.defaultThreadFactory()); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch endThread = new CountDownLatch(1); + factory.newThread(() -> { + started.countDown(); + assertTrue(factory.currentlyInFactory()); + assertEquals(1, factory.size()); + try { + endThread.await(); + } catch (InterruptedException e) { + fail(e); + } + }).start(); + started.await(); + assertFalse(factory.currentlyInFactory()); + assertEquals(1, factory.size()); + endThread.countDown(); + + // Wait a little bit to ensure the thread got shut down + Thread.sleep(10); + assertEquals(0, factory.size()); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index e3e315775..044dc9fb2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,4 +6,6 @@ include ( ) findProject(':api')?.name = 'velocity-api' findProject(':proxy')?.name = 'velocity-proxy' -findProject(':native')?.name = 'velocity-native' \ No newline at end of file +findProject(':native')?.name = 'velocity-native' + +enableFeaturePreview('STABLE_PUBLISHING') \ No newline at end of file