A lightweight, dependency-free Java wrapper that lets one Minecraft server accept players from both direct connections (your own domain/IP) and the Minehut proxy at the same time. It is a drop-in replacement for your server jar: instead of running the server directly, it launches the real server on a backend port and fronts it with a dual proxy.
It is a minimal Java port of mc-dual-proxy, built on plain
java.base-only APIs (blocking sockets on virtual threads, HttpURLConnection)
so it stays small, fast, and runs even on trimmed JREs that omit extra modules.
This is the proxy running in production on the steedsgate.quest MC server.
Connecting an external server to Minehut forces two incompatible choices:
- PROXY protocol — Minehut prepends HAProxy PROXY protocol headers; direct players don't. The backend can't have it both on and off.
- Session server — Minehut authenticates
hasJoinedagainst its own MITM session server; direct players use Mojang's. The backend can only point at one.
The wrapper runs three things in one process:
players ───────────────→ [tcp proxy :25565] ──→ [real server :25566]
(direct + minehut) normalizes proxy-protocol: true
PROXY protocol session.host → :8652
│
real server hasJoined ──→ [multiauth :8652] ──→ Mojang + Minehut (first 200 wins)
- TCP proxy (
listen): direct connections get a generated PROXY protocol v2 header; Minehut connections have theirs forwarded verbatim. The backend always sees a header, so it can keepproxy-protocol: truefor everyone. - Backend server (
backend): launched as a child process with the Mojang API host overrides injected, andsession.hostpointed at the local multiauth port. - Multiauth HTTP server (
auth_listen): fans eachhasJoinedout to Mojang and Minehut concurrently and returns the first HTTP 200. TheserverIdhash is cryptographically unique per connection, so exactly one upstream ever matches.
-
Build:
./build.sh→ producesproxy.jar. -
Put
proxy.jar, your real server jar, andstartup.iniin the server folder. -
Rename your server jar to
real-server.jar(or set[server] jarinstartup.ini). -
Configure the backend for the proxy (see below).
-
Run it:
java -jar proxy.jar --nogui
The wrapper caps its own heap at
[proxy] heap(128M) automatically, so the memory instartup.inigoes to the backend — no-Xmxneeded on the launch command (pass one anyway and it's respected as-is).
Any arguments after the jar are passed through to the real server. --port is
injected automatically (from [proxy] backend) unless you pass your own.
[proxy]
heap = 128M ; cap the wrapper's OWN heap (re-exec); "off" disables
listen = 0.0.0.0:25565 ; public address players connect to
backend = 127.0.0.1:25566 ; the real server this wrapper launches
auth_listen = 127.0.0.1:8652 ; local multiauth server (localhost only)
session_servers = https://sessionserver.mojang.com,https://api.minehut.com/mitm/proxy
[api]
enabled = true ; serve GET /source (Minehut vs Mojang) on the auth server
[web]
enabled = true ; landing page + live status
name = SteedsGate
address = steedsgate.quest ; shown on the page / Copy IP button
listen = 0.0.0.0:8080
[server]
jar = real-server.jar
min_memory = 8G ; backend heap (applied as -Xms/-Xmx)
max_memory = 8G
[restart]
autorestart = true ; restart the backend when it crashes
restart_on_stop = false ; /stop stops the wrapper; true = always-up (only a signal stops it)
restart_delay = 5 ; seconds to wait before each restart
restart_max = 5 ; max restarts per window, 0 = unlimited
restart_window = 60 ; window the limit applies to
[startup_flags]
; backend JVM flags, one per line. The Mojang API host overrides go here —
; session.host must match auth_listen so the backend authenticates via multiauth.
-Dminecraft.api.auth.host=https://authserver.mojang.com/
-Dminecraft.api.account.host=https://api.mojang.com/
-Dminecraft.api.services.host=https://api.minecraftservices.com/
-Dminecraft.api.profiles.host=https://api.mojang.com/
-Dminecraft.api.session.host=http://127.0.0.1:8652
;-XX:+UseG1GC ; plus any GC/perf flagsEvery value is optional; omitted keys fall back to the defaults above. The
wrapper's own heap is capped at heap (128M) via a one-time self re-exec, unless
you pass an explicit -Xmx on the launch command (which is respected as-is).
When autorestart is on (the default), the wrapper relaunches the backend after
it crashes (non-zero exit), waiting restart_delay seconds each time. A clean
/stop (exit 0) instead exits the wrapper, so the panel's Stop button — which
usually just sends /stop to the console — actually stops the server; the panel's
Restart (stop→start) works too. Set restart_on_stop = true to relaunch even on a
clean stop (always-up; then only a kill signal stops it).
If the backend restarts more than restart_max times within restart_window
seconds the wrapper gives up and exits — this stops a crash-looping server from
hammering the box (restart_max = 0 for unlimited). A signal to the wrapper
itself (Ctrl-C / SIGTERM) always shuts everything down without a restart.
These are server-side settings the wrapper can't set via flags:
config/paper-global.yml:
proxies:
proxy-protocol: trueserver.properties:
enforce-secure-profile=falseThe Mojang API host overrides live in [startup_flags] (Paper ignores them unless
all of auth.host, account.host, services.host, profiles.host are set).
Keep those four pointed at Mojang and point session.host at the local multiauth
server — i.e. it must match auth_listen (default http://127.0.0.1:8652).
- Point your external server at your public IP on the
listenport (25565). - DNS record type:
Port. TCP Shield:Not Configured. - Proxy type:
Otherfor standalone Paper.
Only the listen port (25565) must be open externally. The backend (25566) and
auth_listen (8652) ports only need to be reachable from localhost.
While the backend is down (restarting or still booting), the proxy:
- answers server-list pings with a custom MOTD (
starting_motd, with&colors and\nfor a second line — hand-pad it with spaces to taste), and - holds joining players at "Connecting…" — it keeps retrying the backend for
connect_waitseconds and splices them straight through once it's up, instead of kicking them.
The protocol is only parsed on this slow path; a healthy backend is spliced raw.
With [web] enabled = true, the proxy serves a small landing page for the
server (hero, description, screenshot gallery) with live status woven in —
players online, uptime, system memory, and service health.
- The template is
web/index.htmlwith simple{{placeholder}}substitution (name,address,status,players_online/max,version,uptime,mem_used/total/percent,*_status). Edit it freely. - Images and other static files live in
web/assets/and are served from/assets/.... Swap in your own. GET /status.jsonreturns the live data; the page polls it every 5s.- Player counts come from a status ping to the backend; memory from
/proc/meminfo.
Deploy the whole web/ folder next to proxy.jar. Set [web] name and
address for your branding.
The multiauth server knows how each player authenticated (Minehut players hit
Minehut's session server, direct players hit Mojang's) and records it. With
[api] enabled = true it's exposed at:
GET http://127.0.0.1:<auth_listen>/source?username=<name>
→ {"source":"minehut"} | {"source":"mojang"} | {"source":null}
A ready-made PlaceholderAPI expansion that consumes this lives in
papi-expansion/ and provides %proxy_source%,
%proxy_source_badge% ([MH]/``), and %proxy_source_raw%.
The dual proxy adds a per-connection byte pump, so very long uptimes on small boxes can raise ping. To keep it lean:
- The wrapper caps its own heap (
[proxy] heap, default 128M) so it can't grow toward the JVM's ~25%-of-RAM default; the backend gets the real memory. TCP_NODELAYis set on both sides to avoid Nagle latency.- Connections and pipe directions run on virtual threads (low per-connection cost).