mirror of
https://github.com/by-sonic/tglock.git
synced 2026-10-06 17:25:59 +03:00
fix: маршруты, медиа и Worker; Android APK и статический ARM64 CLI (#60)
* fix: address routing, media, worker and platform issues * ci: use available Android tools and verify Windows CLI artifact * fix(gui): return a result from asynchronous stop command * test(android): verify installed APK and proxy lifecycle on emulator * fix(media): use native MTProto for CDN203 and retain recent diagnostics * Fix owned WebSocket split after transport boxing * Clarify CDN transport and connection status guarantees * Recognize accessible Android power button labels * chore(release): prepare 2.0.0-beta.15 with verified draft publication --------- Co-authored-by: babin <Thartewerner536e@engineer.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded installed-APK smoke; no Telegram account or external network needed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
PACKAGE = "com.bysonic.tglock"
|
||||
COMPONENT = f"{PACKAGE}/.MainActivity"
|
||||
EVIDENCE = Path("android-smoke-evidence")
|
||||
EVIDENCE.mkdir(exist_ok=True)
|
||||
RESULT = {"launch": "not_run", "lifecycle": "not_run"}
|
||||
|
||||
|
||||
def adb(*args, check=True, timeout=20):
|
||||
return subprocess.run(
|
||||
["adb", *args], check=check, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=timeout,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def wait_for(description, predicate, seconds=20):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"Timed out: {description}")
|
||||
|
||||
|
||||
def ui_dump(label):
|
||||
# Dump first, then read the file; never tap coordinates inferred from a
|
||||
# screenshot, a previous Activity, or assumed phone dimensions.
|
||||
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
|
||||
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml")
|
||||
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
|
||||
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
|
||||
return ET.fromstring(text)
|
||||
|
||||
|
||||
def label_node(tree, label):
|
||||
for node in tree.iter("node"):
|
||||
# Android aggregates the decorative arrow into the button's accessible
|
||||
# text (observed in the API35 CI dump). Accept only that exact suffix.
|
||||
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
|
||||
if any(name in (label, f"{label} →") for name in names):
|
||||
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
|
||||
if bounds:
|
||||
x1, y1, x2, y2 = map(int, bounds.groups())
|
||||
if x2 > x1 and y2 > y1:
|
||||
return (x1 + x2) // 2, (y1 + y2) // 2
|
||||
return None
|
||||
|
||||
|
||||
def tap_label(label, stage):
|
||||
for attempt in range(3):
|
||||
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
|
||||
if point is not None:
|
||||
adb("shell", "input", "tap", str(point[0]), str(point[1]))
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"Visible action not found: {label}")
|
||||
|
||||
|
||||
def service_running():
|
||||
text = adb("shell", "dumpsys", "activity", "services", f"{PACKAGE}/.TunnelService")
|
||||
(EVIDENCE / "services-last.txt").write_text(text, encoding="utf-8")
|
||||
return "isForeground=true" in text
|
||||
|
||||
|
||||
def proxy_ready():
|
||||
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
|
||||
# Rust listener is serving, beyond just a notification being displayed.
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer:
|
||||
peer.sendall(bytes([5, 1, 0]))
|
||||
return peer.recv(2) == bytes([5, 0])
|
||||
except (OSError, TimeoutError):
|
||||
return False
|
||||
|
||||
|
||||
def launch():
|
||||
adb("shell", "am", "start", "-W", "-n", COMPONENT)
|
||||
wait_for("Activity resumed", lambda: any(
|
||||
"ResumedActivity" in line and PACKAGE in line
|
||||
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
|
||||
))
|
||||
assert adb("shell", "pidof", PACKAGE), "App process is absent"
|
||||
|
||||
|
||||
def main():
|
||||
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
|
||||
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
|
||||
adb("install", "-r", str(apks[0]), timeout=60)
|
||||
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
|
||||
adb("logcat", "-c")
|
||||
adb("forward", "tcp:11080", "tcp:1080")
|
||||
launch()
|
||||
RESULT["launch"] = "passed"
|
||||
deadline = time.monotonic() + 20
|
||||
tree = None
|
||||
start_point = None
|
||||
attempt = 0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
tree = ui_dump(f"launched-{attempt}")
|
||||
start_point = label_node(tree, "Включить защиту")
|
||||
if start_point is not None:
|
||||
break
|
||||
except (subprocess.SubprocessError, ET.ParseError):
|
||||
pass
|
||||
attempt += 1
|
||||
time.sleep(1)
|
||||
assert not service_running(), "Foreground service started without user action"
|
||||
assert not proxy_ready(), "Proxy started without user action"
|
||||
if tree is not None:
|
||||
assert any(
|
||||
node.get("class") == "android.webkit.WebView"
|
||||
for node in tree.iter("node")
|
||||
), "App Activity is resumed but its WebView is absent"
|
||||
if start_point is None:
|
||||
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
|
||||
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
|
||||
return
|
||||
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
|
||||
tap_label("Включить защиту", "before-start")
|
||||
wait_for("foreground service after Start", service_running)
|
||||
wait_for("Rust SOCKS listener after Start", proxy_ready)
|
||||
adb("shell", "input", "keyevent", "KEYCODE_HOME")
|
||||
time.sleep(5)
|
||||
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
|
||||
launch()
|
||||
tap_label("Выключить", "before-stop")
|
||||
wait_for("foreground service after Stop", lambda: not service_running())
|
||||
wait_for("Rust listener after Stop", lambda: not proxy_ready())
|
||||
# Explicit restart and user force-stop, then relaunch. This tests the
|
||||
# user-stop contract, not Android's automatic low-memory process eviction.
|
||||
tap_label("Включить защиту", "before-restart")
|
||||
wait_for("Rust listener after restart", proxy_ready)
|
||||
adb("shell", "am", "force-stop", PACKAGE)
|
||||
launch()
|
||||
assert not service_running() and not proxy_ready(), "Proxy silently restarted after force-stop"
|
||||
RESULT["lifecycle"] = "passed: Start, background 5s, Stop, restart, force-stop"
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
RESULT["failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
logs = adb("logcat", "-d", "-v", "threadtime")
|
||||
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
|
||||
crashes = adb("logcat", "-b", "crash", "-d")
|
||||
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
|
||||
if PACKAGE in crashes or f"ANR in {PACKAGE}" in logs or not adb("shell", "pidof", PACKAGE):
|
||||
RESULT["launch"] = "failed: application crash, ANR, or missing process"
|
||||
raise AssertionError("Application did not remain healthy")
|
||||
finally:
|
||||
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8")
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
with open(summary, "a", encoding="utf-8") as report:
|
||||
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
|
||||
print(json.dumps(RESULT))
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
// Manual, account-free probe of an ALREADY RUNNING local TGLock instance.
|
||||
// Protocol sources (this does not import the Rust implementation):
|
||||
// https://core.telegram.org/mtproto/auth_key
|
||||
// https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation
|
||||
// https://core.telegram.org/mtproto/description#unencrypted-message
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const MAX_RESPONSE = 2 * 1024 * 1024;
|
||||
const HELP = `Usage: node scripts/probe_proxy.mjs --secret-file PATH [--port 1080] [--dc 2] [--timeout-ms 15000] [--fragment-size 0]
|
||||
Or: node scripts/probe_proxy.mjs --direct-cdn --dc 203
|
||||
DC: 1..5 or 203; negative values request the media route.
|
||||
Default connects only to 127.0.0.1. --direct-cdn explicitly probes only 91.105.192.100:443 without a secret.
|
||||
Reads the local proxy secret from the explicit file; never prints it.
|
||||
Sends one req_pq_multi and checks resPQ/nonce. No account, API credentials, login or auth key is created.
|
||||
This proves a protocol response, not DC identity, account operation or media downloads.
|
||||
Use --self-test for offline parser checks; --fragment-size 7 sends small writes with 2ms gaps.`;
|
||||
|
||||
function options(args) {
|
||||
const result = { port: 1080, dc: 2, timeoutMs: 15000, fragmentSize: 0 };
|
||||
const numeric = { "--port": "port", "--dc": "dc", "--timeout-ms": "timeoutMs", "--fragment-size": "fragmentSize" };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const name = args[i];
|
||||
if (name === "--direct-cdn") { result.directCdn = true; continue; }
|
||||
const value = args[++i];
|
||||
if (value === undefined) throw new Error(`Missing value for ${name}`);
|
||||
if (name === "--secret-file") result.secretFile = value;
|
||||
else if (numeric[name] && /^-?\d+$/.test(value)) result[numeric[name]] = Number(value);
|
||||
else throw new Error(`Invalid option ${name}`);
|
||||
}
|
||||
if (!result.secretFile && !result.directCdn) throw new Error("--secret-file is required");
|
||||
if (result.directCdn && result.dc !== 203) throw new Error("Direct CDN probe requires --dc 203");
|
||||
if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) throw new Error("Invalid port");
|
||||
if (![1, 2, 3, 4, 5, 203].includes(Math.abs(result.dc))) throw new Error("Unsupported DC");
|
||||
if (result.timeoutMs < 100 || result.timeoutMs > 120000) throw new Error("Timeout must be 100..120000ms");
|
||||
if (result.fragmentSize < 0 || result.fragmentSize > 65536) throw new Error("Fragment size must be 0..65536");
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeRequest(secret, dc) {
|
||||
let header;
|
||||
do {
|
||||
header = randomBytes(64);
|
||||
} while (header[0] === 0xef || ["HEAD", "POST", "GET ", "OPTI"].includes(header.toString("ascii", 0, 4))
|
||||
|| [0xeeeeeeee, 0xdddddddd, 0x02010316].includes(header.readUInt32LE()) || header.readUInt32LE(4) === 0);
|
||||
header.fill(0xdd, 56, 60); // padded intermediate transport
|
||||
header.writeInt16LE(dc, 60);
|
||||
const salted = (key) => secret ? createHash("sha256").update(key).update(secret).digest() : Buffer.from(key);
|
||||
const encrypt = createCipheriv("aes-256-ctr", salted(header.subarray(8, 40)), header.subarray(40, 56));
|
||||
const reversed = Buffer.from(header.subarray(8, 56)).reverse();
|
||||
const decrypt = createDecipheriv("aes-256-ctr", salted(reversed.subarray(0, 32)), reversed.subarray(32));
|
||||
const wireHeader = Buffer.from(header);
|
||||
encrypt.update(header).copy(wireHeader, 56, 56); // advances outgoing CTR by all 64 bytes
|
||||
|
||||
const nonce = randomBytes(16);
|
||||
const payload = Buffer.alloc(40);
|
||||
// auth_key_id = 0, client message ID divisible by four, TL payload length = 20.
|
||||
const now = BigInt(Date.now());
|
||||
const messageId = ((now / 1000n << 32n) | ((now % 1000n) * (1n << 32n) / 1000n)) & ~3n;
|
||||
payload.writeBigUInt64LE(messageId, 8);
|
||||
payload.writeUInt32LE(20, 16);
|
||||
payload.writeUInt32LE(0xbe7e8ef1, 20);
|
||||
nonce.copy(payload, 24);
|
||||
const padded = Buffer.concat([payload, randomBytes(7)]);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32LE(padded.length);
|
||||
return { wire: Buffer.concat([wireHeader, encrypt.update(Buffer.concat([length, padded]))]), decrypt, nonce };
|
||||
}
|
||||
|
||||
function parseResPQ(frame, nonce) {
|
||||
if (frame.length === 4) throw new Error(`MTProto transport error ${frame.readInt32LE()}`);
|
||||
if (frame.length < 20 || frame.readBigUInt64LE() !== 0n) throw new Error("Expected an unencrypted MTProto response");
|
||||
const length = frame.readUInt32LE(16);
|
||||
if (length < 48 || length % 4 !== 0 || length > frame.length - 20) throw new Error("Invalid MTProto message length");
|
||||
const body = frame.subarray(20, 20 + length);
|
||||
if (body.readUInt32LE() !== 0x05162463) throw new Error("Response is not resPQ");
|
||||
if (!body.subarray(4, 20).equals(nonce)) throw new Error("resPQ nonce does not match request");
|
||||
// pq is at most eight bytes for this handshake, so its TL string uses the
|
||||
// one-byte length encoding followed by padding to a four-byte boundary.
|
||||
const pqLength = body[36];
|
||||
if (pqLength < 1 || pqLength > 8) throw new Error("Invalid resPQ pq length");
|
||||
const vectorOffset = 36 + Math.ceil((1 + pqLength) / 4) * 4;
|
||||
if (vectorOffset + 8 > body.length || body.readUInt32LE(vectorOffset) !== 0x1cb5c415) throw new Error("Invalid RSA fingerprint vector");
|
||||
const count = body.readUInt32LE(vectorOffset + 4);
|
||||
// A CDN may include trailing random bytes in its declared message length.
|
||||
// Validate the complete TL object fits; do not mistake padding for corruption.
|
||||
if (count < 1 || count > 64 || vectorOffset + 8 + count * 8 > body.length) throw new Error("Invalid RSA fingerprint count");
|
||||
// Deliberately do not infer DC identity from these public fingerprints.
|
||||
return { response: "resPQ", nonceMatches: true, rsaFingerprintCount: count };
|
||||
}
|
||||
|
||||
async function probe(config) {
|
||||
let secret = null;
|
||||
if (!config.directCdn) {
|
||||
let hex = (await readFile(config.secretFile, "utf8")).trim();
|
||||
if (/^dd[0-9a-f]{32}$/i.test(hex)) hex = hex.slice(2);
|
||||
if (!/^[0-9a-f]{32}$/i.test(hex)) throw new Error("Secret file must contain 32 hex characters or dd followed by 32 hex characters");
|
||||
secret = Buffer.from(hex, "hex");
|
||||
}
|
||||
const { wire, decrypt, nonce } = makeRequest(secret, config.dc);
|
||||
const started = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(config.directCdn
|
||||
? { host: "91.105.192.100", port: 443 }
|
||||
: { host: "127.0.0.1", port: config.port });
|
||||
let pending = Buffer.alloc(0);
|
||||
let received = 0;
|
||||
let finished = false;
|
||||
const finish = (error, result) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve({ transport: config.directCdn ? "direct-cdn-tcp" : "local-proxy", requestedDc: config.dc, ...result, elapsedMs: Date.now() - started });
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("Timed out waiting for resPQ")), config.timeoutMs);
|
||||
socket.on("error", (error) => finish(error));
|
||||
socket.on("end", () => finish(new Error("Proxy closed before a complete resPQ response")));
|
||||
socket.on("data", (chunk) => {
|
||||
try {
|
||||
received += chunk.length;
|
||||
if (received > MAX_RESPONSE) throw new Error("Response exceeded the 2MiB limit");
|
||||
pending = Buffer.concat([pending, decrypt.update(chunk)]);
|
||||
while (pending.length >= 4) {
|
||||
const length = pending.readUInt32LE();
|
||||
if (length & 0x80000000) { // optional intermediate quick acknowledgment
|
||||
pending = pending.subarray(4);
|
||||
continue;
|
||||
}
|
||||
if (length < 4 || length > MAX_RESPONSE - 4) throw new Error("Invalid intermediate frame length");
|
||||
if (pending.length < 4 + length) return;
|
||||
if (length >= 8 && length <= 16 && pending.readUInt32LE(4) === 0xffffffff) {
|
||||
pending = pending.subarray(4 + length); // padded intermediate quick ACK
|
||||
continue;
|
||||
}
|
||||
finish(null, parseResPQ(pending.subarray(4, 4 + length), nonce));
|
||||
return;
|
||||
}
|
||||
} catch (error) { finish(error); }
|
||||
});
|
||||
socket.on("connect", async () => {
|
||||
try {
|
||||
socket.setNoDelay(true);
|
||||
const size = config.fragmentSize || wire.length;
|
||||
for (let offset = 0; offset < wire.length && !finished; offset += size) {
|
||||
socket.write(wire.subarray(offset, offset + size));
|
||||
if (config.fragmentSize) await sleep(2);
|
||||
}
|
||||
} catch (error) { finish(error); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selfTest() {
|
||||
// Fixed TL fixture: resPQ, request nonce 00..0f, server nonce 10..1f,
|
||||
// eight-byte pq, a one-element vector of public RSA fingerprints.
|
||||
const body = Buffer.from("63241605000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f08112233445566778800000015c4b51c010000008877665544332211", "hex");
|
||||
const envelope = Buffer.alloc(20);
|
||||
envelope.writeUInt32LE(body.length, 16);
|
||||
const frame = Buffer.concat([envelope, body, Buffer.from([1, 2, 3])]);
|
||||
const nonce = Buffer.from("000102030405060708090a0b0c0d0e0f", "hex");
|
||||
assert.equal(parseResPQ(frame, nonce).rsaFingerprintCount, 1);
|
||||
const paddedEnvelope = Buffer.from(envelope);
|
||||
paddedEnvelope.writeUInt32LE(body.length + 128, 16);
|
||||
assert.equal(parseResPQ(Buffer.concat([paddedEnvelope, body, Buffer.alloc(128, 0x42)]), nonce).rsaFingerprintCount, 1);
|
||||
assert.throws(() => parseResPQ(frame, Buffer.alloc(16)), /nonce/);
|
||||
assert.throws(() => parseResPQ(frame.subarray(0, 30), nonce), /length/);
|
||||
const wrongConstructor = Buffer.from(frame);
|
||||
wrongConstructor[20] = 0;
|
||||
assert.throws(() => parseResPQ(wrongConstructor, nonce), /not resPQ/);
|
||||
const oversizedVector = Buffer.from(frame);
|
||||
oversizedVector.writeUInt32LE(65, 72);
|
||||
assert.throws(() => parseResPQ(oversizedVector, nonce), /count/);
|
||||
assert.throws(() => parseResPQ(Buffer.from("6cfeffff", "hex"), nonce), /-404/);
|
||||
console.log("Offline parser checks passed; no network connection made.");
|
||||
}
|
||||
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 1 && args[0] === "--self-test") selfTest();
|
||||
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) console.log(HELP);
|
||||
else console.log(JSON.stringify(await probe(options(args)), null, 2));
|
||||
} catch (error) {
|
||||
console.error(`Probe failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user