chore(history): squash 59 commit(s) from 2026-09-23
- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2) - refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2) - refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2) - refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2) - refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2) - refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2) - refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2) - refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2) - refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2) - refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2) - refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2) - refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2) - refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2) - refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2) - refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2) - refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2) - refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2) - refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2) - refactor(hud): Potions 617→176 + 4×≤166 (8.5.2) - refactor(hud): Admins 702→118 + 4×≤138 (8.5.2) - refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2) - refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2) - refactor(hud): Radar 392→161 + 2×≤124 (8.5.2) - refactor(hud): Fps 309→168 + 3×≤107 (8.5.2) - refactor(hud): Memory 304→151 + 3×≤144 (8.5.2) - refactor(hud): Tps 339→154 + 3×≤143 (8.5.2) - refactor(hud): Ping 309→145 + 3×≤125 (8.5.2) - refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2) - refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2) - refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2) - refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2) - refactor(hud): Armor 324→162 + 2×≤148 (8.5.2) - refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2) - docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост) - docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе) - refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2) - refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2) - refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2) - docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО - refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2) - docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата - docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка - docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3 - chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/ - docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1) - chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md - docs(backend): edition 2024 в плане вместо 2021 - chore: обновить версии до реально актуальных (проверено компиляцией) - docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md - docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend) - docs: commit messages in English from now on - chore: gitignore .superpowers/ scratch directory - docs: commit messages in English from now on (mod) - feat(backend): scaffold accounts-service with health check - chore(backend): remove target/ build artifacts from git, add gitignore - chore: track docs/ in git (was accidentally gitignored, never committed) - chore(backend): convert to Cargo workspace, plan full microservice layout - feat(backend): accounts/avatars/device_links schema + migration - feat(backend): argon2id password hashing
This commit is contained in:
parent
ec10395511
commit
9d08fa910a
2239 changed files with 35265 additions and 28110 deletions
118
mod/src/main/java/dev/loki/lovisual/util/proxy/ProxyBackend.java
Normal file
118
mod/src/main/java/dev/loki/lovisual/util/proxy/ProxyBackend.java
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy;
|
||||
import dev.loki.lovisual.util.proxy.model.ProxyConfig;
|
||||
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
|
||||
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.client.Minecraft;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public enum ProxyBackend {
|
||||
;
|
||||
|
||||
public static final String DEFAULT_ACCOUNT_KEY = "";
|
||||
|
||||
private static final ProxyConfig CONFIG = new ProxyConfig();
|
||||
private static ProxyEntry lastUsedProxy = ProxyEntry.empty();
|
||||
|
||||
public static void init() {
|
||||
if (!CONFIG.isLoaded()) {
|
||||
CONFIG.load();
|
||||
}
|
||||
}
|
||||
|
||||
public static ProxyConfig config() {
|
||||
init();
|
||||
return CONFIG;
|
||||
}
|
||||
|
||||
public static boolean isEnabled() {
|
||||
init();
|
||||
return CONFIG.proxyEnabled();
|
||||
}
|
||||
|
||||
public static void setEnabled(boolean enabled) {
|
||||
init();
|
||||
CONFIG.proxyEnabled(enabled);
|
||||
CONFIG.save();
|
||||
}
|
||||
|
||||
public static ProxyEntry getDefaultProxy() {
|
||||
init();
|
||||
return CONFIG.defaultProxy();
|
||||
}
|
||||
|
||||
public static void setDefaultProxy(ProxyEntry proxy) {
|
||||
init();
|
||||
CONFIG.defaultProxy(proxy);
|
||||
CONFIG.save();
|
||||
}
|
||||
|
||||
public static ProxyEntry getAccountProxy(String accountName) {
|
||||
init();
|
||||
return CONFIG.proxyForAccount(accountName);
|
||||
}
|
||||
|
||||
public static void setAccountProxy(String accountName, ProxyEntry proxy) {
|
||||
init();
|
||||
CONFIG.proxyForAccount(accountName, proxy);
|
||||
CONFIG.save();
|
||||
}
|
||||
|
||||
public static void removeAccountProxy(String accountName) {
|
||||
init();
|
||||
CONFIG.removeAccountProxy(accountName);
|
||||
CONFIG.save();
|
||||
}
|
||||
|
||||
public static Map<String, ProxyEntry> accountProxies() {
|
||||
init();
|
||||
return CONFIG.accountsSnapshot();
|
||||
}
|
||||
|
||||
public static ProxyEntry activeProxy() {
|
||||
init();
|
||||
if (!CONFIG.proxyEnabled()) return ProxyEntry.empty();
|
||||
String name = currentProfileName();
|
||||
ProxyEntry proxy = CONFIG.proxyForAccount(name);
|
||||
return proxy.isConfigured() ? proxy : ProxyEntry.empty();
|
||||
}
|
||||
|
||||
public static ProxyEntry lastUsedProxy() {
|
||||
return lastUsedProxy == null ? ProxyEntry.empty() : lastUsedProxy.copy();
|
||||
}
|
||||
|
||||
static void markLastUsed(ProxyEntry proxy) {
|
||||
lastUsedProxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
|
||||
}
|
||||
|
||||
public static String lastUsedProxyIp() {
|
||||
ProxyEntry proxy = lastUsedProxy();
|
||||
return proxy.isConfigured() ? proxy.host() : "none";
|
||||
}
|
||||
|
||||
private static String currentProfileName() {
|
||||
Minecraft minecraft = Minecraft.getInstance();
|
||||
if (minecraft == null) return "";
|
||||
try {
|
||||
GameProfile profile = minecraft.getGameProfile();
|
||||
if (profile != null && profile.name() != null && !profile.name().isBlank()) {
|
||||
return profile.name();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
return minecraft.getUser().getName();
|
||||
} catch (Throwable ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy;
|
||||
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
|
||||
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelPromise;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class ProxyHandshakeHandler extends ChannelDuplexHandler {
|
||||
|
||||
private final ProxyEntry proxy;
|
||||
|
||||
private ChannelPromise originalConnectPromise;
|
||||
private InetSocketAddress targetAddress;
|
||||
private ByteBuf inbound;
|
||||
private State state = State.INIT;
|
||||
|
||||
ProxyHandshakeHandler(ProxyEntry proxy) {
|
||||
this.proxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) {
|
||||
if (!proxy.isConfigured() || !(remoteAddress instanceof InetSocketAddress target)) {
|
||||
ctx.connect(remoteAddress, localAddress, promise);
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetAddress = target;
|
||||
this.originalConnectPromise = promise;
|
||||
|
||||
ChannelPromise proxyConnectPromise = ctx.newPromise();
|
||||
proxyConnectPromise.addListener(future -> {
|
||||
if (!future.isSuccess()) {
|
||||
fail(ctx, future.cause() != null ? future.cause() : new IllegalStateException("Proxy connect failed"));
|
||||
return;
|
||||
}
|
||||
startHandshake(ctx);
|
||||
});
|
||||
|
||||
ctx.connect(proxy.socketAddress(), localAddress, proxyConnectPromise);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||
if (state == State.DONE || state == State.INIT) {
|
||||
ctx.fireChannelRead(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(msg instanceof ByteBuf buf)) {
|
||||
ReferenceCountUtil.release(msg);
|
||||
fail(ctx, new IllegalStateException("Unexpected proxy handshake payload: " + msg));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (inbound == null) {
|
||||
inbound = ctx.alloc().buffer(buf.readableBytes());
|
||||
}
|
||||
inbound.writeBytes(buf);
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
|
||||
processInbound(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
|
||||
if (state != State.DONE && originalConnectPromise != null && !originalConnectPromise.isDone()) {
|
||||
originalConnectPromise.tryFailure(new IllegalStateException("Proxy connection closed during handshake"));
|
||||
}
|
||||
releaseInbound();
|
||||
super.channelInactive(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
|
||||
if (state != State.DONE && originalConnectPromise != null && !originalConnectPromise.isDone()) {
|
||||
fail(ctx, cause);
|
||||
return;
|
||||
}
|
||||
super.exceptionCaught(ctx, cause);
|
||||
}
|
||||
|
||||
private void startHandshake(ChannelHandlerContext ctx) {
|
||||
try {
|
||||
switch (proxy.type()) {
|
||||
case SOCKS4 -> {
|
||||
state = State.SOCKS4_RESPONSE;
|
||||
ctx.writeAndFlush(createSocks4ConnectRequest(ctx));
|
||||
}
|
||||
case SOCKS5 -> {
|
||||
state = State.SOCKS5_METHOD;
|
||||
ctx.writeAndFlush(createSocks5Greeting(ctx));
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
fail(ctx, t);
|
||||
}
|
||||
}
|
||||
|
||||
private void processInbound(ChannelHandlerContext ctx) {
|
||||
try {
|
||||
boolean again;
|
||||
do {
|
||||
again = false;
|
||||
switch (state) {
|
||||
case SOCKS4_RESPONSE -> {
|
||||
if (inbound.readableBytes() < 8) return;
|
||||
inbound.readByte();
|
||||
int status = inbound.readUnsignedByte();
|
||||
inbound.skipBytes(6);
|
||||
if (status != 90) {
|
||||
throw new IllegalStateException("SOCKS4 proxy rejected connect request: " + status);
|
||||
}
|
||||
finish(ctx);
|
||||
}
|
||||
case SOCKS5_METHOD -> {
|
||||
if (inbound.readableBytes() < 2) return;
|
||||
int version = inbound.readUnsignedByte();
|
||||
int method = inbound.readUnsignedByte();
|
||||
if (version != 5) {
|
||||
throw new IllegalStateException("Invalid SOCKS5 method response version: " + version);
|
||||
}
|
||||
if (method == 0xFF) {
|
||||
throw new IllegalStateException("SOCKS5 proxy rejected all authentication methods");
|
||||
}
|
||||
if (method == 0x02) {
|
||||
state = State.SOCKS5_AUTH;
|
||||
ctx.writeAndFlush(createSocks5AuthRequest(ctx));
|
||||
} else if (method == 0x00) {
|
||||
state = State.SOCKS5_CONNECT;
|
||||
ctx.writeAndFlush(createSocks5ConnectRequest(ctx));
|
||||
} else {
|
||||
throw new IllegalStateException("Unsupported SOCKS5 auth method: " + method);
|
||||
}
|
||||
again = inbound.isReadable();
|
||||
}
|
||||
case SOCKS5_AUTH -> {
|
||||
if (inbound.readableBytes() < 2) return;
|
||||
int version = inbound.readUnsignedByte();
|
||||
int status = inbound.readUnsignedByte();
|
||||
if (version != 1 || status != 0) {
|
||||
throw new IllegalStateException("SOCKS5 authentication failed: " + status);
|
||||
}
|
||||
state = State.SOCKS5_CONNECT;
|
||||
ctx.writeAndFlush(createSocks5ConnectRequest(ctx));
|
||||
again = inbound.isReadable();
|
||||
}
|
||||
case SOCKS5_CONNECT -> {
|
||||
if (!tryReadSocks5ConnectResponse()) return;
|
||||
finish(ctx);
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
} while (again && state != State.DONE);
|
||||
} catch (Throwable t) {
|
||||
fail(ctx, t);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryReadSocks5ConnectResponse() {
|
||||
if (inbound.readableBytes() < 5) return false;
|
||||
|
||||
inbound.markReaderIndex();
|
||||
int version = inbound.readUnsignedByte();
|
||||
int status = inbound.readUnsignedByte();
|
||||
inbound.readUnsignedByte();
|
||||
int addressType = inbound.readUnsignedByte();
|
||||
|
||||
if (version != 5) {
|
||||
throw new IllegalStateException("Invalid SOCKS5 connect response version: " + version);
|
||||
}
|
||||
|
||||
int addressBytes;
|
||||
switch (addressType) {
|
||||
case 1 -> addressBytes = 4;
|
||||
case 4 -> addressBytes = 16;
|
||||
case 3 -> {
|
||||
if (inbound.readableBytes() < 1) {
|
||||
inbound.resetReaderIndex();
|
||||
return false;
|
||||
}
|
||||
addressBytes = inbound.readUnsignedByte();
|
||||
}
|
||||
default -> throw new IllegalStateException("Invalid SOCKS5 address type: " + addressType);
|
||||
}
|
||||
|
||||
if (inbound.readableBytes() < addressBytes + 2) {
|
||||
inbound.resetReaderIndex();
|
||||
return false;
|
||||
}
|
||||
|
||||
inbound.skipBytes(addressBytes + 2);
|
||||
if (status != 0) {
|
||||
throw new IllegalStateException("SOCKS5 proxy rejected connect request: " + status);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private ByteBuf createSocks5Greeting(ChannelHandlerContext ctx) {
|
||||
ByteBuf out = ctx.alloc().buffer(3);
|
||||
out.writeByte(5);
|
||||
out.writeByte(1);
|
||||
out.writeByte(proxy.hasCredentials() ? 0x02 : 0x00);
|
||||
return out;
|
||||
}
|
||||
|
||||
private ByteBuf createSocks5AuthRequest(ChannelHandlerContext ctx) {
|
||||
byte[] username = ascii(proxy.username());
|
||||
byte[] password = ascii(proxy.password());
|
||||
if (username.length > 255 || password.length > 255) {
|
||||
throw new IllegalArgumentException("SOCKS5 username/password is too long");
|
||||
}
|
||||
|
||||
ByteBuf out = ctx.alloc().buffer(3 + username.length + password.length);
|
||||
out.writeByte(1);
|
||||
out.writeByte(username.length);
|
||||
out.writeBytes(username);
|
||||
out.writeByte(password.length);
|
||||
out.writeBytes(password);
|
||||
return out;
|
||||
}
|
||||
|
||||
private ByteBuf createSocks5ConnectRequest(ChannelHandlerContext ctx) {
|
||||
ByteBuf out = ctx.alloc().buffer(32);
|
||||
out.writeByte(5);
|
||||
out.writeByte(1);
|
||||
out.writeByte(0);
|
||||
writeSocks5Address(out, targetAddress);
|
||||
out.writeShort(targetAddress.getPort());
|
||||
return out;
|
||||
}
|
||||
|
||||
private ByteBuf createSocks4ConnectRequest(ChannelHandlerContext ctx) {
|
||||
ByteBuf out = ctx.alloc().buffer(64);
|
||||
out.writeByte(4);
|
||||
out.writeByte(1);
|
||||
out.writeShort(targetAddress.getPort());
|
||||
|
||||
InetAddress address = targetAddress.getAddress();
|
||||
boolean ipv4 = address instanceof Inet4Address;
|
||||
if (ipv4) {
|
||||
out.writeBytes(address.getAddress());
|
||||
} else {
|
||||
out.writeByte(0);
|
||||
out.writeByte(0);
|
||||
out.writeByte(0);
|
||||
out.writeByte(1);
|
||||
}
|
||||
|
||||
if (proxy.hasUsername()) {
|
||||
out.writeBytes(ascii(proxy.username()));
|
||||
}
|
||||
out.writeByte(0);
|
||||
|
||||
if (!ipv4) {
|
||||
out.writeBytes(ascii(targetAddress.getHostString()));
|
||||
out.writeByte(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void writeSocks5Address(ByteBuf out, InetSocketAddress address) {
|
||||
InetAddress inetAddress = address.getAddress();
|
||||
if (inetAddress instanceof Inet4Address) {
|
||||
out.writeByte(1);
|
||||
out.writeBytes(inetAddress.getAddress());
|
||||
return;
|
||||
}
|
||||
if (inetAddress instanceof Inet6Address) {
|
||||
out.writeByte(4);
|
||||
out.writeBytes(inetAddress.getAddress());
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] host = ascii(address.getHostString());
|
||||
if (host.length > 255) {
|
||||
throw new IllegalArgumentException("SOCKS5 host is too long: " + address.getHostString());
|
||||
}
|
||||
out.writeByte(3);
|
||||
out.writeByte(host.length);
|
||||
out.writeBytes(host);
|
||||
}
|
||||
|
||||
private void finish(ChannelHandlerContext ctx) {
|
||||
state = State.DONE;
|
||||
|
||||
ByteBuf leftover = null;
|
||||
if (inbound != null && inbound.isReadable()) {
|
||||
leftover = inbound.readRetainedSlice(inbound.readableBytes());
|
||||
}
|
||||
releaseInbound();
|
||||
|
||||
if (ctx.pipeline().get(this.getClass()) != null) {
|
||||
ctx.pipeline().remove(this);
|
||||
} else {
|
||||
try {
|
||||
ctx.pipeline().remove(this);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (originalConnectPromise != null) {
|
||||
originalConnectPromise.trySuccess();
|
||||
}
|
||||
if (leftover != null) {
|
||||
ctx.fireChannelRead(leftover);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(ChannelHandlerContext ctx, Throwable throwable) {
|
||||
releaseInbound();
|
||||
if (originalConnectPromise != null) {
|
||||
originalConnectPromise.tryFailure(throwable);
|
||||
}
|
||||
ctx.close();
|
||||
}
|
||||
|
||||
private void releaseInbound() {
|
||||
if (inbound != null) {
|
||||
inbound.release();
|
||||
inbound = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ascii(String value) {
|
||||
return (value == null ? "" : value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private enum State {
|
||||
INIT,
|
||||
SOCKS4_RESPONSE,
|
||||
SOCKS5_METHOD,
|
||||
SOCKS5_AUTH,
|
||||
SOCKS5_CONNECT,
|
||||
DONE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy;
|
||||
import dev.loki.lovisual.util.proxy.model.ProxyEntry;
|
||||
|
||||
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
|
||||
public enum ProxyNettyInstaller {
|
||||
;
|
||||
|
||||
private static final String HANDLER_NAME = "lovisual_proxy";
|
||||
|
||||
public static void install(ChannelPipeline pipeline) {
|
||||
if (pipeline == null) return;
|
||||
|
||||
ProxyEntry proxy = ProxyBackend.activeProxy();
|
||||
if (!proxy.isConfigured()) return;
|
||||
|
||||
try {
|
||||
if (pipeline.get(HANDLER_NAME) != null) {
|
||||
pipeline.remove(HANDLER_NAME);
|
||||
}
|
||||
|
||||
pipeline.addFirst(HANDLER_NAME, new ProxyHandshakeHandler(proxy));
|
||||
ProxyBackend.markLastUsed(proxy);
|
||||
DebugLog.config("Proxy enabled for connection: %s", proxy);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.warn("Failed to install proxy handler: %s", proxy);
|
||||
DebugLog.error("Proxy handler install error", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy.model;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.loki.lovisual.config.io.ConfigPaths;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* File-backed proxy backend config.
|
||||
*/
|
||||
public final class ProxyConfig {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
private static final Type ACCOUNTS_TYPE = new TypeToken<LinkedHashMap<String, ProxyEntry>>() {
|
||||
}
|
||||
.getType();
|
||||
|
||||
private final Path file;
|
||||
private boolean loaded;
|
||||
private boolean enabled;
|
||||
private ProxyEntry defaultProxy = ProxyEntry.empty();
|
||||
private final LinkedHashMap<String, ProxyEntry> accounts = new LinkedHashMap<>();
|
||||
|
||||
public ProxyConfig() {
|
||||
this(ConfigPaths.root().resolve("proxy").resolve("proxy.json"));
|
||||
}
|
||||
|
||||
public ProxyConfig(Path file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public synchronized void load() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
if (!Files.exists(file)) {
|
||||
loaded = true;
|
||||
save();
|
||||
return;
|
||||
}
|
||||
|
||||
String text = Files.readString(file, StandardCharsets.UTF_8);
|
||||
if (text == null || text.isBlank()) {
|
||||
loaded = true;
|
||||
save();
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject json = JsonParser.parseString(text).getAsJsonObject();
|
||||
if (json.has("enabled")) {
|
||||
enabled = json.get("enabled").getAsBoolean();
|
||||
}
|
||||
|
||||
if (json.has("default") && json.get("default").isJsonObject()) {
|
||||
ProxyEntry parsed = GSON.fromJson(json.get("default"), ProxyEntry.class);
|
||||
defaultProxy = parsed == null ? ProxyEntry.empty() : parsed;
|
||||
}
|
||||
|
||||
accounts.clear();
|
||||
if (json.has("accounts") && json.get("accounts").isJsonObject()) {
|
||||
LinkedHashMap<String, ProxyEntry> loadedAccounts = GSON.fromJson(json.get("accounts"), ACCOUNTS_TYPE);
|
||||
if (loadedAccounts != null) {
|
||||
accounts.putAll(loadedAccounts);
|
||||
}
|
||||
}
|
||||
normalize();
|
||||
loaded = true;
|
||||
} catch (Exception e) {
|
||||
loaded = true;
|
||||
DebugLog.warn("Failed to load proxy config: %s", file.toAbsolutePath());
|
||||
DebugLog.error("Proxy config load error", e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void save() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
normalize();
|
||||
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("enabled", enabled);
|
||||
json.add("default", GSON.toJsonTree(defaultProxy, ProxyEntry.class));
|
||||
JsonElement accountsJson = GSON.toJsonTree(accounts, ACCOUNTS_TYPE);
|
||||
json.add("accounts", accountsJson);
|
||||
|
||||
Files.writeString(file, GSON.toJson(json), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
DebugLog.warn("Failed to save proxy config: %s", file.toAbsolutePath());
|
||||
DebugLog.error("Proxy config save error", e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public synchronized boolean proxyEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public synchronized void proxyEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public synchronized ProxyEntry defaultProxy() {
|
||||
return defaultProxy == null ? ProxyEntry.empty() : defaultProxy.copy();
|
||||
}
|
||||
|
||||
public synchronized void defaultProxy(ProxyEntry proxy) {
|
||||
defaultProxy = proxy == null ? ProxyEntry.empty() : proxy.copy();
|
||||
normalize();
|
||||
}
|
||||
|
||||
public synchronized ProxyEntry proxyForAccount(String accountName) {
|
||||
String key = accountKey(accountName);
|
||||
if (!key.isEmpty() && accounts.containsKey(key)) {
|
||||
ProxyEntry accountProxy = accounts.get(key);
|
||||
return accountProxy == null ? ProxyEntry.empty() : accountProxy.copy();
|
||||
}
|
||||
return defaultProxy();
|
||||
}
|
||||
|
||||
public synchronized void proxyForAccount(String accountName, ProxyEntry proxy) {
|
||||
String key = accountKey(accountName);
|
||||
if (key.isEmpty()) {
|
||||
defaultProxy(proxy);
|
||||
return;
|
||||
}
|
||||
accounts.put(key, proxy == null ? ProxyEntry.empty() : proxy.copy());
|
||||
normalize();
|
||||
}
|
||||
|
||||
public synchronized void removeAccountProxy(String accountName) {
|
||||
String key = accountKey(accountName);
|
||||
if (key.isEmpty()) {
|
||||
defaultProxy = ProxyEntry.empty();
|
||||
} else {
|
||||
accounts.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Map<String, ProxyEntry> accountsSnapshot() {
|
||||
LinkedHashMap<String, ProxyEntry> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, ProxyEntry> entry : accounts.entrySet()) {
|
||||
out.put(entry.getKey(), entry.getValue() == null ? ProxyEntry.empty() : entry.getValue().copy());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public synchronized Path file() {
|
||||
return file;
|
||||
}
|
||||
|
||||
private void normalize() {
|
||||
if (defaultProxy == null) {
|
||||
defaultProxy = ProxyEntry.empty();
|
||||
}
|
||||
defaultProxy.normalize();
|
||||
accounts.replaceAll((ignored, proxy) -> {
|
||||
ProxyEntry safe = proxy == null ? ProxyEntry.empty() : proxy;
|
||||
safe.normalize();
|
||||
return safe;
|
||||
});
|
||||
}
|
||||
|
||||
private static String accountKey(String accountName) {
|
||||
return accountName == null ? "" : accountName.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy.model;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Backend proxy data model.
|
||||
*/
|
||||
public final class ProxyEntry {
|
||||
|
||||
private String address = "";
|
||||
private ProxyType type = ProxyType.SOCKS5;
|
||||
private String username = "";
|
||||
private String password = "";
|
||||
|
||||
public ProxyEntry() {
|
||||
}
|
||||
|
||||
public ProxyEntry(ProxyType type, String ipPort, String username, String password) {
|
||||
this.type = ProxyType.safe(type);
|
||||
this.address = clean(ipPort);
|
||||
this.username = clean(username);
|
||||
this.password = clean(password);
|
||||
}
|
||||
|
||||
public ProxyEntry(boolean socks4, String ipPort, String username, String password) {
|
||||
this(socks4 ? ProxyType.SOCKS4 : ProxyType.SOCKS5, ipPort, username, password);
|
||||
}
|
||||
|
||||
public static ProxyEntry socks5(String host, int port) {
|
||||
return new ProxyEntry(ProxyType.SOCKS5, join(host, port), "", "");
|
||||
}
|
||||
|
||||
public static ProxyEntry socks5(String host, int port, String username, String password) {
|
||||
return new ProxyEntry(ProxyType.SOCKS5, join(host, port), username, password);
|
||||
}
|
||||
|
||||
public static ProxyEntry socks4(String host, int port) {
|
||||
return new ProxyEntry(ProxyType.SOCKS4, join(host, port), "", "");
|
||||
}
|
||||
|
||||
public static ProxyEntry socks4(String host, int port, String username) {
|
||||
return new ProxyEntry(ProxyType.SOCKS4, join(host, port), username, "");
|
||||
}
|
||||
|
||||
public static ProxyEntry empty() {
|
||||
return new ProxyEntry();
|
||||
}
|
||||
|
||||
public ProxyEntry copy() {
|
||||
return new ProxyEntry(type, address, username, password);
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return isValidIpPort(address);
|
||||
}
|
||||
|
||||
public String ipPort() {
|
||||
return address == null ? "" : address;
|
||||
}
|
||||
|
||||
public void ipPort(String ipPort) {
|
||||
this.address = clean(ipPort);
|
||||
}
|
||||
|
||||
public ProxyType type() {
|
||||
return ProxyType.safe(type);
|
||||
}
|
||||
|
||||
public void type(ProxyType type) {
|
||||
this.type = ProxyType.safe(type);
|
||||
}
|
||||
|
||||
public String username() {
|
||||
return username == null ? "" : username;
|
||||
}
|
||||
|
||||
public void username(String username) {
|
||||
this.username = clean(username);
|
||||
}
|
||||
|
||||
public String password() {
|
||||
return password == null ? "" : password;
|
||||
}
|
||||
|
||||
public void password(String password) {
|
||||
this.password = clean(password);
|
||||
}
|
||||
|
||||
public boolean hasUsername() {
|
||||
return !username().isBlank();
|
||||
}
|
||||
|
||||
public boolean hasPassword() {
|
||||
return !password().isBlank();
|
||||
}
|
||||
|
||||
public boolean hasCredentials() {
|
||||
return hasUsername() || hasPassword();
|
||||
}
|
||||
|
||||
public String host() {
|
||||
if (address == null || address.isBlank()) return "";
|
||||
int separator = findPortSeparator(address);
|
||||
if (separator < 0) return "";
|
||||
String host = address.substring(0, separator).trim();
|
||||
if (host.startsWith("[") && host.endsWith("]")) {
|
||||
host = host.substring(1, host.length() - 1);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
public int port() {
|
||||
if (address == null || address.isBlank()) return 0;
|
||||
int separator = findPortSeparator(address);
|
||||
if (separator < 0 || separator + 1 >= address.length()) return 0;
|
||||
try {
|
||||
int port = Integer.parseInt(address.substring(separator + 1).trim());
|
||||
return port >= 0 && port <= 0xFFFF ? port : 0;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public InetSocketAddress socketAddress() {
|
||||
String host = host();
|
||||
int port = port();
|
||||
if (host.isBlank() || port <= 0) {
|
||||
throw new IllegalStateException("Proxy entry is not configured: " + ipPort());
|
||||
}
|
||||
return new InetSocketAddress(host, port);
|
||||
}
|
||||
|
||||
public static boolean isValidIpPort(String ipPort) {
|
||||
if (ipPort == null || ipPort.isBlank()) return false;
|
||||
int separator = findPortSeparator(ipPort.trim());
|
||||
if (separator <= 0 || separator + 1 >= ipPort.trim().length()) return false;
|
||||
String host = ipPort.trim().substring(0, separator).trim();
|
||||
if (host.isBlank()) return false;
|
||||
try {
|
||||
int port = Integer.parseInt(ipPort.trim().substring(separator + 1).trim());
|
||||
return port >= 1 && port <= 0xFFFF;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int findPortSeparator(String value) {
|
||||
if (value == null) return -1;
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
int endBracket = trimmed.indexOf(']');
|
||||
if (endBracket < 0) return -1;
|
||||
return endBracket + 1 < trimmed.length() && trimmed.charAt(endBracket + 1) == ':' ? endBracket + 1 : -1;
|
||||
}
|
||||
return trimmed.lastIndexOf(':');
|
||||
}
|
||||
|
||||
private static String join(String host, int port) {
|
||||
String cleanHost = clean(host);
|
||||
if (cleanHost.contains(":") && !cleanHost.startsWith("[")) {
|
||||
cleanHost = '[' + cleanHost + ']';
|
||||
}
|
||||
return cleanHost + ':' + port;
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
public void normalize() {
|
||||
address = clean(address);
|
||||
type = ProxyType.safe(type);
|
||||
username = clean(username);
|
||||
password = clean(password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return type().name().toLowerCase(Locale.ROOT) + "://" + ipPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof ProxyEntry that)) return false;
|
||||
return Objects.equals(ipPort(), that.ipPort())
|
||||
&& type() == that.type()
|
||||
&& Objects.equals(username(), that.username())
|
||||
&& Objects.equals(password(), that.password());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(ipPort(), type(), username(), password());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.proxy.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public enum ProxyType {
|
||||
@SerializedName("SOCKS4")
|
||||
SOCKS4,
|
||||
|
||||
@SerializedName("SOCKS5")
|
||||
SOCKS5;
|
||||
|
||||
public static ProxyType safe(ProxyType type) {
|
||||
return type == null ? SOCKS5 : type;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue