A minimal, self-hosted broadcast-capable virtual LAN, purpose-built to make Mini Militia's (or any) local WiFi game discovery work between BlueStacks, MEmu, and a real phone. No accounts, no cloud, no root, and no configuration on any device -- install the same APK everywhere and it finds the hub and assigns itself an IP automatically.
This revision responds to a security/robustness review of the previous build. Summary of what changed and why:
| Concern | Fix |
|---|---|
| Any device could forge another's virtual IP (no source validation) | Per-device session tokens (see below) |
| Peer names not sanitized server-side | Hub now always re-sanitizes regardless of client |
| No authentication anywhere | Shared NETWORK_KEY required on every request |
Service never called startForeground() -- OS could silently kill the tunnel |
Fixed, with proper Android 14 specialUse type |
No onRevoke() handling |
Added, shares cleanup logic with onDestroy() |
| Hub IP not persisted -- peer list broke across Activity recreation | Persisted to SharedPreferences |
| Peer-list parsing was regex-based | Switched to real org.json parsing |
| No emulator detection | Added EmulatorDetector, used to inform networking |
True MAC-address binding isn't possible in this architecture: Android's
VpnService TUN interface and plain UDP sockets both operate above Layer 2,
so this relay never sees a MAC address at any point, on any device. It
also wouldn't help much even if it were available -- a claimed MAC address
is just as forgeable as a claimed source IP was.
The fix that actually addresses the underlying concern is per-device
session tokens: when the hub allocates a virtual IP to a device, it also
hands back a random private token. Every packet sent to the relay port
must be prefixed with that token, and the hub only relays/registers a
packet if the attached token matches the one issued for that specific
virtual IP. A rogue device on the LAN can no longer claim 10.10.10.3
just by writing that address into a packet header -- it would also need
to know the private token bound to it, which was only ever sent privately
to the device that legitimately holds that IP.
Every request now requires a shared secret string (NETWORK_KEY) to match
between the hub and every device's build. Change the default value
(changeme-shared-secret) in both hub_relay.py and
Config.NETWORK_KEY in TunLanService.kt to the same new string before
relying on this for anything beyond initial testing -- they must match
exactly, character for character, since this is a static shared secret
baked into your own private build (the same model tools like Hamachi use
for a "network name/password"), not something entered per session.
EmulatorDetector.kt uses the same general approach production apps use
for environment checks: no single signal is proof on its own (all are
spoofable in principle), so several are combined -- known file paths
(BlueStacks shares a Windows folder into the guest at a fixed location),
Build.MANUFACTURER/BRAND/PRODUCT/HARDWARE fields specific vendors
set, and generic QEMU/AVD signals. This is surfaced in the app UI ("Platform:
BlueStacks", etc.) and feeds directly into networking: see below.
The app obviously can't run ipconfig on your Windows host directly --
that's a different machine. But it doesn't need to: it can read its own
IP and subnet from inside the guest (exactly the same information
ipconfig would show for that specific virtual adapter on the host side)
and derive the gateway from it. Empirically, across every BlueStacks and
MEmu instance tested, the host's address on that virtual switch was always
the .1 of whatever subnet the guest landed on. So the app now:
- Tries broadcast discovery first (works on BlueStacks and real WiFi).
- If that fails, computes its own subnet's
.1address and sends a direct unicast probe to it, requiring a valid signed reply before trusting it -- this isn't a blind guess, a wrong guess just fails cleanly. This is what fixes MEmu automatically: its unicast networking works fine even though its broadcast doesn't, so this probe succeeds without ever needing the manual IP field. - Only falls back to the manual override field if both of the above fail.
Real routing (WireGuard, IP forwarding, etc.) only delivers packets addressed
to a specific IP. Game discovery relies on broadcast packets sent to
255.255.255.255, which normal routers/tunnels never replicate to other
peers -- and won't even enter a typical VPN tunnel unless the tunnel is
explicitly told to capture that address. This project adds both missing
pieces: a hub that floods broadcast/multicast packets to every connected
device, and a VPN route table that actually captures that traffic in the
first place.
hub_relay.py-- runs on your PC. Pure Python standard library, no installs needed. Three ports:- UDP 7776 -- discovery (now requires the shared key).
- UDP 7777 -- the data relay. Requires a valid per-device session token on every packet; forwards unicast to the right peer and floods broadcast/multicast to everyone else.
- UDP 7778 -- the IP allocator. Hands out a virtual IP + private
token (persisted across restarts via
leases.json), stores each device's sanitized friendly name, answers "who's online?" queries.
android-app/-- an Android Studio project. Builds one small app that creates a TUN interface via Android's built-inVpnService(no root needed), detects which emulator (if any) it's running on, finds the hub automatically, requests its IP + token, registers itself immediately, and forwards every packet (including broadcast/multicast) to/from the hub over UDP -- now as a proper foreground service so it survives being backgrounded. Also shows a live peer list.
Before building, edit NETWORK_KEY in hub_relay.py and
Config.NETWORK_KEY in TunLanService.kt to the same new value. They
must match exactly on both sides.
python hub_relay.py
Leave this running. On start you should see:
[hub] discovery listening on UDP 7776
[hub] data relay listening on UDP 7777
[hub] IP allocator listening on UDP 7778
(If a leases.json already exists from a previous run, you'll also see
[hub] loaded N saved lease(s). If you forgot to change the default key,
the hub prints a loud warning at startup as a reminder.)
Make sure Windows Firewall allows all three ports inbound for Python:
netsh advfirewall firewall add rule name="LAN Bridge Discover" protocol=UDP dir=in localport=7776 action=allow
netsh advfirewall firewall add rule name="LAN Bridge Relay" protocol=UDP dir=in localport=7777 action=allow
netsh advfirewall firewall add rule name="LAN Bridge Alloc" protocol=UDP dir=in localport=7778 action=allowOpen the android-app/ folder in Android Studio (free download from
developer.android.com). Let Gradle sync -- first sync needs your own
machine's internet access, which can't be done from a sandboxed
environment, hence doing it in Android Studio directly.
- Build > Build Bundle(s) / APK(s) > Build APK(s).
- Install the same
app-debug.apkon every device: BlueStacks x2, MEmu, and your phone.
Open the app on each device and tap Start Bridge. On Android 13+ you'll also get a one-time notification permission prompt (needed for the persistent "bridge active" notification the foreground service now shows) in addition to the VPN permission prompt. Watch the status text go:
Detected platform: BlueStacks
Searching for hub via broadcast...
Found hub at 172.30.96.1, requesting IP...
Assigned IP: 10.10.10.x
Connected as 10.10.10.x (hub 172.30.96.1, BlueStacks)
On a device where broadcast doesn't reach the hub, you'll instead see it automatically fall through to the gateway-guess probe -- no manual entry needed anymore in most cases. The manual hub IP field still exists as a last-resort fallback.
Tap Refresh Peer List to see every other currently-connected device by name. This now works correctly even if you've rotated the screen or reopened the app since connecting, since the hub address is persisted.
Open Mini Militia (or any local WiFi game) on all devices and host a game on one -- the others should see it automatically.
- "No hub found" -- confirm
hub_relay.pyis running, the firewall rules above are in place, andNETWORK_KEYmatches exactly on both sides (a mismatched key causes every request to be silently ignored, which looks identical to "hub isn't running" from the app's point of view -- check this first). - A device that should be online doesn't show up in the peer list -- the list only includes devices seen in the last 45 seconds. Confirm that device's status says "Connected".
- Leases/tokens don't seem to persist -- confirm
leases.jsonis being written next tohub_relay.pyand the script has write permission in that folder. - Notification for the bridge doesn't appear -- on Android 13+, this
requires the
POST_NOTIFICATIONSruntime permission; the app requests it automatically when you tap Start Bridge, but if it was previously denied you'll need to grant it from the device's app settings manually.