diff --git a/README.md b/README.md index 3de05614..c205d872 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,16 @@ outgoing: `/api/v1/outgoing.json` on the camera shows whether the destination is live. Majestic can also send to a multicast group (`udp://239.1.2.3:5600`), so several devices can watch one camera. On Wi‑Fi the app holds a multicast lock while it is in a group. +## Debug page (port 8085) + +While the app runs it serves its debug information over HTTP on port **8085**, for a browser or `curl` on the same network: + +- `http://:8085/` — a page that refreshes itself: device, app version, mode and every cell (resolution, codec, decoded/received fps, pauses); +- `http://:8085/stats.json` — the same as JSON, for scripts; +- `http://:8085/log` — the last 500 lines of the app's log. + +Only clients on the local network are answered (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7); others get 403. Logins and passwords are never shown. + ## Known limitations > [!IMPORTANT] @@ -185,6 +195,7 @@ Debug and release builds are signed with different keys. To switch from one to t | `CredentialStore`, `KeystoreCipher` | Encrypted storage of camera URLs (Android Keystore) | | `FramePool`, `Frame` | Buffer pool that reduces garbage-collector load | | `App`, `Crash` | Uncaught-exception handler and crash report screen | +| `DebugServer`, `NetInfo` | Debug page on port 8085 (LAN clients only), device addresses | Network, video decoding and audio run on separate threads, so a slow decoder never blocks the socket. @@ -193,6 +204,7 @@ Network, video decoding and audio run on separate threads, so a slow decoder nev - Credentials are taken from the URL and sent in the `Authorization` header: Basic first, Digest when the camera asks for it. They are not included in the RTSP request line. - RTSP, RTP over UDP and HTTP are not encrypted, and RTSPS is not supported. Credentials that a camera expects inside the URL path or query (for example `user=…_password=…`) are sent with every request. Use the app only on trusted networks, see [#16](https://github.com/OpenIPC/decoder/issues/16). - Camera URLs, including passwords, are stored encrypted (AES-256-GCM with a key kept in the Android Keystore, Android 6.0+) in the app's private storage. On Android 5.x, or if the Keystore is unavailable, they are stored in plain text. Backup is disabled (`allowBackup="false"`). +- The debug page on port 8085 is not encrypted and has no password; it answers only local-network clients and never shows logins or passwords. ## Device compatibility diff --git a/README.ru.md b/README.ru.md index 44c51aef..2e94f041 100644 --- a/README.ru.md +++ b/README.ru.md @@ -124,6 +124,16 @@ outgoing: `/api/v1/outgoing.json` на камере показывает, идёт ли отправка. Majestic умеет слать и в multicast-группу (`udp://239.1.2.3:5600`), тогда одну камеру могут смотреть несколько устройств. В Wi‑Fi приложение держит multicast lock, пока состоит в группе. +## Отладочная страница (порт 8085) + +Пока приложение работает, оно отдаёт отладочную информацию по HTTP на порту **8085** — для браузера или `curl` из той же сети: + +- `http://<приставка>:8085/` — страница с автообновлением: устройство, версия, режим и каждая ячейка (разрешение, кодек, fps декодированных/принятых кадров, паузы); +- `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов; +- `http://<приставка>:8085/log` — последние 500 строк лога приложения. + +Отвечает только клиентам из локальной сети (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7), остальным — 403. Логины и пароли не показываются. + ## Известные ограничения > [!IMPORTANT] @@ -185,6 +195,7 @@ export KEY_PASS=your_key_password | `CredentialStore`, `KeystoreCipher` | Зашифрованное хранение адресов камер (Android Keystore) | | `FramePool`, `Frame` | Пул буферов, снижает нагрузку на сборщик мусора | | `App`, `Crash` | Обработчик необработанных исключений и экран отчёта о сбое | +| `DebugServer`, `NetInfo` | Отладочная страница на порту 8085 (только локальная сеть), адреса устройства | Сеть, декодирование видео и звук работают в отдельных потоках, поэтому медленный декодер не блокирует сокет. @@ -193,6 +204,7 @@ export KEY_PASS=your_key_password - Логин и пароль берутся из адреса и передаются в заголовке `Authorization`: сначала Basic, Digest — если камера его запросит. В строку RTSP-запроса они не попадают. - RTSP, RTP по UDP и HTTP без шифрования, RTSPS не поддерживается. Если камера ждёт логин и пароль прямо в пути или параметрах адреса (например, `user=…_password=…`), они уходят с каждым запросом. Используйте приложение только в доверенных сетях, см. [#16](https://github.com/OpenIPC/decoder/issues/16). - Адреса камер вместе с паролями хранятся в закрытом хранилище приложения в зашифрованном виде (AES-256-GCM, ключ в Android Keystore, Android 6.0+). На Android 5.x или если Keystore недоступен — в открытом виде. Резервное копирование отключено (`allowBackup="false"`). +- Отладочная страница на порту 8085 без шифрования и пароля; она отвечает только клиентам из локальной сети и не показывает логины и пароли. ## Совместимость с устройствами diff --git a/README.zh-CN.md b/README.zh-CN.md index 77382d82..bf84f1fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -124,6 +124,16 @@ outgoing: 摄像机上的 `/api/v1/outgoing.json` 可查看推流是否正常。Majestic 也可以推送到组播组(`udp://239.1.2.3:5600`),这样多台设备可以同时观看同一台摄像机。在 Wi‑Fi 下,应用加入组播组期间会持有 multicast lock。 +## 调试页面(端口 8085) + +应用运行时会在 **8085** 端口通过 HTTP 提供调试信息,可在同一网络中用浏览器或 `curl` 访问: + +- `http://<设备>:8085/` — 自动刷新的页面:设备、应用版本、模式以及每个画面(分辨率、编码、已解码/已接收帧率、暂停); +- `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用; +- `http://<设备>:8085/log` — 应用日志的最后 500 行。 + +仅响应本地网络中的客户端(loopback、10/8、172.16/12、192.168/16、链路本地地址、IPv6 fc00::/7),其他地址返回 403。不会显示用户名和密码。 + ## 已知限制 > [!IMPORTANT] @@ -185,6 +195,7 @@ export KEY_PASS=your_key_password | `CredentialStore`、`KeystoreCipher` | 摄像机地址加密存储(Android Keystore) | | `FramePool`、`Frame` | 缓冲区池,减轻垃圾回收压力 | | `App`、`Crash` | 未捕获异常处理与崩溃报告界面 | +| `DebugServer`、`NetInfo` | 8085 端口调试页面(仅本地网络)、设备地址 | 网络、视频解码和音频分别运行在独立线程中,解码器较慢时也不会阻塞网络读取。 @@ -193,6 +204,7 @@ export KEY_PASS=your_key_password - 用户名和密码从地址中提取,通过 `Authorization` 头发送:默认使用 Basic,摄像机要求时改用 Digest。不会出现在 RTSP 请求行中。 - RTSP、UDP 上的 RTP 和 HTTP 均未加密,不支持 RTSPS。若摄像机要求将用户名和密码写在地址路径或参数中(例如 `user=…_password=…`),它们会随每个请求发送。请仅在可信网络中使用,参见 [#16](https://github.com/OpenIPC/decoder/issues/16)。 - 摄像机地址(含密码)加密保存在应用私有存储中(AES-256-GCM,密钥保存在 Android Keystore,Android 6.0 及以上),在 Android 5.x 或 Keystore 不可用时以明文保存,并已禁用备份(`allowBackup="false"`)。 +- 8085 端口上的调试页面未加密、无密码;仅响应本地网络客户端,且不会显示用户名和密码。 ## 设备兼容性 diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java new file mode 100644 index 00000000..032eb1ca --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -0,0 +1,356 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * DebugServer.java — read-only HTTP server with the debug information, LAN clients only (#96) + * + */ + +package com.openipc.decoder; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Serves what the decoder knows about itself on port 8085, for a browser or curl: + * {@code /} a page refreshing itself, {@code /stats.json} the same as JSON, {@code /log} the + * app's recent log. Always on; a client outside the local network gets 403. GET only, one + * request per connection, short timeouts and a few workers: it must never get in the way of + * decoding. Logins and passwords never leave the device (redacted like the log, #55). + */ +final class DebugServer { + private static final String TAG = "OpenIPCDecoder"; + static final int PORT = 8085; + /** Lines of the app's log served by /log. */ + static final int LOG_LINES = 500; + private static final int SOCKET_TIMEOUT_MS = 3000; + /** Whole request, writing included: a client that stops reading cannot hold a worker. */ + private static final long REQUEST_DEADLINE_MS = 5000; + private static final int MAX_HEADER_BYTES = 8192; + private static final long REBIND_DELAY_MS = 10_000; + + /** What the server reports; called on a server thread. */ + interface Source { + /** State as nested Map / List / String / Number / Boolean, see {@link #toJson}. */ + Map snapshot(); + } + + private static DebugServer instance; + + private volatile Source source; + /** "listening" or why the port could not be opened, for the debug text. */ + private volatile String state = "starting"; + private final ThreadPoolExecutor workers = new ThreadPoolExecutor(2, 2, 30, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(4), r -> { + Thread t = new Thread(r, "debug-http"); + t.setDaemon(true); + return t; + }); + private final ScheduledThreadPoolExecutor deadlines = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "debug-http-deadline"); + t.setDaemon(true); + return t; + }); + private final LogBuffer log = new LogBuffer(LOG_LINES); + + private DebugServer() {} + + /** The one server of the process, started on first use and kept for its lifetime. */ + static synchronized DebugServer get() { + if (instance == null) { + instance = new DebugServer(); + instance.log.startReader(); + Thread t = new Thread(instance::acceptLoop, "debug-http-accept"); + t.setDaemon(true); + t.start(); + } + return instance; + } + + void setSource(Source s) { source = s; } + + /** Clears the source only if it is still the given one (an old activity going away). */ + void clearSource(Source s) { + if (source == s) source = null; + } + + String state() { return state; } + + private void acceptLoop() { + while (true) { + try (ServerSocket server = new ServerSocket()) { + server.setReuseAddress(true); + server.bind(new InetSocketAddress(PORT)); + state = "listening"; + Log.i(TAG, "Debug HTTP server on port " + PORT); + while (true) { + Socket s = server.accept(); + try { + workers.execute(() -> handle(s)); + } catch (RejectedExecutionException busy) { + closeQuietly(s); + } + } + } catch (IOException e) { + state = "port " + PORT + " unavailable: " + e.getMessage(); + Log.w(TAG, "Debug HTTP server: " + state + ", retrying"); + } + try { + Thread.sleep(REBIND_DELAY_MS); + } catch (InterruptedException e) { + return; + } + } + } + + private void handle(Socket s) { + // Socket writes have no timeout of their own: closing the socket ends a blocked write + ScheduledFuture deadline = deadlines.schedule(() -> closeQuietly(s), + REQUEST_DEADLINE_MS, TimeUnit.MILLISECONDS); + try (Socket socket = s) { + socket.setSoTimeout(SOCKET_TIMEOUT_MS); + OutputStream out = socket.getOutputStream(); + if (!NetInfo.isLocalClient(socket.getInetAddress())) { + reply(out, 403, "text/plain", "Forbidden: local network only\n"); + return; + } + String requestLine = readRequestHead(socket.getInputStream()); + if (requestLine == null) return; + String[] parts = requestLine.split(" "); + if (parts.length < 2) { + reply(out, 400, "text/plain", "Bad request\n"); + return; + } + if (!parts[0].equals("GET")) { + reply(out, 405, "text/plain", "Only GET\n"); + return; + } + String path = parts[1]; + int q = path.indexOf('?'); + if (q >= 0) path = path.substring(0, q); + switch (path) { + case "/": + case "/index.html": + reply(out, 200, "text/html; charset=utf-8", PAGE); + break; + case "/stats.json": + reply(out, 200, "application/json; charset=utf-8", toJson(snapshot())); + break; + case "/log": + reply(out, 200, "text/plain; charset=utf-8", log.text()); + break; + default: + reply(out, 404, "text/plain", "Not found\n"); + } + } catch (IOException e) { + // The client went away or was too slow: nothing to report + } catch (RuntimeException e) { + Log.w(TAG, "Debug HTTP request failed", e); + } finally { + deadline.cancel(false); + } + } + + private Map snapshot() { + Source s = source; + Map m; + if (s != null) { + m = s.snapshot(); + } else { + m = new java.util.LinkedHashMap<>(); + m.put("mode", "no activity"); + } + m.put("server", state); + return m; + } + + /** The request line; the headers are read and dropped. Null on a closed connection. */ + static String readRequestHead(InputStream in) throws IOException { + ByteArrayOutputStream head = new ByteArrayOutputStream(); + int matched = 0; + while (head.size() < MAX_HEADER_BYTES) { + int b = in.read(); + if (b < 0) break; + head.write(b); + // End of the head: \r\n\r\n (or a bare \n\n) + if (b == '\n') { + if (++matched == 2) break; + } else if (b != '\r') { + matched = 0; + } + } + String text = head.toString("ISO-8859-1"); + int eol = text.indexOf('\n'); + String line = (eol >= 0 ? text.substring(0, eol) : text).trim(); + return line.isEmpty() ? null : line; + } + + private static void reply(OutputStream out, int code, String type, String body) + throws IOException { + byte[] data = body.getBytes(StandardCharsets.UTF_8); + String head = "HTTP/1.1 " + code + " " + reason(code) + "\r\n" + + "Content-Type: " + type + "\r\n" + + "Content-Length: " + data.length + "\r\n" + + "Cache-Control: no-store\r\n" + + (code == 405 ? "Allow: GET\r\n" : "") + + "Connection: close\r\n\r\n"; + out.write(head.getBytes(StandardCharsets.ISO_8859_1)); + out.write(data); + out.flush(); + } + + private static String reason(int code) { + switch (code) { + case 200: return "OK"; + case 400: return "Bad Request"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + default: return "Error"; + } + } + + private static final java.util.regex.Pattern AUTH_HEADER = + java.util.regex.Pattern.compile("(?i)(authorization:\\s*\\w+)\\s+\\S.*"); + /** Logins in the URL path or query, e.g. user=admin_password=... (XM cameras). */ + private static final java.util.regex.Pattern USER_PARAM = + java.util.regex.Pattern.compile("(?i)\\b(user(?:name)?|login|usr)=[^_&;/?\\s]*"); + + /** Like the log (#55), plus logins and Authorization headers: never a login or password. */ + static String redact(String s) { + if (s == null) return null; + s = RtspSession.redact(s); + s = USER_PARAM.matcher(s).replaceAll("$1=***"); + return AUTH_HEADER.matcher(s).replaceAll("$1 ***"); + } + + // ====================== JSON ====================== + + /** JSON of nested Map / List / array / String / Number / Boolean / null. */ + static String toJson(Object v) { + StringBuilder sb = new StringBuilder(); + writeJson(sb, v); + return sb.toString(); + } + + private static void writeJson(StringBuilder sb, Object v) { + if (v == null) { + sb.append("null"); + } else if (v instanceof Boolean || v instanceof Integer || v instanceof Long) { + sb.append(v); + } else if (v instanceof Number) { + double d = ((Number) v).doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) sb.append("null"); + else sb.append(String.format(Locale.US, "%.2f", d)); + } else if (v instanceof Map) { + sb.append('{'); + boolean first = true; + for (Map.Entry e : ((Map) v).entrySet()) { + if (!first) sb.append(','); + first = false; + writeString(sb, String.valueOf(e.getKey())); + sb.append(':'); + writeJson(sb, e.getValue()); + } + sb.append('}'); + } else if (v instanceof List) { + sb.append('['); + boolean first = true; + for (Object o : (List) v) { + if (!first) sb.append(','); + first = false; + writeJson(sb, o); + } + sb.append(']'); + } else if (v instanceof Object[]) { + writeJson(sb, java.util.Arrays.asList((Object[]) v)); + } else { + writeString(sb, String.valueOf(v)); + } + } + + private static void writeString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20 || c == '<' || c == '>' || c == '&') { + sb.append(String.format(Locale.US, "\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void closeQuietly(Socket s) { + try { + s.close(); + } catch (IOException ignored) { + } + } + + // ====================== Page ====================== + + /** Static page: it fetches /stats.json every 1.5 s and renders it, no server templating. */ + private static final String PAGE = "\n" + + "" + + "" + + "OpenIPC Decoder" + + "

OpenIPC Decoder

" + + "
stats.json · log" + + "
" + + "
" + + "\n"; +} diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index ed8593b7..2216d616 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -21,6 +21,8 @@ import android.os.Build; import android.os.Bundle; import android.os.Environment; +import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; import android.provider.MediaStore; import android.util.Log; @@ -49,14 +51,20 @@ import java.net.NetworkInterface; import java.text.SimpleDateFormat; import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; public class Decoder extends Activity implements MenuManager.Listener { @@ -273,6 +281,7 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { } updateStatus("disconnected"); + DebugServer.get().setSource(debugSource); } // ====================== MenuManager.Listener ====================== @@ -927,6 +936,110 @@ private void removeQuadOverlay(int index) { } } + // ====================== Debug HTTP server (#96) ====================== + + /** How long a request waits for the UI thread before it reports it as not responding. */ + private static final long DEBUG_UI_TIMEOUT_MS = 1000; + private final DebugServer.Source debugSource = this::debugSnapshot; + + /** Called on a server thread: the state is read on the UI thread, which owns it. */ + private Map debugSnapshot() { + AtomicReference> ref = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + new Handler(Looper.getMainLooper()).post(() -> { + try { + ref.set(debugState()); + } finally { + done.countDown(); + } + }); + boolean answered; + try { + answered = done.await(DEBUG_UI_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + answered = false; + } + Map m = ref.get(); + if (!answered || m == null) { + // A stuck UI thread is itself the news (ANR, #76): report what needs no UI state + m = debugDevice(); + m.put("ui_thread", "not responding within " + DEBUG_UI_TIMEOUT_MS + " ms"); + } + return m; + } + + /** Device and app facts, safe to read from any thread. */ + private Map debugDevice() { + Map m = new LinkedHashMap<>(); + m.put("app", "OpenIPC Decoder " + mVersion + " (" + BuildConfig.GIT_HASH + ")"); + String soc = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ? Build.SOC_MANUFACTURER + " " + Build.SOC_MODEL : Build.HARDWARE; + m.put("device", Build.MANUFACTURER + " " + Build.MODEL + " \u00b7 " + soc + + " \u00b7 board " + Build.BOARD); + m.put("android", Build.VERSION.RELEASE + " (SDK " + Build.VERSION.SDK_INT + ")"); + m.put("screen", getResources().getDisplayMetrics().widthPixels + "x" + + getResources().getDisplayMetrics().heightPixels); + List urls = new ArrayList<>(); + for (String ip : NetInfo.addresses()) urls.add("http://" + ip + ":" + DebugServer.PORT + "/"); + m.put("addresses", urls); + m.put("uptime_s", SystemClock.elapsedRealtime() / 1000); + return m; + } + + /** The state behind the on-screen texts; UI thread only. */ + private Map debugState() { + Map m = debugDevice(); + m.put("ui_thread", "ok"); + m.put("mode", !mResumed ? "paused" : udpViewShown ? "udp" + : quadEnabled ? "multi-view" : "single"); + m.put("active_slot", mActive + 1); + if (udpViewShown) { + Map udp = new LinkedHashMap<>(); + udp.put("port", UDP_RTP_PORT); + udp.put("status", "no receiver yet (#87)"); + m.put("udp", udp); + } else if (!quadEnabled) { + Map single = new LinkedHashMap<>(); + single.put("url", DebugServer.redact(mHost)); + single.put("transport", mTransports[mActive] != null ? mTransports[mActive].name() : null); + single.put("status", statusText != null ? statusText.getText().toString() : null); + single.put("codec", codecH265 ? "H.265" : "H.264"); + single.put("audio", audioCodec); + if (mVideoWidth > 0) single.put("video", mVideoWidth + "x" + mVideoHeight); + m.put("single", single); + } + QuadCell[] cells = quadCells; + if (quadEnabled && cells != null) { + List list = new ArrayList<>(); + for (int i = 0; i < CAM_COUNT; i++) { + if (quadViews[i] == null) continue; + Map c = new LinkedHashMap<>(); + c.put("slot", i + 1); + QuadCell cell = cells[i]; + if (cell == null) { + c.put("text", "not configured"); + } else { + c.put("url", DebugServer.redact(mHosts[i])); + c.put("transport", mTransports[i] != null ? mTransports[i].name() : null); + TextView v = quadStats[i]; + c.put("text", v != null ? v.getText().toString() : null); + c.put("in_fps", cell.inFps()); + c.put("lost_fps", cell.lostFps()); + // Seconds in a row the decoder made this cell lose frames; at + // OVERLOAD_SAMPLES the grid is relieved (#83) + c.put("behind_s", cell.behindSamples); + c.put("paused", cell.isPaused()); + c.put("audio", i == quadAudio); + } + list.add(c); + } + m.put("cells", list); + m.put("overload_after_s", OVERLOAD_SAMPLES); + } + return m; + } + // ====================== Screen / zoom ====================== private static final int REQUEST_STORAGE_FOR_SCREENSHOT = 1; @@ -1189,6 +1302,7 @@ private void stopExecutor() { @Override protected void onDestroy() { super.onDestroy(); + DebugServer.get().clearSource(debugSource); audioManager.shutdown(); } diff --git a/android/app/src/main/java/com/openipc/decoder/LogBuffer.java b/android/app/src/main/java/com/openipc/decoder/LogBuffer.java new file mode 100644 index 00000000..89a08c5f --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/LogBuffer.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * LogBuffer.java — the app's recent log lines in memory, redacted, for /log (#96) + * + */ + +package com.openipc.decoder; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; + +/** + * A bounded ring of log lines. Lines are redacted when they come in, so no login or password + * is kept. Fed by a logcat reader started with the server: without READ_LOGS logcat shows only + * this app's own lines, so every Log call in the code lands here without changing it. + */ +final class LogBuffer { + private static final long RESTART_DELAY_MS = 10_000; + + private final int capacity; + private final ArrayDeque lines; + + LogBuffer(int capacity) { + this.capacity = capacity; + this.lines = new ArrayDeque<>(capacity); + } + + void add(String line) { + String safe = DebugServer.redact(line); + synchronized (lines) { + if (lines.size() == capacity) lines.removeFirst(); + lines.addLast(safe); + } + } + + String text() { + StringBuilder sb = new StringBuilder(); + synchronized (lines) { + for (String line : lines) sb.append(line).append('\n'); + } + return sb.toString(); + } + + /** Follow logcat on a daemon thread for the life of the process; restarted if it ends. */ + void startReader() { + Thread t = new Thread(() -> { + while (true) { + Process p = null; + try { + // No -d: dump what logcat still has, then keep following + p = new ProcessBuilder("logcat", "-v", "threadtime") + .redirectErrorStream(true).start(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) add(line); + } + } catch (IOException e) { + add("Cannot read the log: " + e.getMessage()); + } finally { + if (p != null) p.destroy(); + } + try { + Thread.sleep(RESTART_DELAY_MS); + } catch (InterruptedException e) { + return; + } + } + }, "debug-log"); + t.setDaemon(true); + t.start(); + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/NetInfo.java b/android/app/src/main/java/com/openipc/decoder/NetInfo.java new file mode 100644 index 00000000..2d53a3a1 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/NetInfo.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * NetInfo.java — addresses of this device and the "local network" test for clients + * + */ + +package com.openipc.decoder; + +import android.util.Log; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class NetInfo { + private static final String TAG = "OpenIPCDecoder"; + + private NetInfo() {} + + /** + * IPv4 addresses of every interface that is up, the ones worth showing to a user who has + * to type one in: no loopback (127.0.0.0/8) and no link-local (169.254.0.0/16) (#87, #96). + */ + static List addresses() { + List out = new ArrayList<>(); + try { + for (NetworkInterface nif : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nif.isUp()) continue; + for (InetAddress a : Collections.list(nif.getInetAddresses())) { + if (isShown(a) && !out.contains(a.getHostAddress())) out.add(a.getHostAddress()); + } + } + } catch (Exception e) { + Log.w(TAG, "No local address: " + e.getMessage()); + } + return out; + } + + static boolean isShown(InetAddress a) { + return a instanceof Inet4Address && !a.isLoopbackAddress() && !a.isLinkLocalAddress() + && !a.isAnyLocalAddress(); + } + + /** + * A client on the local network: loopback, private IPv4 (10/8, 172.16/12, 192.168/16), + * link-local (169.254/16, fe80::/10) or IPv6 unique-local (fc00::/7). An IPv4 client on a + * dual-stack socket arrives as ::ffff:a.b.c.d, which Java already turns into IPv4. + * The deprecated IPv6 site-local range (fec0::/10) is not local here. + */ + static boolean isLocalClient(InetAddress a) { + if (a == null) return false; + if (a.isLoopbackAddress() || a.isLinkLocalAddress()) return true; + if (a instanceof Inet4Address) return a.isSiteLocalAddress(); + return a instanceof Inet6Address && (a.getAddress()[0] & 0xfe) == 0xfc; + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/DebugServerTest.java b/android/app/src/test/java/com/openipc/decoder/DebugServerTest.java new file mode 100644 index 00000000..29018fe9 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/DebugServerTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * DebugServerTest.java — debug HTTP server: LAN-only clients, redaction, JSON, requests (#96) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +public class DebugServerTest { + + private static InetAddress ip(String literal) throws Exception { + return InetAddress.getByName(literal); + } + + @Test + public void localClientsAreServed() throws Exception { + for (String a : new String[] {"127.0.0.1", "::1", "10.1.2.3", "172.16.0.1", + "172.31.255.254", "192.168.1.20", "169.254.10.1", "fe80::1", "fd12:3456::1", + "fc00::1", "::ffff:192.168.1.5"}) { + assertTrue(a, NetInfo.isLocalClient(ip(a))); + } + } + + @Test + public void otherClientsAreRejected() throws Exception { + for (String a : new String[] {"8.8.8.8", "172.15.0.1", "172.32.0.1", "100.64.0.1", + "2001:db8::1", "2a00:1450::1", "::ffff:8.8.8.8", "fec0::1"}) { + assertFalse(a, NetInfo.isLocalClient(ip(a))); + } + assertFalse(NetInfo.isLocalClient(null)); + } + + @Test + public void shownAddressesSkipLoopbackAndLinkLocal() throws Exception { + assertTrue(NetInfo.isShown(ip("192.168.1.20"))); + assertTrue(NetInfo.isShown(ip("172.17.32.144"))); + assertTrue(NetInfo.isShown(ip("8.8.8.8"))); + assertFalse(NetInfo.isShown(ip("127.0.0.1"))); + assertFalse(NetInfo.isShown(ip("127.1.2.3"))); + assertFalse(NetInfo.isShown(ip("169.254.1.1"))); + assertFalse(NetInfo.isShown(ip("0.0.0.0"))); + assertFalse(NetInfo.isShown(ip("fd12::1"))); + } + + @Test + public void credentialsAreRedacted() { + String r = DebugServer.redact("rtsp://admin:secret@192.168.1.10:554/stream=0"); + assertFalse(r, r.contains("secret")); + assertFalse(r, r.contains("admin")); + assertTrue(r, r.contains("192.168.1.10:554/stream=0")); + + r = DebugServer.redact("http://cam/onvif?user=admin&password=secret"); + assertFalse(r, r.contains("secret")); + assertFalse(r, r.contains("admin")); + + r = DebugServer.redact("rtsp://172.17.32.53/user=admin_password=12345_channel=1_stream=0.sdp"); + assertEquals("rtsp://172.17.32.53/user=***_password=***_channel=1_stream=0.sdp", r); + assertEquals("login=*** username=***", DebugServer.redact("login=root username=root")); + + r = DebugServer.redact("D OpenIPCDecoder: > Authorization: Basic cm9vdDoxMjM0NQ=="); + assertFalse(r, r.contains("cm9vdDoxMjM0NQ")); + assertTrue(r, r.contains("Authorization: Basic ***")); + + r = DebugServer.redact("Authorization: Digest username=\"root\", realm=\"x\", response=\"ab\""); + assertFalse(r, r.contains("root")); + assertFalse(r, r.contains("response")); + + assertNull(DebugServer.redact(null)); + assertEquals("udp://@:5600", DebugServer.redact("udp://@:5600")); + } + + @Test + public void jsonShape() { + Map cell = new LinkedHashMap<>(); + cell.put("slot", 1); + cell.put("in_fps", 24.5f); + cell.put("paused", false); + cell.put("text", "1920×1080 \"H.264\"\n"); + Map m = new LinkedHashMap<>(); + m.put("mode", "multi-view"); + m.put("uptime_s", 42L); + m.put("none", null); + m.put("nan", Float.NaN); + m.put("cells", Arrays.asList(cell)); + assertEquals("{\"mode\":\"multi-view\",\"uptime_s\":42,\"none\":null,\"nan\":null," + + "\"cells\":[{\"slot\":1,\"in_fps\":24.50,\"paused\":false," + + "\"text\":\"1920×1080 \\\"H.264\\\"\\n\\u003cb\\u003e\"}]}", + DebugServer.toJson(m)); + } + + @Test + public void logBufferKeepsTheLastLinesRedacted() { + LogBuffer log = new LogBuffer(3); + log.add("one"); + log.add("rtsp://root:12345@cam/stream=0"); + log.add("three"); + log.add("four"); + String text = log.text(); + assertFalse(text, text.contains("one")); + assertFalse(text, text.contains("12345")); + assertTrue(text, text.contains("rtsp://***@cam/stream=0\nthree\nfour\n")); + } + + private static String head(String raw) throws IOException { + return DebugServer.readRequestHead( + new ByteArrayInputStream(raw.getBytes(StandardCharsets.ISO_8859_1))); + } + + @Test + public void requestLine() throws IOException { + assertEquals("GET /stats.json HTTP/1.1", + head("GET /stats.json HTTP/1.1\r\nHost: box\r\nAccept: */*\r\n\r\n")); + assertEquals("GET / HTTP/1.0", head("GET / HTTP/1.0\n\n")); + assertNull(head("")); + assertNull(head("\r\n\r\n")); + } +}