tools: beacon fixes — cleartext, multi-device, validated-net POST, status URL
Debugging against a real restricted LAN surfaced three fixes: - Android blocks app cleartext HTTP by default (targetSdk 36) — the port discovery + reachability were fine (phone curl reached fmr), only the app POST was denied. Added usesCleartextTraffic for this dev tool. - Multi-device: report + receiver are keyed by device (Build.MODEL) so a phone and tablet don't clobber each other; connector connects each. - POST over a VALIDATED internet network (prefer cellular) since the wireless-debug wifi is often a restricted LAN. - Status/notification now show the target beacon URL + which network, per the request to surface what it's connecting to. Verified live: beacon tracks the (frequently rotating) port via mDNS and self-reports the current endpoint within seconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
52748ac853
commit
38d4440412
@@ -21,7 +21,7 @@ android {
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// Prefilled beacon config (dev tool — secret in the APK is fine).
|
||||
buildConfigField("String", "BEACON_URL", "\"http://fmr-1.echo-lot.app:9099/beacon\"")
|
||||
buildConfigField("String", "BEACON_URL", "\"http://89.185.109.150:443/beacon\"")
|
||||
buildConfigField("String", "BEACON_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||
}
|
||||
buildTypes { release { isMinifyEnabled = false } }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="Echolot ADB Beacon"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.Material.Light">
|
||||
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
|
||||
@@ -90,9 +90,16 @@ class BeaconService : Service() {
|
||||
val ip = wifiIpv4() ?: run { Status.set("no wlan0 IPv4 (is wifi up?)"); return }
|
||||
val port = currentPort
|
||||
if (port <= 0) { Status.set("$ip — waiting for wireless-debug port"); return }
|
||||
val body = """{"ip":"$ip","port":$port}"""
|
||||
val device = android.os.Build.MODEL.replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||
val body = """{"device":"$device","ip":"$ip","port":$port}"""
|
||||
// Send over a VALIDATED internet network — the wireless-debug wifi is often a restricted
|
||||
// LAN with no real internet TCP egress, so bind the report to cellular/whatever actually
|
||||
// reaches the beacon.
|
||||
val net = internetNetwork()
|
||||
val ok = runCatching {
|
||||
(URL(BuildConfig.BEACON_URL).openConnection() as HttpURLConnection).run {
|
||||
val url = URL(BuildConfig.BEACON_URL)
|
||||
val conn = (net?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
|
||||
conn.run {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = 5000; readTimeout = 5000
|
||||
doOutput = true
|
||||
@@ -102,9 +109,40 @@ class BeaconService : Service() {
|
||||
responseCode == 200
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
val line = if (ok) "reported $ip:$port ✓" else "report failed for $ip:$port (beacon unreachable?)"
|
||||
val via = if (net != null) "via ${netLabel(net)}" else "via default net"
|
||||
val line = if (ok) {
|
||||
"reported [$device] $ip:$port ✓ $via\n→ ${BuildConfig.BEACON_URL}"
|
||||
} else {
|
||||
"report FAILED for [$device] $ip:$port $via\n→ POST ${BuildConfig.BEACON_URL}"
|
||||
}
|
||||
Status.set(line)
|
||||
updateNotification(line)
|
||||
updateNotification(if (ok) "reported $ip:$port ✓" else "report failed for $ip:$port")
|
||||
}
|
||||
|
||||
/** A network with validated internet access, preferring cellular (the wireless-debug wifi is
|
||||
* frequently a restricted LAN that can't reach the beacon over TCP). */
|
||||
private fun internetNetwork(): android.net.Network? {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
var fallback: android.net.Network? = null
|
||||
for (n in cm.allNetworks) {
|
||||
val c = cm.getNetworkCapabilities(n) ?: continue
|
||||
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue
|
||||
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)) continue
|
||||
if (c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR)) return n
|
||||
fallback = n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun netLabel(n: android.net.Network): String {
|
||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
val c = cm.getNetworkCapabilities(n) ?: return "net"
|
||||
return when {
|
||||
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
else -> "net"
|
||||
}
|
||||
}
|
||||
|
||||
/** The wlan0 (or first private) IPv4 the PC routes to. */
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
# SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Tiny rendezvous receiver for the wireless-adb beacon. The phone POSTs its
|
||||
# current wireless-debug endpoint here; the PC-side connector reads the stored
|
||||
# file (over SSH) and runs `adb connect`. Dev tooling — not part of the product.
|
||||
# Rendezvous receiver for the wireless-adb beacon (dev tool). Phones/tablets
|
||||
# POST their current wireless-debug endpoint here, keyed by device; the PC-side
|
||||
# connector reads them all and keeps `adb connect` current for each.
|
||||
#
|
||||
# POST /beacon { "ip": "...", "port": N } header: X-Beacon-Secret: <secret>
|
||||
# GET /beacon -> the last stored JSON (no secret; it's just an ip:port)
|
||||
# POST /beacon { "device": "...", "ip": "...", "port": N }
|
||||
# header: X-Beacon-Secret: <secret>
|
||||
# GET /beacon -> { "<device>": {ip, port, seen_at}, ... }
|
||||
#
|
||||
# Secret comes from ECHOLOT_BEACON_SECRET; stored state goes to STATE_PATH.
|
||||
# Bind is 0.0.0.0:9099 so the phone can reach it over the internet/VPN.
|
||||
# ECHOLOT_BEACON_SECRET gates POST; STATE_PATH holds the device map.
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -23,6 +23,22 @@ BIND = os.environ.get("BEACON_BIND", "0.0.0.0")
|
||||
PORT = int(os.environ.get("BEACON_PORT", "9099"))
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
with open(STATE_PATH) as f:
|
||||
d = json.load(f)
|
||||
return d if isinstance(d, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def store(d):
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(d, f)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _send(self, code, body=b"", ctype="application/json"):
|
||||
self.send_response(code)
|
||||
@@ -35,11 +51,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.rstrip("/") != "/beacon":
|
||||
return self._send(404, b'{"error":"not found"}')
|
||||
try:
|
||||
with open(STATE_PATH, "rb") as f:
|
||||
self._send(200, f.read())
|
||||
except FileNotFoundError:
|
||||
self._send(200, b'{"available":false}')
|
||||
self._send(200, json.dumps(load()).encode())
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.rstrip("/") != "/beacon":
|
||||
@@ -49,19 +61,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
try:
|
||||
data = json.loads(self.rfile.read(n) or b"{}")
|
||||
device = str(data.get("device", "default"))
|
||||
ip = str(data["ip"])
|
||||
port = int(data["port"])
|
||||
except Exception:
|
||||
return self._send(400, b'{"error":"need {ip, port}"}')
|
||||
record = {"ip": ip, "port": port, "seen_at": int(time.time())}
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(record, f)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
self._send(200, json.dumps(record).encode())
|
||||
return self._send(400, b'{"error":"need {device, ip, port}"}')
|
||||
d = load()
|
||||
d[device] = {"ip": ip, "port": port, "seen_at": int(time.time())}
|
||||
store(d)
|
||||
self._send(200, json.dumps(d[device]).encode())
|
||||
|
||||
def log_message(self, *_):
|
||||
pass # quiet
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user