Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
236
.gitignore
vendored
Normal file
236
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
data/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
### Python Patch ###
|
||||||
|
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||||
|
poetry.toml
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# LSP config files
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
|
||||||
|
|
||||||
|
# Local dumps / secrets (keep misc/ tracked only as a parking lot)
|
||||||
|
.DS_Store
|
||||||
|
traffic.jsonl
|
||||||
|
captured/
|
||||||
|
misc/captured/
|
||||||
|
misc/*.txt
|
||||||
|
misc/*.jsonl
|
||||||
|
# rteproxy runtime
|
||||||
|
# rolling live segments
|
||||||
|
streams/
|
||||||
|
|
||||||
|
# Capture artifacts (contain auth tokens)
|
||||||
|
rte_cap.json
|
||||||
|
.last_capture.json
|
||||||
|
start_rteproxy.sh
|
||||||
|
|
||||||
|
|
||||||
|
# Per-run capture + keys (auth tokens)
|
||||||
|
outputs/
|
||||||
|
|
||||||
|
|
||||||
|
# Local tool installs from setup-env.sh
|
||||||
|
bin/
|
||||||
|
.cache/
|
||||||
|
scripts/env.sh
|
||||||
|
|
||||||
|
|
||||||
|
# --- project ---
|
||||||
|
outputs/
|
||||||
|
.cache/
|
||||||
|
bin/*
|
||||||
|
!bin/.gitkeep
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
rte_cap.json
|
||||||
|
.last_capture.json
|
||||||
|
# android MITM binaries (rebuild with proxyctl build)
|
||||||
|
apps/proxy/proxy-android-arm64
|
||||||
|
apps/proxy/rteproxy-android-arm64
|
||||||
|
# local / VPS deploy + operator scripts (test stuff; keep out of git)
|
||||||
|
deploy/
|
||||||
|
scripts/
|
||||||
|
# phone app modules: the Go packages are tracked and compiled into bin/drm.exe,
|
||||||
|
# but most module.yaml files (package ids, Brightcove keys, origins, KIDs,
|
||||||
|
# channel maps) are local only and must never be committed.
|
||||||
|
# Exception: bbc/module.yaml is public (clear streams; package + channel map only).
|
||||||
|
apps/modules/**
|
||||||
|
!apps/modules/README.md
|
||||||
|
!apps/modules/go.mod
|
||||||
|
!apps/modules/go.sum
|
||||||
|
!apps/modules/*/
|
||||||
|
!apps/modules/*/*.go
|
||||||
|
!apps/modules/*/**/*.go
|
||||||
|
!apps/modules/bbc/module.yaml
|
||||||
|
# local forgejo / deploy secrets
|
||||||
|
**/.env
|
||||||
|
**/.push-token
|
||||||
154
README.md
Normal file
154
README.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# DRM-Decryption
|
||||||
|
|
||||||
|
Capture Widevine live sessions from Android streaming apps and restream them.
|
||||||
|
|
||||||
|
Everything is one Go binary — `drm` — with every app module compiled in. Each
|
||||||
|
streaming app (RTE, TG4, …) is a Go package under `apps/modules/`; its secrets and
|
||||||
|
IDs live in a local, gitignored values file that you fill in yourself.
|
||||||
|
|
||||||
|
- **[docs/](docs/)** — architecture, module authoring, per-provider setup
|
||||||
|
- **[docs/quickstart.md](docs/quickstart.md)** — the longer version of what follows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
|
||||||
|
| Need | Why |
|
||||||
|
|---|---|
|
||||||
|
| **Go 1.25+** | builds everything |
|
||||||
|
| **Python 3.10+** | `apps/wvkey/wvkey.py` talks to the Widevine CDM |
|
||||||
|
| **adb** (platform-tools) on `PATH` | only for phone capture |
|
||||||
|
| A `.wvd` Widevine device file | only for fetching keys |
|
||||||
|
|
||||||
|
Phone capture additionally needs a rooted Android device (Magisk) so the MITM CA
|
||||||
|
can be injected into the system trust store. Catalog lookups need no phone.
|
||||||
|
|
||||||
|
### 2. Clone and build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.nulldrm.com/nulldrm/Null-DRM-Official.git
|
||||||
|
cd Null-DRM-Official
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
go -C apps/cli build -o ../../bin/drm .
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
go -C apps/cli build -o ../../bin/drm.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
Check it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm help
|
||||||
|
./bin/drm modules # which app modules this build contains
|
||||||
|
```
|
||||||
|
|
||||||
|
`drm modules` works immediately, but channel lists stay empty until you add
|
||||||
|
values — that is the next step.
|
||||||
|
|
||||||
|
### 3. Python CDM helper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/bin/pip install -r apps/wvkey/requirements.txt # Windows: .venv\Scripts\pip
|
||||||
|
```
|
||||||
|
|
||||||
|
Put your Widevine device file in `data/` (gitignored), e.g. `data/device.wvd`.
|
||||||
|
|
||||||
|
### 4. Fill in a module's values
|
||||||
|
|
||||||
|
Compiled-in module code carries **no** account IDs, policy keys, video IDs,
|
||||||
|
license URLs or key IDs. Those go in a gitignored file per module, which you create:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/modules/tg4/module.yaml
|
||||||
|
apps/modules/rte/module.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Where each value comes from:
|
||||||
|
|
||||||
|
- **[docs/providers/tg4.md](docs/providers/tg4.md)** — Brightcove account ID, policy key, video IDs
|
||||||
|
- **[docs/providers/rte.md](docs/providers/rte.md)** — license URL, origin hosts, channel KIDs
|
||||||
|
- **[docs/capture.md](docs/capture.md)** — how to discover all of it for a *new* app
|
||||||
|
|
||||||
|
Any value can be passed per-run instead of stored:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm catalog --app tg4 --channel ioi --tg4.policy-key 'BCpkAD...'
|
||||||
|
TG4_POLICY_KEY='BCpkAD...' ./bin/drm catalog --app tg4 --channel ioi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run something
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# keys from a public catalog — no phone needed
|
||||||
|
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd
|
||||||
|
|
||||||
|
# phone capture: launch the app, drive it to a channel, grab license + keys
|
||||||
|
./bin/drm proxy build # once: cross-compile the on-device MITM
|
||||||
|
./bin/drm capture --app rte --channel rteone --auto-play --wvd data/device.wvd
|
||||||
|
|
||||||
|
# control plane + dashboard on http://127.0.0.1:8083
|
||||||
|
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||||
|
|
||||||
|
# always-on refresher: watches streamd, re-captures dead channels
|
||||||
|
STREAMD_TOKEN=SECRET ./bin/drm agent run --config apps/agent/agent.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Results land in `outputs/<app>/<timestamp>/`, with `latest/` kept as a copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `drm modules` | list compiled-in app modules, their channels and config source |
|
||||||
|
| `drm capture` | one-shot phone MITM capture → `outputs/<app>/<stamp>/` |
|
||||||
|
| `drm catalog` | resolve a channel from its public catalog, no phone |
|
||||||
|
| `drm serve` | streamd: REST API + dashboard + media supervisor |
|
||||||
|
| `drm agent` | `run` / `status` / `devices` / `enqueue` / `cancel` |
|
||||||
|
| `drm proxy` | `build` / `push` / `install-ca` / `start` / `stop` / `discover` / `pull` |
|
||||||
|
|
||||||
|
Run any of them with `--help`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/cli/ the binary -> bin/drm
|
||||||
|
apps/modules/ app modules: Go (tracked) + module.yaml (gitignored)
|
||||||
|
apps/pkg/ shared, provider-neutral libraries
|
||||||
|
apps/capture/ phone capture command
|
||||||
|
apps/agent/ always-on key refresher
|
||||||
|
apps/streamd/ control plane: API, SQLite, dashboard, supervisor
|
||||||
|
apps/proxy/ MITM host CLI + on-device proxy source
|
||||||
|
apps/wvkey/ wvkey.py — CDM only
|
||||||
|
docs/ architecture, authoring, provider setup
|
||||||
|
data/ secrets: .wvd, CA (gitignored)
|
||||||
|
outputs/ capture sessions (gitignored)
|
||||||
|
bin/ built binaries (gitignored)
|
||||||
|
www/ static site
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go -C apps/pkg test ./...
|
||||||
|
go -C apps/modules test ./...
|
||||||
|
go -C apps/streamd test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests that hit a live origin are behind a build tag, so they stay out of the
|
||||||
|
normal run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go -C apps/modules test -tags live ./rte/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
For use only on services you are authorised to access, with content you have the
|
||||||
|
right to decrypt. [docs/architecture.md](docs/architecture.md) describes what the
|
||||||
|
system does and where it stops.
|
||||||
63
apps/agent/README.md
Normal file
63
apps/agent/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# agent
|
||||||
|
|
||||||
|
Always-on credentials refresher, hosted by the single binary as `drm agent`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/agent/
|
||||||
|
agentcmd/ the command body, imported by apps/cli
|
||||||
|
internal/ poller, durable queue, device pool, worker, ws client
|
||||||
|
agent.yaml config
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Polls streamd (`GET /api/streams`)
|
||||||
|
2. When an **enabled** stream is down/errored or missing `mpd`/`key`, enqueues a job
|
||||||
|
in a durable SQLite queue under `data_dir`
|
||||||
|
3. Claims a free ADB device that **already has** the app installed — it never installs
|
||||||
|
4. Runs a phone capture through the stream's app module
|
||||||
|
5. `POST /api/streams/:id/credentials`
|
||||||
|
6. Force-stops the app and clears the device proxy so the phone is free
|
||||||
|
|
||||||
|
It never runs a downloader. Claims are kept alive by a websocket heartbeat, so they
|
||||||
|
expire by themselves if the agent dies.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — control plane
|
||||||
|
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||||
|
|
||||||
|
# terminal 2 — agent
|
||||||
|
export STREAMD_TOKEN=SECRET
|
||||||
|
./bin/drm agent run --config apps/agent/agent.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm agent status
|
||||||
|
./bin/drm agent devices
|
||||||
|
./bin/drm agent enqueue --stream tg4-ioi --reason manual
|
||||||
|
./bin/drm agent cancel --job 3
|
||||||
|
```
|
||||||
|
|
||||||
|
App modules are compiled into the binary, so there is no `modules_dir` to set. The
|
||||||
|
agent drives whichever module a stream names and contains no provider logic.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`agent.yaml` — `streamd_url`, `token` (or `STREAMD_TOKEN`), `poll_interval_sec`,
|
||||||
|
`workers`, `max_attempts`, `capture_wait_sec`, `devices` (empty = any authorised
|
||||||
|
device), `wvd`, `user_agent`, `agent_id`, `data_dir`.
|
||||||
|
|
||||||
|
`wvd` must be set or key fetches fail.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go -C apps/cli build -o ../../bin/drm .
|
||||||
|
|
||||||
|
# cross-compile
|
||||||
|
GOOS=darwin GOARCH=arm64 go -C apps/cli build -o ../../bin/drm .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full guide: [docs/streamd.md](../../docs/streamd.md)**
|
||||||
18
apps/agent/agent.yaml
Normal file
18
apps/agent/agent.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Always-on credentials agent (apps/agent → bin/agent.exe)
|
||||||
|
# Default path: apps/agent/agent.yaml
|
||||||
|
streamd_url: http://127.0.0.1:8083
|
||||||
|
# Must match streamd --token / STREAMD_TOKEN (mutating API: claim, credentials).
|
||||||
|
token: "SECRET"
|
||||||
|
# Stable id across restarts (default = hostname). Required for reclaim after crash.
|
||||||
|
# agent_id: my-agent
|
||||||
|
poll_interval_sec: 30
|
||||||
|
data_dir: .cache/agent
|
||||||
|
workers: 1 # concurrent ADB jobs
|
||||||
|
max_attempts: 5
|
||||||
|
capture_wait_sec: 180
|
||||||
|
devices: [] # empty = all adb "device" serials; never installs apps
|
||||||
|
# wvd: data/device.wvd # path to .wvd (required for key fetch)
|
||||||
|
# user_agent: "" # optional license-request User-Agent
|
||||||
|
# App modules (rte, tg4, …) are compiled into the binary — there is no
|
||||||
|
# modules directory to point at. Their local values live in
|
||||||
|
# apps/modules/<name>/module.yaml; see apps/modules/README.md.
|
||||||
259
apps/agent/agentcmd/agentcmd.go
Normal file
259
apps/agent/agentcmd/agentcmd.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
// Package agentcmd is the agent CLI, exposed as a library so the single drm
|
||||||
|
// binary can host it as a subcommand.
|
||||||
|
package agentcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"drmdecryption/apps/agent/internal/client"
|
||||||
|
"drmdecryption/apps/agent/internal/config"
|
||||||
|
"drmdecryption/apps/agent/internal/device"
|
||||||
|
"drmdecryption/apps/agent/internal/poller"
|
||||||
|
"drmdecryption/apps/agent/internal/queue"
|
||||||
|
"drmdecryption/apps/agent/internal/worker"
|
||||||
|
"drmdecryption/apps/agent/internal/wsclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run dispatches an agent subcommand. args[0] is the subcommand name.
|
||||||
|
func Run(args []string) error {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||||
|
log.SetPrefix("agent: ")
|
||||||
|
|
||||||
|
if len(args) < 1 {
|
||||||
|
Usage()
|
||||||
|
return fmt.Errorf("agent: subcommand required")
|
||||||
|
}
|
||||||
|
sub, rest := args[0], args[1:]
|
||||||
|
switch sub {
|
||||||
|
case "run":
|
||||||
|
runCmd(sub, rest)
|
||||||
|
case "status":
|
||||||
|
statusCmd(sub, rest)
|
||||||
|
case "devices":
|
||||||
|
devicesCmd(sub, rest)
|
||||||
|
case "enqueue":
|
||||||
|
enqueueCmd(rest)
|
||||||
|
case "cancel":
|
||||||
|
cancelCmd(rest)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
Usage()
|
||||||
|
default:
|
||||||
|
Usage()
|
||||||
|
return fmt.Errorf("agent: unknown subcommand %q", sub)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints the agent subcommand help.
|
||||||
|
func Usage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `agent — always-on credentials refresher (queue + ADB)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
agent run [--config apps/agent/agent.yaml]
|
||||||
|
agent status [--config ...]
|
||||||
|
agent devices [--config ...]
|
||||||
|
agent enqueue --stream NAME [--reason manual] [--config ...]
|
||||||
|
agent cancel --job ID [--config ...]
|
||||||
|
|
||||||
|
Polls streamd for enabled streams that are down / missing credentials,
|
||||||
|
enqueues jobs, claims a free phone that already has the app, captures
|
||||||
|
via MITM, POSTs credentials, then force-stops the app.
|
||||||
|
|
||||||
|
Does not install apps. Does not run NRE/ffmpeg.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCfg(sub string, args []string) (config.Config, *flag.FlagSet) {
|
||||||
|
fs := flag.NewFlagSet(sub, flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg, fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func openQueue(cfg config.Config) *queue.Store {
|
||||||
|
q, err := queue.Open(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("queue: %v", err)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCmd(sub string, args []string) {
|
||||||
|
cfg, _ := loadCfg(sub, args)
|
||||||
|
api := client.New(cfg.StreamdURL, cfg.Token)
|
||||||
|
if err := api.Health(); err != nil {
|
||||||
|
log.Fatalf("streamd unreachable at %s: %v", cfg.StreamdURL, err)
|
||||||
|
}
|
||||||
|
q := openQueue(cfg)
|
||||||
|
defer q.Close()
|
||||||
|
|
||||||
|
if n, err := q.RecoverStaleRunning(); err != nil {
|
||||||
|
log.Fatalf("recover jobs: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("re-queued %d stuck running job(s) from previous run", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := device.New("", cfg.Devices)
|
||||||
|
stop := make(chan struct{})
|
||||||
|
|
||||||
|
p := &poller.Poller{
|
||||||
|
API: api,
|
||||||
|
Queue: q,
|
||||||
|
MaxAttempts: cfg.MaxAttempts,
|
||||||
|
Interval: cfg.PollInterval(),
|
||||||
|
Log: log.Default(),
|
||||||
|
}
|
||||||
|
go p.Loop(stop)
|
||||||
|
|
||||||
|
ws := wsclient.New(cfg.StreamdURL, cfg.Token, cfg.AgentID, log.Default())
|
||||||
|
go ws.Loop()
|
||||||
|
|
||||||
|
for i := 0; i < cfg.Workers; i++ {
|
||||||
|
w := &worker.Worker{
|
||||||
|
ID: i + 1,
|
||||||
|
API: api,
|
||||||
|
Queue: q,
|
||||||
|
Pool: pool,
|
||||||
|
AgentID: cfg.AgentID,
|
||||||
|
CaptureWait: cfg.CaptureWait(),
|
||||||
|
Python: cfg.Python,
|
||||||
|
WVD: cfg.WVD,
|
||||||
|
UserAgent: cfg.UserAgent,
|
||||||
|
Log: log.Default(),
|
||||||
|
}
|
||||||
|
go w.Loop(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("running agent_id=%s streamd=%s data=%s workers=%d poll=%s",
|
||||||
|
cfg.AgentID, cfg.StreamdURL, cfg.DataDir, cfg.Workers, cfg.PollInterval())
|
||||||
|
log.Printf("ws heartbeat enabled — claims auto-expire if this agent dies")
|
||||||
|
log.Printf("waiting for down / missing-credential streams… Ctrl+C to stop")
|
||||||
|
|
||||||
|
ch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-ch
|
||||||
|
log.Printf("shutting down…")
|
||||||
|
ws.Stop()
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCmd(sub string, args []string) {
|
||||||
|
cfg, _ := loadCfg(sub, args)
|
||||||
|
q := openQueue(cfg)
|
||||||
|
defer q.Close()
|
||||||
|
jobs, err := q.ListRecent(40)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "ID\tSTREAM\tAPP\tCHANNEL\tSTATUS\tATTEMPTS\tDEVICE\tREASON\tERROR")
|
||||||
|
for _, j := range jobs {
|
||||||
|
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%d/%d\t%s\t%s\t%s\n",
|
||||||
|
j.ID, j.StreamName, j.App, j.Channel, j.Status, j.Attempts, j.MaxAttempts,
|
||||||
|
j.DeviceSerial, j.Reason, trunc(j.LastError, 60))
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func devicesCmd(sub string, args []string) {
|
||||||
|
cfg, _ := loadCfg(sub, args)
|
||||||
|
pool := device.New("", cfg.Devices)
|
||||||
|
list, err := pool.List()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
fmt.Println("no adb devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "SERIAL\tSTATE\tMODEL\tALLOWED\tBUSY\tBUSY_FOR")
|
||||||
|
for _, d := range list {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%v\t%s\n",
|
||||||
|
d.Serial, d.State, d.Model, d.Allowed, d.Busy, d.BusyFor)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("enqueue", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||||
|
name := fs.String("stream", "", "streamd stream name")
|
||||||
|
reason := fs.String("reason", "manual", "job reason")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
if *name == "" {
|
||||||
|
log.Fatal("--stream required")
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
api := client.New(cfg.StreamdURL, cfg.Token)
|
||||||
|
streams, err := api.ListStreams()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
var st *client.Stream
|
||||||
|
for i := range streams {
|
||||||
|
if streams[i].Name == *name {
|
||||||
|
st = &streams[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st == nil {
|
||||||
|
log.Fatalf("stream %q not found on streamd", *name)
|
||||||
|
}
|
||||||
|
q := openQueue(cfg)
|
||||||
|
defer q.Close()
|
||||||
|
j, added, err := q.EnqueueIfIdle(st.ID, st.Name, st.App, st.Channel, *reason, cfg.MaxAttempts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if !added {
|
||||||
|
fmt.Printf("already has queued/running job for %s\n", st.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("enqueued job=%d stream=%s app=%s channel=%s\n", j.ID, j.StreamName, j.App, j.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("cancel", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||||
|
jobID := fs.String("job", "", "job id")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
if *jobID == "" {
|
||||||
|
log.Fatal("--job required")
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(*jobID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
q := openQueue(cfg)
|
||||||
|
defer q.Close()
|
||||||
|
if err := q.Cancel(id); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("cancelled", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
25
apps/agent/go.mod
Normal file
25
apps/agent/go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
module drmdecryption/apps/agent
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
drmdecryption v0.0.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
55
apps/agent/go.sum
Normal file
55
apps/agent/go.sum
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac h1:pNA9PRXGPFURORAsI3IqfQJ4RLsRXqghCFdvypd/4GY=
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
118
apps/agent/internal/client/client.go
Normal file
118
apps/agent/internal/client/client.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
UserAgent: "drm-agent/1.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
PlaylistAgeS float64 `json:"playlist_age_s"`
|
||||||
|
ClaimedBy string `json:"claimed_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
PSSH string `json:"pssh,omitempty"`
|
||||||
|
Auth string `json:"auth,omitempty"`
|
||||||
|
PID string `json:"pid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Health() error {
|
||||||
|
_, err := c.do(http.MethodGet, "/api/health", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListStreams() ([]Stream, error) {
|
||||||
|
body, err := c.do(http.MethodGet, "/api/streams", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var wrap struct {
|
||||||
|
Streams []Stream `json:"streams"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrap.Streams, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Claim(id int64, agentID string, ttlSec int) error {
|
||||||
|
payload := map[string]any{"agent_id": agentID, "ttl_sec": ttlSec}
|
||||||
|
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/claim", id), payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseClaim(id int64, agentID string) error {
|
||||||
|
payload := map[string]any{"agent_id": agentID}
|
||||||
|
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/claim/release", id), payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PostCredentials(id int64, cred Credentials) error {
|
||||||
|
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/credentials", id), cred)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(method, path string, payload any) ([]byte, error) {
|
||||||
|
var rdr io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rdr = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, c.BaseURL+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", c.UserAgent)
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("%s %s: %s — %s", method, path, resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
104
apps/agent/internal/config/config.go
Normal file
104
apps/agent/internal/config/config.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"drmdecryption/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
StreamdURL string `yaml:"streamd_url"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
PollIntervalSec int `yaml:"poll_interval_sec"`
|
||||||
|
DataDir string `yaml:"data_dir"`
|
||||||
|
Workers int `yaml:"workers"`
|
||||||
|
MaxAttempts int `yaml:"max_attempts"`
|
||||||
|
CaptureWaitSec int `yaml:"capture_wait_sec"`
|
||||||
|
Devices []string `yaml:"devices"`
|
||||||
|
Python string `yaml:"python"`
|
||||||
|
WVD string `yaml:"wvd"` // path to .wvd device file (required for key fetch)
|
||||||
|
UserAgent string `yaml:"user_agent"`
|
||||||
|
AgentID string `yaml:"agent_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) PollInterval() time.Duration {
|
||||||
|
if c.PollIntervalSec <= 0 {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.PollIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) CaptureWait() time.Duration {
|
||||||
|
if c.CaptureWaitSec <= 0 {
|
||||||
|
return 180 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.CaptureWaitSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (Config, error) {
|
||||||
|
root := repo.Root()
|
||||||
|
if path == "" {
|
||||||
|
path = filepath.Join(root, "apps", "agent", "agent.yaml")
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
cfg = defaults(root)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg = applyDefaults(cfg, root)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaults(root string) Config {
|
||||||
|
return applyDefaults(Config{}, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg Config, root string) Config {
|
||||||
|
if cfg.StreamdURL == "" {
|
||||||
|
cfg.StreamdURL = "http://127.0.0.1:8083"
|
||||||
|
}
|
||||||
|
if cfg.Token == "" {
|
||||||
|
cfg.Token = os.Getenv("STREAMD_TOKEN")
|
||||||
|
}
|
||||||
|
if cfg.PollIntervalSec <= 0 {
|
||||||
|
cfg.PollIntervalSec = 30
|
||||||
|
}
|
||||||
|
if cfg.DataDir == "" {
|
||||||
|
cfg.DataDir = filepath.Join(root, ".cache", "agent")
|
||||||
|
} else if !filepath.IsAbs(cfg.DataDir) {
|
||||||
|
cfg.DataDir = filepath.Join(root, cfg.DataDir)
|
||||||
|
}
|
||||||
|
if cfg.Workers <= 0 {
|
||||||
|
cfg.Workers = 1
|
||||||
|
}
|
||||||
|
if cfg.MaxAttempts <= 0 {
|
||||||
|
cfg.MaxAttempts = 5
|
||||||
|
}
|
||||||
|
if cfg.CaptureWaitSec <= 0 {
|
||||||
|
cfg.CaptureWaitSec = 180
|
||||||
|
}
|
||||||
|
if cfg.AgentID == "" {
|
||||||
|
// Stable across restarts so the same agent can reclaim after a crash.
|
||||||
|
// Override in apps/agent/agent.yaml when running multiple agents.
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
host = "agent"
|
||||||
|
}
|
||||||
|
cfg.AgentID = host
|
||||||
|
}
|
||||||
|
if cfg.WVD != "" && !filepath.IsAbs(cfg.WVD) {
|
||||||
|
cfg.WVD = filepath.Join(root, cfg.WVD)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
118
apps/agent/internal/device/pool.go
Normal file
118
apps/agent/internal/device/pool.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool tracks free/busy ADB devices. Never installs apps.
|
||||||
|
type Pool struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
adbBin string
|
||||||
|
allow map[string]bool // empty allow = all serials
|
||||||
|
busy map[string]string // serial → job label
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(adbBin string, allowlist []string) *Pool {
|
||||||
|
p := &Pool{
|
||||||
|
adbBin: adbBin,
|
||||||
|
allow: map[string]bool{},
|
||||||
|
busy: map[string]string{},
|
||||||
|
}
|
||||||
|
for _, s := range allowlist {
|
||||||
|
if s != "" {
|
||||||
|
p.allow[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Busy bool `json:"busy"`
|
||||||
|
BusyFor string `json:"busy_for,omitempty"`
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) List() ([]Info, error) {
|
||||||
|
devs, err := adb.ListDevices(p.adbBin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := make([]Info, 0, len(devs))
|
||||||
|
for _, d := range devs {
|
||||||
|
allowed := len(p.allow) == 0 || p.allow[d.Serial]
|
||||||
|
busyFor, busy := p.busy[d.Serial]
|
||||||
|
out = append(out, Info{
|
||||||
|
Serial: d.Serial,
|
||||||
|
State: d.State,
|
||||||
|
Model: d.Model,
|
||||||
|
Busy: busy,
|
||||||
|
BusyFor: busyFor,
|
||||||
|
Allowed: allowed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire finds a free device in state "device" that already has pkg installed.
|
||||||
|
// Returns ErrWait if none available (do not burn job attempts).
|
||||||
|
func (p *Pool) Acquire(pkg, jobLabel string) (*adb.Client, string, error) {
|
||||||
|
devs, err := adb.ListDevices(p.adbBin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
base := adb.New()
|
||||||
|
if p.adbBin != "" {
|
||||||
|
base.Bin = p.adbBin
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
var lastMiss string
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.State != "device" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p.allow) > 0 && !p.allow[d.Serial] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, busy := p.busy[d.Serial]; busy {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := base.WithSerial(d.Serial)
|
||||||
|
if pkg != "" && !c.PackageInstalled(pkg) {
|
||||||
|
lastMiss = fmt.Sprintf("%s missing package %s", d.Serial, pkg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.busy[d.Serial] = jobLabel
|
||||||
|
return c, d.Serial, nil
|
||||||
|
}
|
||||||
|
if lastMiss != "" {
|
||||||
|
return nil, "", &WaitError{Msg: lastMiss}
|
||||||
|
}
|
||||||
|
return nil, "", &WaitError{Msg: "no free adb device"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Release(serial string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
delete(p.busy, serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitError means the job should stay queued without consuming an attempt.
|
||||||
|
type WaitError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *WaitError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
func IsWait(err error) bool {
|
||||||
|
_, ok := err.(*WaitError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
111
apps/agent/internal/poller/poller.go
Normal file
111
apps/agent/internal/poller/poller.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/apps/agent/internal/client"
|
||||||
|
"drmdecryption/apps/agent/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Poller struct {
|
||||||
|
API *client.Client
|
||||||
|
Queue *queue.Store
|
||||||
|
MaxAttempts int
|
||||||
|
Interval time.Duration
|
||||||
|
Log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Once() error {
|
||||||
|
streams, err := p.API.ListStreams()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, st := range streams {
|
||||||
|
if !st.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reason := needsRefresh(st)
|
||||||
|
if reason == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j, added, err := p.Queue.EnqueueIfIdle(st.ID, st.Name, st.App, st.Channel, reason, p.MaxAttempts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if added {
|
||||||
|
p.logf("enqueued job=%d stream=%s reason=%s", j.ID, st.Name, reason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if active, ok, err := p.Queue.ActiveJobForStream(st.ID); err == nil && ok {
|
||||||
|
extra := ""
|
||||||
|
if active.Status == queue.StatusQueued && !active.NotBefore.IsZero() && active.NotBefore.After(time.Now().UTC()) {
|
||||||
|
extra = fmt.Sprintf(" backoff_until=%s", active.NotBefore.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
p.logf("stream %s needs %s but job=%d already %s (attempts %d/%d)%s",
|
||||||
|
st.Name, reason, active.ID, active.Status, active.Attempts, active.MaxAttempts, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) Loop(stop <-chan struct{}) {
|
||||||
|
t := time.NewTicker(p.Interval)
|
||||||
|
defer t.Stop()
|
||||||
|
_ = p.Once()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := p.Once(); err != nil {
|
||||||
|
p.logf("poll error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRefresh(st client.Stream) string {
|
||||||
|
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
|
||||||
|
return "missing_credentials"
|
||||||
|
}
|
||||||
|
if !validKeyPair(st.Key) {
|
||||||
|
return "invalid_credentials"
|
||||||
|
}
|
||||||
|
h := strings.ToLower(strings.TrimSpace(st.Health))
|
||||||
|
switch h {
|
||||||
|
case "down", "error", "failed":
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validKeyPair accepts KID:KEY as 32-hex:32-hex (colons/dashes in KID ignored).
|
||||||
|
func validKeyPair(key string) bool {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(key), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hexOnly := func(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
kid, k := hexOnly(parts[0]), hexOnly(parts[1])
|
||||||
|
return len(kid) == 32 && len(k) == 32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Poller) logf(format string, args ...any) {
|
||||||
|
if p.Log != nil {
|
||||||
|
p.Log.Printf(format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
334
apps/agent/internal/queue/queue.go
Normal file
334
apps/agent/internal/queue/queue.go
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusQueued Status = "queued"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusOK Status = "ok"
|
||||||
|
StatusFailed Status = "failed"
|
||||||
|
StatusCancel Status = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID int64
|
||||||
|
StreamID int64
|
||||||
|
StreamName string
|
||||||
|
App string
|
||||||
|
Channel string
|
||||||
|
Reason string
|
||||||
|
Status Status
|
||||||
|
Priority int
|
||||||
|
Attempts int
|
||||||
|
MaxAttempts int
|
||||||
|
DeviceSerial string
|
||||||
|
LastError string
|
||||||
|
NotBefore time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dataDir string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, "agent.db")
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", filepath.ToSlash(path))
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
s := &Store{DB: db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error { return s.DB.Close() }
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.DB.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stream_id INTEGER NOT NULL,
|
||||||
|
stream_name TEXT NOT NULL DEFAULT '',
|
||||||
|
app TEXT NOT NULL DEFAULT '',
|
||||||
|
channel TEXT NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||||
|
device_serial TEXT NOT NULL DEFAULT '',
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
not_before TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL DEFAULT '',
|
||||||
|
finished_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, priority, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_stream ON jobs(stream_id, status);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||||
|
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
t, _ := time.Parse(time.RFC3339, s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueIfIdle inserts a job unless one is already queued/running for the stream.
|
||||||
|
func (s *Store) EnqueueIfIdle(streamID int64, name, app, channel, reason string, maxAttempts int) (Job, bool, error) {
|
||||||
|
var existing int
|
||||||
|
err := s.DB.QueryRow(`
|
||||||
|
SELECT COUNT(1) FROM jobs WHERE stream_id=? AND status IN ('queued','running')`, streamID).Scan(&existing)
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
return Job{}, false, nil
|
||||||
|
}
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
maxAttempts = 5
|
||||||
|
}
|
||||||
|
res, err := s.DB.Exec(`
|
||||||
|
INSERT INTO jobs(stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,created_at,not_before)
|
||||||
|
VALUES(?,?,?,?,?,'queued',100,0,?,?,?)`,
|
||||||
|
streamID, name, app, channel, reason, maxAttempts, now(), now())
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
j, err := s.Get(id)
|
||||||
|
return j, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(id int64) (Job, error) {
|
||||||
|
row := s.DB.QueryRow(`
|
||||||
|
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||||
|
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||||
|
FROM jobs WHERE id=?`, id)
|
||||||
|
return scanJob(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scannable interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanJob(row scannable) (Job, error) {
|
||||||
|
var j Job
|
||||||
|
var status, nb, ca, sa, fa string
|
||||||
|
err := row.Scan(
|
||||||
|
&j.ID, &j.StreamID, &j.StreamName, &j.App, &j.Channel, &j.Reason, &status,
|
||||||
|
&j.Priority, &j.Attempts, &j.MaxAttempts, &j.DeviceSerial, &j.LastError,
|
||||||
|
&nb, &ca, &sa, &fa,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return j, err
|
||||||
|
}
|
||||||
|
j.Status = Status(status)
|
||||||
|
j.NotBefore = parseTime(nb)
|
||||||
|
j.CreatedAt = parseTime(ca)
|
||||||
|
j.StartedAt = parseTime(sa)
|
||||||
|
j.FinishedAt = parseTime(fa)
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimNext marks the next ready queued job as running. Returns false if none.
|
||||||
|
func (s *Store) ClaimNext() (Job, bool, error) {
|
||||||
|
tx, err := s.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
nowStr := now()
|
||||||
|
row := tx.QueryRow(`
|
||||||
|
SELECT id FROM jobs
|
||||||
|
WHERE status='queued' AND (not_before='' OR not_before<=?)
|
||||||
|
ORDER BY priority ASC, id ASC
|
||||||
|
LIMIT 1`, nowStr)
|
||||||
|
var id int64
|
||||||
|
if err := row.Scan(&id); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Job{}, false, nil
|
||||||
|
}
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`UPDATE jobs SET status='running', started_at=?, last_error='' WHERE id=? AND status='queued'`, nowStr, id)
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
j, err := s.Get(id)
|
||||||
|
return j, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetDevice(id int64, serial string) error {
|
||||||
|
_, err := s.DB.Exec(`UPDATE jobs SET device_serial=? WHERE id=?`, serial, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkOK(id int64) error {
|
||||||
|
_, err := s.DB.Exec(`UPDATE jobs SET status='ok', finished_at=?, last_error='' WHERE id=?`, now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailed increments attempts. Re-queues with backoff unless maxed out.
|
||||||
|
// waitingDevice=true keeps status queued without burning an attempt.
|
||||||
|
func (s *Store) MarkFailed(id int64, msg string, waitingDevice bool) error {
|
||||||
|
j, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if waitingDevice {
|
||||||
|
_, err = s.DB.Exec(`
|
||||||
|
UPDATE jobs SET status='queued', device_serial='', last_error=?, started_at='' WHERE id=?`,
|
||||||
|
msg, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attempts := j.Attempts + 1
|
||||||
|
if attempts >= j.MaxAttempts {
|
||||||
|
_, err = s.DB.Exec(`
|
||||||
|
UPDATE jobs SET status='failed', attempts=?, last_error=?, finished_at=? WHERE id=?`,
|
||||||
|
attempts, msg, now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
backoff := time.Duration(1<<uint(min(attempts, 4))) * time.Minute
|
||||||
|
if backoff > 15*time.Minute {
|
||||||
|
backoff = 15 * time.Minute
|
||||||
|
}
|
||||||
|
nb := time.Now().UTC().Add(backoff).Format(time.RFC3339)
|
||||||
|
_, err = s.DB.Exec(`
|
||||||
|
UPDATE jobs SET status='queued', attempts=?, last_error=?, not_before=?, device_serial='', started_at='' WHERE id=?`,
|
||||||
|
attempts, msg, nb, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Cancel(id int64) error {
|
||||||
|
_, err := s.DB.Exec(`
|
||||||
|
UPDATE jobs SET status='cancelled', finished_at=? WHERE id=? AND status IN ('queued','running')`, now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListRecent(limit int) ([]Job, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
rows, err := s.DB.Query(`
|
||||||
|
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||||
|
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||||
|
FROM jobs ORDER BY id DESC LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Job
|
||||||
|
for rows.Next() {
|
||||||
|
j, err := scanJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, j)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ActiveForStream(streamID int64) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := s.DB.QueryRow(`SELECT COUNT(1) FROM jobs WHERE stream_id=? AND status IN ('queued','running')`, streamID).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverStaleRunning re-queues jobs left in status=running after a crash/kill
|
||||||
|
// so a down stream is not blocked forever by EnqueueIfIdle.
|
||||||
|
func (s *Store) RecoverStaleRunning() (int, error) {
|
||||||
|
nowStr := now()
|
||||||
|
res, err := s.DB.Exec(`
|
||||||
|
UPDATE jobs SET status='queued', device_serial='', started_at='',
|
||||||
|
last_error='recovered after agent restart', not_before=?
|
||||||
|
WHERE status='running'`, nowStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextDelayed returns the soonest queued job that is still waiting on not_before.
|
||||||
|
func (s *Store) NextDelayed() (Job, time.Duration, bool, error) {
|
||||||
|
nowStr := now()
|
||||||
|
row := s.DB.QueryRow(`
|
||||||
|
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||||
|
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status='queued' AND not_before!='' AND not_before>?
|
||||||
|
ORDER BY not_before ASC LIMIT 1`, nowStr)
|
||||||
|
j, err := scanJob(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Job{}, 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, 0, false, err
|
||||||
|
}
|
||||||
|
until := time.Until(j.NotBefore)
|
||||||
|
if until < 0 {
|
||||||
|
until = 0
|
||||||
|
}
|
||||||
|
return j, until, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearBackoff makes a queued job runnable immediately (e.g. after fixing auth).
|
||||||
|
func (s *Store) ClearBackoff(id int64) error {
|
||||||
|
_, err := s.DB.Exec(`UPDATE jobs SET not_before=? WHERE id=? AND status='queued'`, now(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveJobForStream returns the queued/running job for a stream, if any.
|
||||||
|
func (s *Store) ActiveJobForStream(streamID int64) (Job, bool, error) {
|
||||||
|
row := s.DB.QueryRow(`
|
||||||
|
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||||
|
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||||
|
FROM jobs WHERE stream_id=? AND status IN ('queued','running')
|
||||||
|
ORDER BY id DESC LIMIT 1`, streamID)
|
||||||
|
j, err := scanJob(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Job{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Job{}, false, err
|
||||||
|
}
|
||||||
|
return j, true, nil
|
||||||
|
}
|
||||||
149
apps/agent/internal/worker/worker.go
Normal file
149
apps/agent/internal/worker/worker.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/phonecap"
|
||||||
|
|
||||||
|
"drmdecryption/apps/agent/internal/client"
|
||||||
|
"drmdecryption/apps/agent/internal/device"
|
||||||
|
"drmdecryption/apps/agent/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Worker struct {
|
||||||
|
ID int
|
||||||
|
API *client.Client
|
||||||
|
Queue *queue.Store
|
||||||
|
Pool *device.Pool
|
||||||
|
AgentID string
|
||||||
|
CaptureWait time.Duration
|
||||||
|
Python string
|
||||||
|
WVD string
|
||||||
|
UserAgent string
|
||||||
|
Log *log.Logger
|
||||||
|
|
||||||
|
lastDelayLog time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) Loop(stop <-chan struct{}) {
|
||||||
|
t := time.NewTicker(2 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) tick() {
|
||||||
|
job, ok, err := w.Queue.ClaimNext()
|
||||||
|
if err != nil {
|
||||||
|
w.logf("claim next: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if j, until, found, err := w.Queue.NextDelayed(); err == nil && found {
|
||||||
|
if time.Since(w.lastDelayLog) > 30*time.Second {
|
||||||
|
w.lastDelayLog = time.Now()
|
||||||
|
w.logf("no runnable jobs; next is job=%d stream=%s in %s (backoff)",
|
||||||
|
j.ID, j.StreamName, until.Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.logf("job=%d running stream=%s app=%s channel=%s reason=%s",
|
||||||
|
job.ID, job.StreamName, job.App, job.Channel, job.Reason)
|
||||||
|
if err := w.runJob(job); err != nil {
|
||||||
|
if device.IsWait(err) || isAuthErr(err) || isUnknownAppErr(err) {
|
||||||
|
// Config/environment issues — do not burn attempts or multi-minute backoff.
|
||||||
|
w.logf("job=%d waiting: %v", job.ID, err)
|
||||||
|
_ = w.Queue.MarkFailed(job.ID, err.Error(), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.logf("job=%d failed: %v", job.ID, err)
|
||||||
|
_ = w.Queue.MarkFailed(job.ID, err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = w.Queue.MarkOK(job.ID)
|
||||||
|
w.logf("job=%d ok stream=%s", job.ID, job.StreamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "401") || strings.Contains(s, "unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnknownAppErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(s, "unknown app")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) runJob(job queue.Job) error {
|
||||||
|
a, err := app.Get(job.App)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("job-%d:%s", job.ID, job.StreamName)
|
||||||
|
c, serial, err := w.Pool.Acquire(a.Package(), label)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.Pool.Release(serial)
|
||||||
|
_ = w.Queue.SetDevice(job.ID, serial)
|
||||||
|
w.logf("job=%d device=%s package=%s", job.ID, serial, a.Package())
|
||||||
|
|
||||||
|
// Short TTL; /ws/agent heartbeats renew while this process is alive.
|
||||||
|
if err := w.API.Claim(job.StreamID, w.AgentID, 90); err != nil {
|
||||||
|
return fmt.Errorf("streamd claim: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = w.API.ReleaseClaim(job.StreamID, w.AgentID) }()
|
||||||
|
|
||||||
|
res, err := phonecap.Run(phonecap.Options{
|
||||||
|
App: a,
|
||||||
|
Channel: job.Channel,
|
||||||
|
Client: c,
|
||||||
|
Python: w.Python,
|
||||||
|
WVD: w.WVD,
|
||||||
|
UserAgent: w.UserAgent,
|
||||||
|
Wait: w.CaptureWait,
|
||||||
|
CloseApp: true,
|
||||||
|
ClearProxy: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := client.Credentials{
|
||||||
|
MPD: res.MPD,
|
||||||
|
Key: res.Key,
|
||||||
|
PSSH: res.Session.PSSH,
|
||||||
|
Auth: res.Session.Auth,
|
||||||
|
PID: res.Session.PID,
|
||||||
|
}
|
||||||
|
if err := w.API.PostCredentials(job.StreamID, cred); err != nil {
|
||||||
|
return fmt.Errorf("post credentials: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) logf(format string, args ...any) {
|
||||||
|
prefix := fmt.Sprintf("worker/%d: ", w.ID)
|
||||||
|
if w.Log != nil {
|
||||||
|
w.Log.Printf(prefix+format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf(prefix+format, args...)
|
||||||
|
}
|
||||||
148
apps/agent/internal/wsclient/wsclient.go
Normal file
148
apps/agent/internal/wsclient/wsclient.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package wsclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client keeps a streamd /ws/agent connection alive and sends heartbeats
|
||||||
|
// so claims auto-renew; on disconnect streamd releases this agent's claims.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
AgentID string
|
||||||
|
Log *log.Logger
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(baseURL, token, agentID string, lg *log.Logger) *Client {
|
||||||
|
if lg == nil {
|
||||||
|
lg = log.Default()
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Token: token,
|
||||||
|
AgentID: agentID,
|
||||||
|
Log: lg,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() { close(c.stop) }
|
||||||
|
|
||||||
|
// Loop reconnects forever until Stop.
|
||||||
|
func (c *Client) Loop() {
|
||||||
|
backoff := time.Second
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := c.session(); err != nil {
|
||||||
|
c.Log.Printf("ws: session ended: %v — retry in %s", err, backoff)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 30*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) session() error {
|
||||||
|
u, err := url.Parse(c.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
u.Scheme = "wss"
|
||||||
|
default:
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
u.Path = "/ws/agent"
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("agent_id", c.AgentID)
|
||||||
|
if c.Token != "" {
|
||||||
|
q.Set("token", c.Token)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
hdr := http.Header{}
|
||||||
|
if c.Token != "" {
|
||||||
|
hdr.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||||
|
conn, _, err := dialer.Dial(u.String(), hdr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
c.Log.Printf("ws: connected to %s as %s", u.Host, c.AgentID)
|
||||||
|
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
var msg map[string]any
|
||||||
|
if json.Unmarshal(data, &msg) == nil {
|
||||||
|
if t, _ := msg["type"].(string); t == "heartbeat_ack" {
|
||||||
|
if ok, _ := msg["ok"].(bool); !ok {
|
||||||
|
c.Log.Printf("ws: heartbeat nack: %v", msg["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(20 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
// Immediate heartbeat so existing claims renew right away on reconnect.
|
||||||
|
if err := c.writeHeartbeat(conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
_ = conn.WriteJSON(map[string]string{"type": "release_all"})
|
||||||
|
return nil
|
||||||
|
case <-done:
|
||||||
|
return fmt.Errorf("read closed")
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.writeHeartbeat(conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeHeartbeat(conn *websocket.Conn) error {
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
return conn.WriteJSON(map[string]string{
|
||||||
|
"type": "heartbeat",
|
||||||
|
"agent_id": c.AgentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
24
apps/capture/README.md
Normal file
24
apps/capture/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# capture
|
||||||
|
|
||||||
|
One-shot phone MITM capture, hosted by the single binary as `drm capture`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/capture/
|
||||||
|
capturecmd/ the command body, imported by apps/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm capture # passive: play anything
|
||||||
|
./bin/drm capture --app rte # launch, navigate by hand
|
||||||
|
./bin/drm capture --app rte --channel rteone --auto-play \
|
||||||
|
--wvd data/device.wvd # fully automated
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes `outputs/<app>/<stamp>/` plus `latest/`. App modules are compiled in, so
|
||||||
|
there is no modules directory to point at; `drm modules` lists what is available.
|
||||||
|
|
||||||
|
**Full guide: [docs/capture.md](../../docs/capture.md)** — device setup, flags,
|
||||||
|
discovering a new app, and troubleshooting.
|
||||||
|
|
||||||
|
The catalog lookup that used to be a separate `tg4info` binary is now the
|
||||||
|
provider-agnostic `drm catalog` (`apps/cli/catalogcmd`).
|
||||||
99
apps/capture/capturecmd/capturecmd.go
Normal file
99
apps/capture/capturecmd/capturecmd.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Package capturecmd is the one-shot phone MITM capture command, exposed as a
|
||||||
|
// library so the single drm binary can host it as a subcommand.
|
||||||
|
package capturecmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/phonecap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run performs one capture. App modules are compiled in, so there is no modules
|
||||||
|
// directory to scan: --app names a registered provider.
|
||||||
|
func Run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("capture", flag.ExitOnError)
|
||||||
|
appName := fs.String("app", "", "app module. Empty = passive MITM, no launch/auto-play")
|
||||||
|
channel := fs.String("channel", "", "channel id for --auto-play")
|
||||||
|
autoPlay := fs.Bool("auto-play", false, "auto-navigate to --channel (requires --app and --channel)")
|
||||||
|
waitSec := fs.Int("wait", 180, "seconds to wait for license/mpd capture")
|
||||||
|
python := fs.String("python", "", "python for wvkey.py (default: .venv)")
|
||||||
|
wvd := fs.String("wvd", "", "path to .wvd device file (required for key fetch)")
|
||||||
|
userAgent := fs.String("user-agent", "", "optional User-Agent for license requests")
|
||||||
|
serial := fs.String("serial", "", "adb device serial (default: sole/USB selected device)")
|
||||||
|
closeApp := fs.Bool("close", true, "force-stop --app when capture finishes (ignored in passive mode)")
|
||||||
|
keyMode := fs.String("key-mode", "", "override the app's key mode: modulardrm | raw | none")
|
||||||
|
// Each module contributes --<app>.<field> overrides for its local values.
|
||||||
|
app.BindFlags(fs)
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintf(fs.Output(), "capture — one-shot phone MITM capture\n\nUsage:\n capture --app <%s> [--channel ID] [--auto-play] [--wvd PATH]\n capture (passive: play anything on the phone)\n\nFlags:\n", strings.Join(app.Names(), "|"))
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c := adb.New()
|
||||||
|
if *serial != "" {
|
||||||
|
c = c.WithSerial(*serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No --app: passive MITM — the operator plays whatever they want and we dump
|
||||||
|
// whatever DRM traffic appears.
|
||||||
|
if strings.TrimSpace(*appName) == "" {
|
||||||
|
_, err := phonecap.RunPassive(phonecap.Options{
|
||||||
|
Client: c,
|
||||||
|
Python: *python,
|
||||||
|
WVD: *wvd,
|
||||||
|
UserAgent: *userAgent,
|
||||||
|
Wait: time.Duration(*waitSec) * time.Second,
|
||||||
|
KeyMode: *keyMode,
|
||||||
|
ClearProxy: true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := app.Get(*appName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *autoPlay && strings.TrimSpace(*channel) == "" {
|
||||||
|
return fmt.Errorf("--auto-play requires --channel (%s)", knownChannels(a))
|
||||||
|
}
|
||||||
|
if *channel != "" && !a.HasChannel(*channel) {
|
||||||
|
return fmt.Errorf("unknown channel %q for %s (%s)", *channel, a.Name(), knownChannels(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = phonecap.Run(phonecap.Options{
|
||||||
|
App: a,
|
||||||
|
Channel: *channel,
|
||||||
|
Client: c,
|
||||||
|
Python: *python,
|
||||||
|
WVD: *wvd,
|
||||||
|
UserAgent: *userAgent,
|
||||||
|
Wait: time.Duration(*waitSec) * time.Second,
|
||||||
|
NoAutoPlay: !*autoPlay,
|
||||||
|
CloseApp: *closeApp,
|
||||||
|
KeyMode: *keyMode,
|
||||||
|
ClearProxy: true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownChannels describes what the operator can pick, or where to declare it when
|
||||||
|
// the module has no local values file yet.
|
||||||
|
func knownChannels(a app.App) string {
|
||||||
|
ids := []string{}
|
||||||
|
for _, ch := range a.Channels() {
|
||||||
|
ids = append(ids, ch.ID)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return "no channels configured — add " + modcfg.Path(a.Name())
|
||||||
|
}
|
||||||
|
return "known: " + strings.Join(ids, ", ")
|
||||||
|
}
|
||||||
13
apps/capture/go.mod
Normal file
13
apps/capture/go.mod
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
module drmdecryption/apps/capture
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require drmdecryption v0.0.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
12
apps/capture/go.sum
Normal file
12
apps/capture/go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac h1:pNA9PRXGPFURORAsI3IqfQJ4RLsRXqghCFdvypd/4GY=
|
||||||
|
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
181
apps/cli/catalogcmd/catalogcmd.go
Normal file
181
apps/cli/catalogcmd/catalogcmd.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// Package catalogcmd resolves a channel's stream credentials from its provider's
|
||||||
|
// public catalog, with no phone involved. It is provider-agnostic: any app module
|
||||||
|
// implementing app.Catalog works here.
|
||||||
|
package catalogcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
"drmdecryption/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run performs a catalog lookup for one channel.
|
||||||
|
func Run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("catalog", flag.ExitOnError)
|
||||||
|
appName := fs.String("app", "", "app module to query")
|
||||||
|
channel := fs.String("channel", "", "channel id")
|
||||||
|
asJSON := fs.Bool("json", false, "print streamd-ready JSON only")
|
||||||
|
list := fs.Bool("list", false, "list the app's channels and their catalog ids")
|
||||||
|
writeOut := fs.Bool("out", false, "write outputs/<app>/<stamp>/session.json")
|
||||||
|
withKeys := fs.Bool("keys", false, "also fetch PSSH and resolve KID:KEY via the local CDM")
|
||||||
|
python := fs.String("python", "", "python for wvkey.py (default: .venv)")
|
||||||
|
wvd := fs.String("wvd", "", "path to .wvd device file (required with --keys)")
|
||||||
|
// Each module contributes --<app>.<field> overrides for its local values.
|
||||||
|
app.BindFlags(fs)
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintf(fs.Output(), "catalog — resolve a channel from its public catalog (no phone)\n\nUsage:\n catalog --app <%s> --channel ID [--keys] [--json]\n\nFlags:\n", strings.Join(catalogApps(), "|"))
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
available := catalogApps()
|
||||||
|
if strings.TrimSpace(*appName) == "" {
|
||||||
|
if len(available) != 1 {
|
||||||
|
return fmt.Errorf("--app required (apps with a catalog: %s)", strings.Join(available, ", "))
|
||||||
|
}
|
||||||
|
*appName = available[0]
|
||||||
|
}
|
||||||
|
a, err := app.Get(*appName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cat, ok := a.(app.Catalog)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("app %q has no catalog — capture it from the phone instead: drm capture --app %s", a.Name(), a.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
if *list {
|
||||||
|
return listChannels(a, cat)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*channel) == "" {
|
||||||
|
return fmt.Errorf("--channel required (see: drm catalog --app %s --list)", a.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := cat.Resolve(*channel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *withKeys {
|
||||||
|
fmt.Fprintln(os.Stderr, "[*] fetching PSSH + Widevine keys…")
|
||||||
|
if err := cat.EnrichWithKeys(&info, *python, *wvd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "[+] key %s (%d total)\n", info.Key, len(info.Keys))
|
||||||
|
}
|
||||||
|
if *writeOut || *withKeys {
|
||||||
|
dir, err := session.WriteStream(repo.Root(), a.Name(), info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "[+] wrote %s\n", dir)
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(info)
|
||||||
|
}
|
||||||
|
printInfo(a.Name(), info)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogApps lists compiled-in modules that can answer catalog lookups.
|
||||||
|
func catalogApps() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, a := range app.All() {
|
||||||
|
if _, ok := a.(app.Catalog); ok {
|
||||||
|
out = append(out, a.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func listChannels(a app.App, cat app.Catalog) error {
|
||||||
|
if l, ok := cat.(app.CatalogLister); ok {
|
||||||
|
rows, flags, err := l.ListChannels()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%-10s %-16s %s\n", "CHANNEL", "CATALOG ID", "LABEL")
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
|
||||||
|
for _, r := range rows {
|
||||||
|
id := r.CatalogID
|
||||||
|
if id == "" {
|
||||||
|
id = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-10s %-16s %s\n", r.ID, id, r.Label)
|
||||||
|
}
|
||||||
|
if len(flags) > 0 {
|
||||||
|
keys := make([]string, 0, len(flags))
|
||||||
|
for k := range flags {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%v", k, flags[k]))
|
||||||
|
}
|
||||||
|
fmt.Printf("\nprovider flags: %s\n", strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Fall back to the plain channel list every app can produce.
|
||||||
|
fmt.Printf("%-10s %s\n", "CHANNEL", "LABEL")
|
||||||
|
for _, ch := range a.Channels() {
|
||||||
|
fmt.Printf("%-10s %s\n", ch.ID, ch.Label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printInfo(appName string, info session.Stream) {
|
||||||
|
fmt.Printf("name: %s\n", info.Name)
|
||||||
|
fmt.Printf("title: %s\n", info.Title)
|
||||||
|
fmt.Printf("app/channel: %s / %s\n", info.App, info.Channel)
|
||||||
|
if info.VideoID != "" {
|
||||||
|
fmt.Printf("catalog id: %s\n", info.VideoID)
|
||||||
|
}
|
||||||
|
fmt.Printf("manifest: %s\n", info.MPD)
|
||||||
|
fmt.Printf("manifest type: %s\n", info.ManifestType)
|
||||||
|
fmt.Printf("license_url: %s\n", truncate(info.LicenseURL, 100))
|
||||||
|
if info.PSSH != "" {
|
||||||
|
fmt.Printf("pssh: %s\n", truncate(info.PSSH, 64))
|
||||||
|
}
|
||||||
|
if info.Key != "" {
|
||||||
|
fmt.Printf("key: %s\n", info.Key)
|
||||||
|
}
|
||||||
|
for i, k := range info.Keys {
|
||||||
|
if k == info.Key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("key[%d]: %s\n", i, k)
|
||||||
|
}
|
||||||
|
body, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"name": info.Name,
|
||||||
|
"title": info.Title,
|
||||||
|
"app": info.App,
|
||||||
|
"channel": info.Channel,
|
||||||
|
"mpd": info.MPD,
|
||||||
|
"key": info.Key,
|
||||||
|
"headers_json": info.HeadersJSON,
|
||||||
|
"rewriter": info.Rewriter,
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Printf("\nstreamd create body:\n%s\n", body)
|
||||||
|
fmt.Printf("\nTip: drm catalog --app %s --channel %s --keys --wvd path/to/device.wvd --json\n",
|
||||||
|
appName, info.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
36
apps/cli/go.mod
Normal file
36
apps/cli/go.mod
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
module drmdecryption/apps/cli
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
drmdecryption v0.0.0
|
||||||
|
drmdecryption/apps/agent v0.0.0
|
||||||
|
drmdecryption/apps/capture v0.0.0
|
||||||
|
drmdecryption/apps/proxy v0.0.0
|
||||||
|
drmdecryption/apps/streamd v0.0.0
|
||||||
|
drmdecryption/modules v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
modernc.org/sqlite v1.34.5 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace (
|
||||||
|
drmdecryption => ../pkg
|
||||||
|
drmdecryption/apps/agent => ../agent
|
||||||
|
drmdecryption/apps/capture => ../capture
|
||||||
|
drmdecryption/apps/proxy => ../proxy
|
||||||
|
drmdecryption/apps/streamd => ../streamd
|
||||||
|
drmdecryption/modules => ../modules
|
||||||
|
)
|
||||||
49
apps/cli/go.sum
Normal file
49
apps/cli/go.sum
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
76
apps/cli/main.go
Normal file
76
apps/cli/main.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Command drm is the single entry point for the toolchain: phone capture,
|
||||||
|
// catalog lookups, the always-on agent, the streamd control plane and the
|
||||||
|
// on-device MITM. Every app module under apps/modules is compiled in.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"drmdecryption/apps/agent/agentcmd"
|
||||||
|
"drmdecryption/apps/capture/capturecmd"
|
||||||
|
"drmdecryption/apps/cli/catalogcmd"
|
||||||
|
"drmdecryption/apps/cli/modulescmd"
|
||||||
|
"drmdecryption/apps/proxy/proxyctlcmd"
|
||||||
|
"drmdecryption/apps/streamd/servecmd"
|
||||||
|
|
||||||
|
// Links every app module into this binary.
|
||||||
|
_ "drmdecryption/modules/all"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
cmd, args := os.Args[1], os.Args[2:]
|
||||||
|
var err error
|
||||||
|
switch cmd {
|
||||||
|
case "capture":
|
||||||
|
err = capturecmd.Run(args)
|
||||||
|
case "catalog":
|
||||||
|
err = catalogcmd.Run(args)
|
||||||
|
case "modules":
|
||||||
|
err = modulescmd.Run(args)
|
||||||
|
case "agent":
|
||||||
|
err = agentcmd.Run(args)
|
||||||
|
case "serve":
|
||||||
|
err = servecmd.Run(append([]string{"serve"}, args...))
|
||||||
|
case "proxy":
|
||||||
|
err = proxyctlcmd.Run(args)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
usage()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
err = fmt.Errorf("unknown command %q", cmd)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "[!]", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprint(os.Stderr, `drm — DRM capture / restream toolchain
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
drm modules list compiled-in app modules and their channels
|
||||||
|
drm capture --app NAME [--channel ID] [--auto-play] [--wvd PATH]
|
||||||
|
one-shot phone MITM capture
|
||||||
|
drm catalog --app NAME --channel ID [--keys] [--json] [--list]
|
||||||
|
resolve a channel from its public catalog (no phone)
|
||||||
|
drm agent run|status|devices|enqueue|cancel [--config …]
|
||||||
|
always-on credential refresher
|
||||||
|
drm serve [--bind …] [--data …] [--token …]
|
||||||
|
streamd control plane + dashboard
|
||||||
|
drm proxy build|push|install-ca|start|stop|discover|pull|clear-proxy
|
||||||
|
on-device HTTPS MITM
|
||||||
|
|
||||||
|
App modules are compiled in; their local values live in
|
||||||
|
apps/modules/<name>/module.yaml (gitignored) and can be overridden per run with
|
||||||
|
--<app>.<field> flags or <APP>_<FIELD> environment variables.
|
||||||
|
|
||||||
|
Run any subcommand with --help for its own flags.
|
||||||
|
`)
|
||||||
|
}
|
||||||
78
apps/cli/modulescmd/modulescmd.go
Normal file
78
apps/cli/modulescmd/modulescmd.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Package modulescmd lists the app modules compiled into this binary, their
|
||||||
|
// channels, and where each one's local values came from.
|
||||||
|
package modulescmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run prints the compiled-in app modules.
|
||||||
|
func Run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("modules", flag.ExitOnError)
|
||||||
|
verbose := fs.Bool("channels", true, "list each module's channels")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := app.Names()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return fmt.Errorf("no app modules compiled in — check the blank imports in apps/modules/all/all.go")
|
||||||
|
}
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "APP\tPACKAGE\tKEY MODE\tREWRITER\tCHANNELS\tVALUES")
|
||||||
|
for _, name := range names {
|
||||||
|
a, err := app.Get(name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(tw, "%s\t-\t-\t-\t-\t%v\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rewriter := "none"
|
||||||
|
if sd, ok := a.(app.StreamDefaults); ok && sd.RewriterName() != "" {
|
||||||
|
rewriter = sd.RewriterName()
|
||||||
|
}
|
||||||
|
pkg := a.Package()
|
||||||
|
if pkg == "" {
|
||||||
|
pkg = "(not configured)"
|
||||||
|
}
|
||||||
|
values := modcfg.Path(name)
|
||||||
|
if _, err := os.Stat(values); err != nil {
|
||||||
|
values = "missing: " + values
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\n",
|
||||||
|
name, pkg, a.KeyMode(), rewriter, len(a.Channels()), values)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
|
||||||
|
if !*verbose {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
a, err := app.Get(name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chans := a.Channels()
|
||||||
|
sort.Slice(chans, func(i, j int) bool { return chans[i].ID < chans[j].ID })
|
||||||
|
fmt.Printf("\n%s channels:", name)
|
||||||
|
if len(chans) == 0 {
|
||||||
|
fmt.Printf(" none — add %s\n", modcfg.Path(name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, ch := range chans {
|
||||||
|
catalog := ""
|
||||||
|
if _, ok := a.(app.Catalog); ok {
|
||||||
|
catalog = " (catalog)"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-10s %s%s\n", ch.ID, ch.Label, catalog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
67
apps/modules/README.md
Normal file
67
apps/modules/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# App modules
|
||||||
|
|
||||||
|
One Go package per streaming app. Every module is **compiled into the binary** —
|
||||||
|
there is no plugin loading and no modules directory to point a binary at.
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/modules/ Go module: drmdecryption/modules
|
||||||
|
go.mod replace drmdecryption => ../pkg
|
||||||
|
all/all.go blank-imports every module — the link point
|
||||||
|
provider/ shared base: values loading, channel aliases, durations
|
||||||
|
<name>/*.go TRACKED: the app's logic
|
||||||
|
<name>/module.yaml usually GITIGNORED values (bbc is published)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tracked code, untracked values
|
||||||
|
|
||||||
|
This split is the rule for modules with secrets; BBC is the exception (clear
|
||||||
|
streams — package id + channel map only, so `bbc/module.yaml` is committed):
|
||||||
|
|
||||||
|
| `<name>/*.go` (tracked) | `<name>/module.yaml` (gitignored except bbc) |
|
||||||
|
|---|---|
|
||||||
|
| launch + auto-play sequence | android package id |
|
||||||
|
| navigation idioms | license URL, origin hostnames |
|
||||||
|
| manifest rewrite shape | channel KIDs, account id, policy key |
|
||||||
|
| catalog request flow | video ids, playback config URL |
|
||||||
|
| which capture fields are required | UI selectors, labels, aliases |
|
||||||
|
|
||||||
|
Module Go source contains **no** account id, policy key, video id, license URL,
|
||||||
|
origin host or KID. Modules with secrets have a test asserting an empty config
|
||||||
|
yields empty credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go -C apps/modules test ./... -run NoHardcoded -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Values arrive by **flag → environment → module.yaml → Go default**, and only
|
||||||
|
mechanical defaults (timeouts, DASH timescales, card geometry) live in code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm catalog --app tg4 --channel ioi --tg4.policy-key BCpkAD...
|
||||||
|
export TG4_POLICY_KEY=BCpkAD... # same thing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
1. Create `apps/modules/myapp/` with `config.go` and `myapp.go`
|
||||||
|
2. Register from `init()`: `appreg.Register(Name, New)` and
|
||||||
|
`appreg.RegisterFlags(bindFlags)`
|
||||||
|
3. Add one line to `all/all.go`: `_ "drmdecryption/modules/myapp"`
|
||||||
|
4. Create `apps/modules/myapp/module.yaml` with your values
|
||||||
|
5. `go -C apps/cli build -o ../../bin/drm .` then `./bin/drm modules`
|
||||||
|
|
||||||
|
**Authoring guide: [docs/modules.md](../../docs/modules.md)** — the full contract,
|
||||||
|
`uiflow` reference, optional capability interfaces, and a checklist.
|
||||||
|
|
||||||
|
**Finding the values:** [docs/capture.md](../../docs/capture.md) for a new app,
|
||||||
|
[docs/providers/](../../docs/providers/) for the modules already here.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm modules # what is compiled in
|
||||||
|
./bin/drm capture --app rte --channel rteone --auto-play
|
||||||
|
./bin/drm capture --app bbc --channel bbcone # transparent MITM; play on phone; MPD only
|
||||||
|
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd
|
||||||
|
./bin/drm agent run --config apps/agent/agent.yaml
|
||||||
|
```
|
||||||
10
apps/modules/all/all.go
Normal file
10
apps/modules/all/all.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Package all links every app module into the binary. Importing it for side
|
||||||
|
// effects is what makes `--app <name>` work; adding a provider means adding one
|
||||||
|
// line here.
|
||||||
|
package all
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "drmdecryption/modules/bbc"
|
||||||
|
_ "drmdecryption/modules/rte"
|
||||||
|
_ "drmdecryption/modules/tg4"
|
||||||
|
)
|
||||||
72
apps/modules/all/all_test.go
Normal file
72
apps/modules/all/all_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package all
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every module must be constructible with no module.yaml present — a fresh clone
|
||||||
|
// has none, and the binary still has to start and list its providers.
|
||||||
|
func TestAllModulesRegisterAndConstruct(t *testing.T) {
|
||||||
|
names := appreg.Names()
|
||||||
|
if len(names) == 0 {
|
||||||
|
t.Fatal("no app modules registered — check the blank imports in all.go")
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
a, err := appreg.Get(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct %s: %v", n, err)
|
||||||
|
}
|
||||||
|
if a.Name() != n {
|
||||||
|
t.Errorf("%s: Name() = %q", n, a.Name())
|
||||||
|
}
|
||||||
|
switch a.KeyMode() {
|
||||||
|
case "raw", "modulardrm", "none":
|
||||||
|
default:
|
||||||
|
t.Errorf("%s: KeyMode() = %q, want raw, modulardrm, or none", n, a.KeyMode())
|
||||||
|
}
|
||||||
|
if a.CAHash() == "" {
|
||||||
|
t.Errorf("%s: CAHash() empty", n)
|
||||||
|
}
|
||||||
|
if a.ProxyBin() == "" {
|
||||||
|
t.Errorf("%s: ProxyBin() empty", n)
|
||||||
|
}
|
||||||
|
if a.MPDRewriter() == nil {
|
||||||
|
t.Errorf("%s: MPDRewriter() nil", n)
|
||||||
|
}
|
||||||
|
if a.CaptureHints().RemoteLog == "" {
|
||||||
|
t.Errorf("%s: CaptureHints has no remote log", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag binding must not construct any app, so overrides land before config load.
|
||||||
|
func TestBindFlagsRegistersOverrides(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
appreg.BindFlags(fs)
|
||||||
|
count := 0
|
||||||
|
fs.VisitAll(func(*flag.Flag) { count++ })
|
||||||
|
if count == 0 {
|
||||||
|
t.Fatal("no module override flags registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module declaring a rewriter must have registered it under that name.
|
||||||
|
func TestDeclaredRewritersAreRegistered(t *testing.T) {
|
||||||
|
for _, a := range appreg.All() {
|
||||||
|
sd, ok := a.(appreg.StreamDefaults)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := sd.RewriterName()
|
||||||
|
if name == "" || name == "none" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.MPDRewriter().Name() != name {
|
||||||
|
t.Errorf("%s: RewriterName()=%q but MPDRewriter().Name()=%q",
|
||||||
|
a.Name(), name, a.MPDRewriter().Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
apps/modules/bbc/bbc.go
Normal file
129
apps/modules/bbc/bbc.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Package bbc is the BBC iPlayer app module: clear DASH/HLS captured through
|
||||||
|
// transparent MITM (UK VPN stays on). Only the manifest URL is required — no
|
||||||
|
// Widevine keys for the mobile-phone-main mediaset streams we capture.
|
||||||
|
package bbc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
appreg.Register(Name, New)
|
||||||
|
appreg.RegisterFlags(bindFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindFlags(fs *flag.FlagSet) {
|
||||||
|
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||||
|
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||||
|
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is the BBC iPlayer plugin.
|
||||||
|
type App struct {
|
||||||
|
*provider.Base
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the plugin from module.yaml + flags + env.
|
||||||
|
func New() (appreg.App, error) {
|
||||||
|
cfg, res, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
labels := make(map[string]string, len(cfg.Channels))
|
||||||
|
for id, ch := range cfg.Channels {
|
||||||
|
labels[id] = ch.Label
|
||||||
|
}
|
||||||
|
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMode none: mobile streams are clear (CDN signed URLs); no wvkey.
|
||||||
|
func (a *App) KeyMode() string { return "none" }
|
||||||
|
|
||||||
|
// CaptureHints: only the MPD/HLS master from mediaselector is required.
|
||||||
|
func (a *App) CaptureHints() capture.Hints {
|
||||||
|
return a.Hints("mpd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseTransparentMITM: NordVPN UK conflicts with Wi‑Fi http_proxy.
|
||||||
|
func (a *App) UseTransparentMITM() bool { return true }
|
||||||
|
|
||||||
|
// MPDRewriter: passthrough — no rewrite needed for clear BBC DASH.
|
||||||
|
func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||||
|
|
||||||
|
// Channels lists configured live targets.
|
||||||
|
func (a *App) Channels() []appreg.Channel {
|
||||||
|
out := make([]appreg.Channel, 0, len(a.cfg.Channels))
|
||||||
|
for _, id := range a.ChannelIDs() {
|
||||||
|
out = append(out, appreg.Channel{ID: id, Label: a.Label(id)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch cold-starts iPlayer.
|
||||||
|
func (a *App) Launch(c *adb.Client) error {
|
||||||
|
c.EnsureAwake()
|
||||||
|
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||||
|
c.ForceStop(a.Package())
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if err := uiflow.MonkeyLaunch(c, a.Package()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.LaunchSettle))
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
if err := uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{
|
||||||
|
DescContains: a.cfg.UI.LaunchDescContains,
|
||||||
|
ResourceContains: a.cfg.UI.LaunchResourceContains,
|
||||||
|
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI)); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] launch UI wait: %v — continue and play manually\n", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPlay: best-effort; iPlayer UI varies — operator can play by hand.
|
||||||
|
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||||
|
id, err := a.Resolve(channel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ch := a.cfg.Channels[id]
|
||||||
|
fmt.Printf("[*] Auto-play %s — open Live and start playback on the phone…\n", a.Label(id))
|
||||||
|
if ch.VPID != "" {
|
||||||
|
fmt.Printf(" expected mediaselector vpid ≈ %s\n", ch.VPID)
|
||||||
|
}
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
if len(ch.PlayDesc) == 0 && len(ch.ChipDesc) == 0 {
|
||||||
|
return fmt.Errorf("no UI selectors for %s — play manually on the phone", id)
|
||||||
|
}
|
||||||
|
if len(ch.ChipDesc) > 0 {
|
||||||
|
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{DescRE: ch.ChipDesc}, 20*time.Second); err != nil {
|
||||||
|
return fmt.Errorf("channel chip: %w", err)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
if len(ch.PlayDesc) > 0 {
|
||||||
|
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{DescRE: ch.PlayDesc}, 15*time.Second); err != nil {
|
||||||
|
return fmt.Errorf("play: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||||
|
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDefaults for clear live DASH.
|
||||||
|
func (a *App) VideoSelect() string { return "res=1280x720:for=best" }
|
||||||
|
func (a *App) AudioSelect() string { return "lang=en:for=best" }
|
||||||
|
func (a *App) RewriterName() string { return "none" }
|
||||||
|
func (a *App) LiveWaitSeconds() int { return 2 }
|
||||||
|
func (a *App) TSReadyBytes() int64 { return 256 << 10 }
|
||||||
80
apps/modules/bbc/bbc_test.go
Normal file
80
apps/modules/bbc/bbc_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package bbc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ appreg.App = (*App)(nil)
|
||||||
|
_ appreg.StreamDefaults = (*App)(nil)
|
||||||
|
_ appreg.TransparentMITM = (*App)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func testApp(t *testing.T) *App {
|
||||||
|
t.Helper()
|
||||||
|
cfg := Config{
|
||||||
|
Channels: map[string]Channel{
|
||||||
|
"bbcone": {Label: "BBC One", VPID: "bbc_one_london"},
|
||||||
|
"bbctwo": {Label: "BBC Two", VPID: "bbc_two_england"},
|
||||||
|
},
|
||||||
|
Common: provider.Common{
|
||||||
|
Package: "bbc.iplayer.android",
|
||||||
|
Aliases: map[string]string{"one": "bbcone", "bbc1": "bbcone"},
|
||||||
|
Score: capture.ScoreCfg{
|
||||||
|
Hosts: []string{"open.live.bbc.co.uk", "vs-cmaf-push-uk"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}.withDefaults()
|
||||||
|
labels := map[string]string{}
|
||||||
|
for id, ch := range cfg.Channels {
|
||||||
|
labels[id] = ch.Label
|
||||||
|
}
|
||||||
|
return &App{Base: provider.New(Name, cfg.Common, labels, modcfg.Result{Path: "test"}), cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoHardcodedSecrets(t *testing.T) {
|
||||||
|
cfg := Config{}.withDefaults()
|
||||||
|
if cfg.Package != "" || cfg.LicenseURL != "" {
|
||||||
|
t.Fatalf("empty config must not invent package/license: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyModeNoneAndMPDOnly(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
if a.KeyMode() != "none" {
|
||||||
|
t.Fatalf("KeyMode = %q, want none", a.KeyMode())
|
||||||
|
}
|
||||||
|
h := a.CaptureHints()
|
||||||
|
if len(h.Require) != 1 || h.Require[0] != "mpd" {
|
||||||
|
t.Fatalf("Require = %v, want [mpd]", h.Require)
|
||||||
|
}
|
||||||
|
if !a.UseTransparentMITM() {
|
||||||
|
t.Fatal("BBC must use transparent MITM (UK VPN)")
|
||||||
|
}
|
||||||
|
if _, ok := a.MPDRewriter().(mpd.Passthrough); !ok {
|
||||||
|
t.Fatal("expected Passthrough rewriter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliases(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
id, err := a.Resolve("one")
|
||||||
|
if err != nil || id != "bbcone" {
|
||||||
|
t.Fatalf("Resolve(one) = %q %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureHintsScoreHosts(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
h := a.CaptureHints()
|
||||||
|
u := "https://vs-cmaf-push-uk.live.fastly.md.bbci.co.uk/x/mobile.mpd"
|
||||||
|
if h.Score.Score(u) <= 0 {
|
||||||
|
t.Fatalf("expected positive score for %s (score=%d)", u, h.Score.Score(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/modules/bbc/config.go
Normal file
74
apps/modules/bbc/config.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package bbc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name is the module id (--app bbc).
|
||||||
|
const Name = "bbc"
|
||||||
|
|
||||||
|
// Channel is one live (or catch-up) target. Phone UI selectors are optional —
|
||||||
|
// capture works with manual play; auto-play uses them when present.
|
||||||
|
type Channel struct {
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
// VPID is the mediaselector version/service id (e.g. bbc_one_london).
|
||||||
|
VPID string `yaml:"vpid"`
|
||||||
|
// ChipDesc / PlayDesc are optional UI matchers for --auto-play.
|
||||||
|
ChipDesc []string `yaml:"chip_desc"`
|
||||||
|
PlayDesc []string `yaml:"play_desc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is apps/modules/bbc/module.yaml.
|
||||||
|
type Config struct {
|
||||||
|
provider.Common `yaml:",inline"`
|
||||||
|
|
||||||
|
Channels map[string]Channel `yaml:"channels"`
|
||||||
|
|
||||||
|
Timeouts Timeouts `yaml:"timeouts"`
|
||||||
|
UI UI `yaml:"ui"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeouts for launch / optional autoplay.
|
||||||
|
type Timeouts struct {
|
||||||
|
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||||
|
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||||
|
Playback provider.Duration `yaml:"playback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI markers for launch readiness (localized; keep loose).
|
||||||
|
type UI struct {
|
||||||
|
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||||
|
LaunchResourceContains []string `yaml:"launch_resource_contains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
t := &c.Timeouts
|
||||||
|
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(30 * time.Second))
|
||||||
|
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(4 * time.Second))
|
||||||
|
t.Playback = provider.Duration(t.Playback.D(60 * time.Second))
|
||||||
|
if len(c.UI.LaunchDescContains) == 0 {
|
||||||
|
c.UI.LaunchDescContains = []string{"iPlayer", "Home", "Live"}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags struct {
|
||||||
|
packageName string
|
||||||
|
caHash string
|
||||||
|
proxyBin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (Config, modcfg.Result, error) {
|
||||||
|
var cfg Config
|
||||||
|
res, err := modcfg.Load(Name, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, res, err
|
||||||
|
}
|
||||||
|
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||||
|
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||||
|
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||||
|
return cfg.withDefaults(), res, nil
|
||||||
|
}
|
||||||
51
apps/modules/bbc/module.yaml
Normal file
51
apps/modules/bbc/module.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# BBC iPlayer — published values. Package id and channel map only;
|
||||||
|
# no Widevine license URL (mobile-phone-main streams are clear).
|
||||||
|
|
||||||
|
package: bbc.iplayer.android
|
||||||
|
proxy_bin: bin/proxy-android-arm64
|
||||||
|
ca_hash: "6c3578b4"
|
||||||
|
|
||||||
|
# Manifest-URL scoring for the on-device MITM / waiter.
|
||||||
|
score:
|
||||||
|
hosts:
|
||||||
|
- open.live.bbc.co.uk
|
||||||
|
- vs-cmaf-push-uk
|
||||||
|
- vod-dash-uk
|
||||||
|
- akamaized.net
|
||||||
|
- bbci.co.uk
|
||||||
|
- bidi.net.uk
|
||||||
|
- bidi.bbc.co.uk
|
||||||
|
deny:
|
||||||
|
- telemetry.api.bbci
|
||||||
|
- bag.api.bbc
|
||||||
|
- ibl.api.bbci
|
||||||
|
- thumbnail
|
||||||
|
|
||||||
|
aliases:
|
||||||
|
one: bbcone
|
||||||
|
bbc1: bbcone
|
||||||
|
london: bbcone
|
||||||
|
two: bbctwo
|
||||||
|
bbc2: bbctwo
|
||||||
|
news: bbcnews
|
||||||
|
four: bbcfour
|
||||||
|
|
||||||
|
channels:
|
||||||
|
bbcone:
|
||||||
|
label: BBC One
|
||||||
|
vpid: bbc_one_london
|
||||||
|
bbctwo:
|
||||||
|
label: BBC Two
|
||||||
|
vpid: bbc_two_england
|
||||||
|
bbcnews:
|
||||||
|
label: BBC News
|
||||||
|
vpid: bbc_news_channel
|
||||||
|
bbcfour:
|
||||||
|
label: BBC Four
|
||||||
|
vpid: bbc_four
|
||||||
|
|
||||||
|
# timeouts:
|
||||||
|
# launch_wait_ui: 30s
|
||||||
|
# playback: 60s
|
||||||
|
# ui:
|
||||||
|
# launch_desc_contains: ["iPlayer", "Home", "Live"]
|
||||||
9
apps/modules/go.mod
Normal file
9
apps/modules/go.mod
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
module drmdecryption/modules
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require drmdecryption v0.0.0
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
4
apps/modules/go.sum
Normal file
4
apps/modules/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
41
apps/modules/provider/duration.go
Normal file
41
apps/modules/provider/duration.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duration is a time.Duration that accepts the spellings a module.yaml uses:
|
||||||
|
// a Go duration string ("25s", "1200ms") or a bare number meaning seconds.
|
||||||
|
// yaml.v3 decodes time.Duration only as raw nanoseconds, which no one writes.
|
||||||
|
type Duration time.Duration
|
||||||
|
|
||||||
|
// D returns the value as a time.Duration, or def when unset.
|
||||||
|
func (d Duration) D(def time.Duration) time.Duration {
|
||||||
|
if d == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return time.Duration(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
var s string
|
||||||
|
if err := unmarshal(&s); err == nil {
|
||||||
|
if s == "" {
|
||||||
|
*d = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||||
|
}
|
||||||
|
*d = Duration(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
if err := unmarshal(&f); err != nil {
|
||||||
|
return fmt.Errorf("duration must be a string like \"25s\" or a number of seconds")
|
||||||
|
}
|
||||||
|
*d = Duration(time.Duration(f * float64(time.Second)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
147
apps/modules/provider/provider.go
Normal file
147
apps/modules/provider/provider.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Package provider holds the plumbing every app module shares: loading its local
|
||||||
|
// values file, channel id normalization and aliasing, and the boilerplate half of
|
||||||
|
// app.App. Module packages embed Base and add their own navigation and catalog.
|
||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/phonecap"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common is the part of a module.yaml that every module has. Modules embed it in
|
||||||
|
// their own config struct with `yaml:",inline"`.
|
||||||
|
type Common struct {
|
||||||
|
Package string `yaml:"package"`
|
||||||
|
LicenseURL string `yaml:"license_url"`
|
||||||
|
ProxyBin string `yaml:"proxy_bin"`
|
||||||
|
CAHash string `yaml:"ca_hash"`
|
||||||
|
Aliases map[string]string `yaml:"aliases"`
|
||||||
|
Score capture.ScoreCfg `yaml:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base implements the boilerplate half of app.App.
|
||||||
|
type Base struct {
|
||||||
|
name string
|
||||||
|
common Common
|
||||||
|
labels map[string]string // canonical channel id -> label
|
||||||
|
cacheDir string
|
||||||
|
cfg modcfg.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Base. labels maps canonical channel ids to display labels.
|
||||||
|
func New(name string, common Common, labels map[string]string, cfg modcfg.Result) *Base {
|
||||||
|
root := repo.Root()
|
||||||
|
cacheDir := filepath.Join(root, ".cache", "ui-"+name)
|
||||||
|
_ = os.MkdirAll(cacheDir, 0o755)
|
||||||
|
return &Base{name: name, common: common, labels: labels, cacheDir: cacheDir, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) Name() string { return b.name }
|
||||||
|
func (b *Base) Package() string { return b.common.Package }
|
||||||
|
func (b *Base) LicenseURL() string { return b.common.LicenseURL }
|
||||||
|
func (b *Base) CacheDir() string { return b.cacheDir }
|
||||||
|
|
||||||
|
// ConfigPath / ConfigLoaded report where this module's values came from.
|
||||||
|
func (b *Base) ConfigPath() string { return b.cfg.Path }
|
||||||
|
func (b *Base) ConfigLoaded() bool { return b.cfg.Loaded }
|
||||||
|
|
||||||
|
// ProxyBin resolves the on-device MITM binary, relative paths against repo root.
|
||||||
|
func (b *Base) ProxyBin() string {
|
||||||
|
if b.common.ProxyBin != "" {
|
||||||
|
return modcfg.Resolve(b.common.ProxyBin)
|
||||||
|
}
|
||||||
|
root := repo.Root()
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(root, "bin", "proxy-android-arm64"),
|
||||||
|
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
|
||||||
|
} {
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(root, "bin", "proxy-android-arm64")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) CAHash() string {
|
||||||
|
if b.common.CAHash == "" {
|
||||||
|
return phonecap.DefaultCAHash
|
||||||
|
}
|
||||||
|
return b.common.CAHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score is this module's manifest-URL scoring, merged onto the neutral baseline.
|
||||||
|
func (b *Base) Score() capture.ScoreCfg {
|
||||||
|
return capture.DefaultScoreCfg().Merge(b.common.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hints returns device-layout hints with this module's scoring applied.
|
||||||
|
func (b *Base) Hints(require ...string) capture.Hints {
|
||||||
|
h := capture.DefaultHints()
|
||||||
|
h.Require = require
|
||||||
|
h.Score = b.Score()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeID folds a channel id to its comparison form.
|
||||||
|
func NormalizeID(id string) string {
|
||||||
|
id = strings.ToLower(strings.TrimSpace(id))
|
||||||
|
for _, ch := range []string{"-", "_", " "} {
|
||||||
|
id = strings.ReplaceAll(id, ch, "")
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve maps any spelling or alias of a channel to its canonical id.
|
||||||
|
func (b *Base) Resolve(id string) (string, error) {
|
||||||
|
want := NormalizeID(id)
|
||||||
|
for canon := range b.labels {
|
||||||
|
if NormalizeID(canon) == want {
|
||||||
|
return canon, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for alias, canon := range b.common.Aliases {
|
||||||
|
if NormalizeID(alias) == want {
|
||||||
|
if _, ok := b.labels[canon]; ok {
|
||||||
|
return canon, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
known := make([]string, 0, len(b.labels))
|
||||||
|
for k := range b.labels {
|
||||||
|
known = append(known, k)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown channel %q for %s (known: %s) — add it to %s",
|
||||||
|
id, b.name, strings.Join(known, "|"), b.cfg.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) HasChannel(id string) bool {
|
||||||
|
_, err := b.Resolve(id)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label returns a channel's display label, falling back to its id.
|
||||||
|
func (b *Base) Label(canonID string) string {
|
||||||
|
if l := b.labels[canonID]; l != "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return canonID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelIDs lists canonical channel ids, sorted so listings and API responses
|
||||||
|
// are stable (map iteration order is not).
|
||||||
|
func (b *Base) ChannelIDs() []string {
|
||||||
|
out := make([]string, 0, len(b.labels))
|
||||||
|
for k := range b.labels {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
118
apps/modules/rte/config.go
Normal file
118
apps/modules/rte/config.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name is the module id (--app rte).
|
||||||
|
const Name = "rte"
|
||||||
|
|
||||||
|
// Channel is one live channel's phone-UI selectors and encoder identity. All of
|
||||||
|
// it is data: nothing here is compiled into the binary.
|
||||||
|
type Channel struct {
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
// ChipDesc matches the channel chip on the Live tab.
|
||||||
|
ChipDesc []string `yaml:"chip_desc"`
|
||||||
|
// PlayDesc matches the large hero Play button.
|
||||||
|
PlayDesc []string `yaml:"play_desc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is apps/modules/rte/module.yaml.
|
||||||
|
type Config struct {
|
||||||
|
provider.Common `yaml:",inline"`
|
||||||
|
|
||||||
|
Channels map[string]Channel `yaml:"channels"`
|
||||||
|
|
||||||
|
// Origins maps an encoder channel id to its Smooth Streaming origin base.
|
||||||
|
Origins map[string]string `yaml:"origins"`
|
||||||
|
// KIDs maps an encoder channel id to its Widevine KID.
|
||||||
|
KIDs map[string]string `yaml:"kids"`
|
||||||
|
// EncoderChannelRE matches origin channel ids that use the encoder timeline.
|
||||||
|
EncoderChannelRE string `yaml:"encoder_channel_re"`
|
||||||
|
// OriginMarker is the path element identifying an origin BaseURL.
|
||||||
|
OriginMarker string `yaml:"origin_marker"`
|
||||||
|
|
||||||
|
// Synthetic tunes the generated MPD. Everything in it has a safe default.
|
||||||
|
Synthetic mpd.SyntheticConfig `yaml:"synthetic"`
|
||||||
|
|
||||||
|
// HeroMinArea is the smallest node area accepted as the hero Play button.
|
||||||
|
HeroMinArea int `yaml:"hero_min_area"`
|
||||||
|
|
||||||
|
Timeouts Timeouts `yaml:"timeouts"`
|
||||||
|
UI UI `yaml:"ui"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeouts for the launch / autoplay sequence. Written as "25s" in module.yaml.
|
||||||
|
type Timeouts struct {
|
||||||
|
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||||
|
LiveTab provider.Duration `yaml:"live_tab"`
|
||||||
|
ChipWait provider.Duration `yaml:"chip_wait"`
|
||||||
|
PlayWait provider.Duration `yaml:"play_wait"`
|
||||||
|
Playback provider.Duration `yaml:"playback"`
|
||||||
|
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||||
|
AfterLiveTab provider.Duration `yaml:"after_live_tab"`
|
||||||
|
AfterChip provider.Duration `yaml:"after_chip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI holds the markers the launch/autoplay sequence waits on, as config so an
|
||||||
|
// app UI redesign needs no rebuild.
|
||||||
|
type UI struct {
|
||||||
|
// LaunchDescContains: any of these means the app is up.
|
||||||
|
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||||
|
// LiveTabDescRE matches the Live tab to tap.
|
||||||
|
LiveTabDescRE []string `yaml:"live_tab_desc_re"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
if c.EncoderChannelRE == "" {
|
||||||
|
c.EncoderChannelRE = `^(vc|channel)\d+$`
|
||||||
|
}
|
||||||
|
if c.OriginMarker == "" {
|
||||||
|
c.OriginMarker = ".isml"
|
||||||
|
}
|
||||||
|
if c.HeroMinArea == 0 {
|
||||||
|
c.HeroMinArea = 200_000
|
||||||
|
}
|
||||||
|
t := &c.Timeouts
|
||||||
|
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(25 * time.Second))
|
||||||
|
t.LiveTab = provider.Duration(t.LiveTab.D(30 * time.Second))
|
||||||
|
t.ChipWait = provider.Duration(t.ChipWait.D(20 * time.Second))
|
||||||
|
t.PlayWait = provider.Duration(t.PlayWait.D(15 * time.Second))
|
||||||
|
t.Playback = provider.Duration(t.Playback.D(45 * time.Second))
|
||||||
|
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(4 * time.Second))
|
||||||
|
t.AfterLiveTab = provider.Duration(t.AfterLiveTab.D(2 * time.Second))
|
||||||
|
t.AfterChip = provider.Duration(t.AfterChip.D(2 * time.Second))
|
||||||
|
if len(c.UI.LaunchDescContains) == 0 {
|
||||||
|
c.UI.LaunchDescContains = []string{"live tab", "header logo"}
|
||||||
|
}
|
||||||
|
if len(c.UI.LiveTabDescRE) == 0 {
|
||||||
|
c.UI.LiveTabDescRE = []string{"Live tab"}
|
||||||
|
}
|
||||||
|
c.Synthetic = c.Synthetic.WithDefaults()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag overrides, bound before any app is constructed.
|
||||||
|
var flags struct {
|
||||||
|
packageName string
|
||||||
|
licenseURL string
|
||||||
|
caHash string
|
||||||
|
proxyBin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (Config, modcfg.Result, error) {
|
||||||
|
var cfg Config
|
||||||
|
res, err := modcfg.Load(Name, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, res, err
|
||||||
|
}
|
||||||
|
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||||
|
cfg.LicenseURL = modcfg.Override(Name, "license_url", flags.licenseURL, cfg.LicenseURL)
|
||||||
|
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||||
|
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||||
|
return cfg.withDefaults(), res, nil
|
||||||
|
}
|
||||||
130
apps/modules/rte/live_test.go
Normal file
130
apps/modules/rte/live_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
//go:build live
|
||||||
|
|
||||||
|
// Live tests hit the real origin and need this module's local module.yaml.
|
||||||
|
// They are excluded from the normal test run; enable with:
|
||||||
|
//
|
||||||
|
// go -C apps/modules test -tags live ./rte/ -v
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The rewriter must publish a usable manifest from the KID alone — this is the
|
||||||
|
// path taken when the ad-stitched upstream session has expired (HTTP 410) and all
|
||||||
|
// that is left is the key from an earlier capture.
|
||||||
|
func TestLiveRewriteFromKIDOnly(t *testing.T) {
|
||||||
|
cfg, res, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Loaded {
|
||||||
|
t.Skipf("no local values at %s", res.Path)
|
||||||
|
}
|
||||||
|
if len(cfg.KIDs) == 0 {
|
||||||
|
t.Skip("no kids configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
for channel, kid := range cfg.KIDs {
|
||||||
|
t.Run(channel, func(t *testing.T) {
|
||||||
|
resetCaches(channel)
|
||||||
|
r := NewRewriterWith(cfg)
|
||||||
|
|
||||||
|
// Empty upstream = expired session; only the KID hint is available.
|
||||||
|
out, err := r.Rewrite(nil, kid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rewrite from KID: %v", err)
|
||||||
|
}
|
||||||
|
got := string(out)
|
||||||
|
|
||||||
|
origin := cfg.Origins[channel]
|
||||||
|
if origin == "" {
|
||||||
|
t.Fatalf("no origin configured for %s", channel)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`type="dynamic"`,
|
||||||
|
origin + cfg.Synthetic.SegmentPathSuffix,
|
||||||
|
channel + "-" + cfg.Synthetic.VideoName,
|
||||||
|
channel + "-" + cfg.Synthetic.AudioName,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("manifest missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u, _ := mpd.KIDToUUID(kid)
|
||||||
|
if !strings.Contains(got, `cenc:default_KID="`+u+`"`) {
|
||||||
|
t.Errorf("manifest does not carry KID %s", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timeline must be non-empty and on the encoder grid, or the
|
||||||
|
// downloader asks the origin for segments that do not exist.
|
||||||
|
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="(\d+)"/>`)
|
||||||
|
ms := re.FindAllStringSubmatch(got, -1)
|
||||||
|
if len(ms) != 2 {
|
||||||
|
t.Fatalf("want 2 timelines, got %d", len(ms))
|
||||||
|
}
|
||||||
|
grid, err := mpd.LearnGrid(channel, origin, cfg.Synthetic.GridSpec())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("learn grid: %v", err)
|
||||||
|
}
|
||||||
|
for i, m := range ms {
|
||||||
|
start, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
dur, _ := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
rep, _ := strconv.Atoi(m[3])
|
||||||
|
wantDur, mod := grid.VDur, grid.VMod
|
||||||
|
if i == 1 {
|
||||||
|
wantDur, mod = grid.ADur, grid.AMod
|
||||||
|
}
|
||||||
|
if dur != wantDur {
|
||||||
|
t.Errorf("timeline %d: d=%d, want %d (from the live origin)", i, dur, wantDur)
|
||||||
|
}
|
||||||
|
if int64(rep+1) != cfg.Synthetic.WindowSegments {
|
||||||
|
t.Errorf("timeline %d: %d segments, want %d", i, rep+1, cfg.Synthetic.WindowSegments)
|
||||||
|
}
|
||||||
|
if off := ((start-mod)%wantDur + wantDur) % wantDur; off != 0 {
|
||||||
|
t.Errorf("timeline %d: start %d is off the encoder grid by %d", i, start, off)
|
||||||
|
}
|
||||||
|
if start <= 0 {
|
||||||
|
t.Errorf("timeline %d: non-positive start %d", i, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("%s: %d bytes, vdur=%d adur=%d", channel, len(out), grid.VDur, grid.ADur)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The KID→channel→origin lookup is what makes the expired-session path work.
|
||||||
|
func TestLiveKIDResolvesToConfiguredOrigin(t *testing.T) {
|
||||||
|
cfg, res, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Loaded {
|
||||||
|
t.Skipf("no local values at %s", res.Path)
|
||||||
|
}
|
||||||
|
r := NewRewriterWith(cfg)
|
||||||
|
for channel, kid := range cfg.KIDs {
|
||||||
|
gotCh, gotBase, ok := r.ResolveKID(mpd.KIDHex(kid))
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: KID %s did not resolve", channel, kid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gotCh != channel {
|
||||||
|
t.Errorf("KID %s resolved to %q, want %q", kid, gotCh, channel)
|
||||||
|
}
|
||||||
|
if gotBase != cfg.Origins[channel] {
|
||||||
|
t.Errorf("%s: origin %q, want %q", channel, gotBase, cfg.Origins[channel])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetCaches(channel string) {
|
||||||
|
// The grid/anchor caches live in pkg/mpd and are keyed by channel; a fresh
|
||||||
|
// process per test run is enough, so nothing to do here beyond documenting it.
|
||||||
|
_ = channel
|
||||||
|
}
|
||||||
146
apps/modules/rte/mpd.go
Normal file
146
apps/modules/rte/mpd.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mpd.Register(Name, func() mpd.Rewriter { return NewRewriter() })
|
||||||
|
}
|
||||||
|
|
||||||
|
var reBaseURL = regexp.MustCompile(`(?i)<BaseURL[^>]*>([^<]+)</BaseURL>`)
|
||||||
|
|
||||||
|
// Rewriter turns an ad-stitched upstream MPD into a synthetic manifest on the
|
||||||
|
// origin encoder's own segment grid, so a downloader keeps pulling live segments
|
||||||
|
// after the upstream session expires. All origins and KIDs come from config.
|
||||||
|
type Rewriter struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
fallbackCh string
|
||||||
|
fallbackBase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRewriter loads the module config and returns a rewriter. A config error is
|
||||||
|
// deferred to Rewrite so registry lookup never fails at startup.
|
||||||
|
func NewRewriter() *Rewriter {
|
||||||
|
cfg, _, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return &Rewriter{cfg: Config{}.withDefaults()}
|
||||||
|
}
|
||||||
|
return &Rewriter{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRewriterWith builds a rewriter from an already-loaded config.
|
||||||
|
func NewRewriterWith(cfg Config) *Rewriter {
|
||||||
|
return &Rewriter{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rewriter) Name() string { return Name }
|
||||||
|
|
||||||
|
// Prime records a channel/origin learned elsewhere (e.g. from the KID).
|
||||||
|
func (r *Rewriter) Prime(channel, originBase string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if channel != "" {
|
||||||
|
r.fallbackCh = channel
|
||||||
|
}
|
||||||
|
if originBase != "" {
|
||||||
|
r.fallbackBase = originBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveKID maps a KID back to its encoder channel and origin.
|
||||||
|
func (r *Rewriter) ResolveKID(kidHex string) (channel, originBase string, ok bool) {
|
||||||
|
for ch, kid := range r.cfg.KIDs {
|
||||||
|
if mpd.KIDHex(kid) == kidHex {
|
||||||
|
return ch, r.cfg.Origins[ch], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite produces the synthetic MPD. upstreamXML may be empty (expired session):
|
||||||
|
// the KID fallback then supplies the channel and origin.
|
||||||
|
func (r *Rewriter) Rewrite(upstreamXML []byte, kidHint string) ([]byte, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
fbCh, fbBase := r.fallbackCh, r.fallbackBase
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
payload, channel, originBase, err := r.rewrite(string(upstreamXML), kidHint, fbCh, fbBase)
|
||||||
|
if channel != "" || originBase != "" {
|
||||||
|
r.Prime(channel, originBase)
|
||||||
|
}
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rewriter) rewrite(xmlText, kidHint, fallbackChannel, fallbackBase string) (payload []byte, channel, originBase string, err error) {
|
||||||
|
cfg := r.cfg
|
||||||
|
channel, originBase = r.originFromXML(xmlText)
|
||||||
|
resolvedKid := mpd.ExtractDefaultKID(xmlText)
|
||||||
|
if resolvedKid == "" && kidHint != "" {
|
||||||
|
resolvedKid, _ = mpd.KIDToUUID(kidHint)
|
||||||
|
}
|
||||||
|
if (channel == "" || originBase == "") && resolvedKid != "" {
|
||||||
|
if mapped, base, ok := r.ResolveKID(mpd.KIDHex(resolvedKid)); ok {
|
||||||
|
if channel == "" {
|
||||||
|
channel = mapped
|
||||||
|
}
|
||||||
|
if originBase == "" {
|
||||||
|
originBase = base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel == "" {
|
||||||
|
channel = fallbackChannel
|
||||||
|
}
|
||||||
|
if originBase == "" {
|
||||||
|
originBase = fallbackBase
|
||||||
|
}
|
||||||
|
if channel != "" && originBase == "" {
|
||||||
|
originBase = cfg.Origins[channel]
|
||||||
|
}
|
||||||
|
if channel != "" && originBase != "" && r.usesEncoderTimeline(channel) {
|
||||||
|
if resolvedKid == "" {
|
||||||
|
resolvedKid = cfg.KIDs[channel]
|
||||||
|
}
|
||||||
|
if resolvedKid == "" {
|
||||||
|
return nil, "", "", fmt.Errorf("%s: no KID for synthetic MPD channel %s", Name, channel)
|
||||||
|
}
|
||||||
|
payload, err = mpd.BuildSynthetic(cfg.Synthetic, channel, originBase, resolvedKid)
|
||||||
|
return payload, channel, originBase, err
|
||||||
|
}
|
||||||
|
return nil, channel, originBase, fmt.Errorf("%s rewrite: unsupported MPD (no encoder channel); channel=%q", Name, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Rewriter) usesEncoderTimeline(channel string) bool {
|
||||||
|
ok, _ := regexp.MatchString(r.cfg.EncoderChannelRE, channel)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// originFromXML finds the origin BaseURL and derives the encoder channel from it.
|
||||||
|
func (r *Rewriter) originFromXML(xmlText string) (channel, base string) {
|
||||||
|
marker := r.cfg.OriginMarker
|
||||||
|
if marker == "" {
|
||||||
|
marker = ".isml"
|
||||||
|
}
|
||||||
|
for _, m := range reBaseURL.FindAllStringSubmatch(xmlText, -1) {
|
||||||
|
text := strings.TrimSpace(m[1])
|
||||||
|
idx := strings.Index(text, marker)
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base = text[:idx] + marker + "/"
|
||||||
|
parts := strings.Split(strings.TrimSuffix(base, "/"), "/")
|
||||||
|
channel = strings.TrimSuffix(parts[len(parts)-1], marker)
|
||||||
|
if channel != "" {
|
||||||
|
return channel, base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
118
apps/modules/rte/rte.go
Normal file
118
apps/modules/rte/rte.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// Package rte is the RTE Player app module: DASH + ModularDrm Widevine, captured
|
||||||
|
// through the phone MITM. Launch/auto-play are Go; every value (package name,
|
||||||
|
// license URL, channel chips, origins, KIDs) comes from the local, untracked
|
||||||
|
// apps/modules/rte/module.yaml, a flag, or the environment.
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
appreg.Register(Name, New)
|
||||||
|
appreg.RegisterFlags(bindFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindFlags(fs *flag.FlagSet) {
|
||||||
|
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||||
|
fs.StringVar(&flags.licenseURL, Name+".license-url", "", "override "+Name+" Widevine license URL")
|
||||||
|
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||||
|
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is the RTE app plugin.
|
||||||
|
type App struct {
|
||||||
|
*provider.Base
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the plugin, loading values from module.yaml + flags + env.
|
||||||
|
func New() (appreg.App, error) {
|
||||||
|
cfg, res, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
labels := make(map[string]string, len(cfg.Channels))
|
||||||
|
for id, ch := range cfg.Channels {
|
||||||
|
labels[id] = ch.Label
|
||||||
|
}
|
||||||
|
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMode: RTE serves a thePlatform ModularDrm JSON license.
|
||||||
|
func (a *App) KeyMode() string { return "modulardrm" }
|
||||||
|
|
||||||
|
// CaptureHints: the MITM must yield the auth token, program id, PSSH and manifest.
|
||||||
|
func (a *App) CaptureHints() capture.Hints {
|
||||||
|
return a.Hints("auth", "pid", "pssh", "mpd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPDRewriter publishes the synthetic encoder-timeline manifest.
|
||||||
|
func (a *App) MPDRewriter() mpd.Rewriter { return NewRewriterWith(a.cfg) }
|
||||||
|
|
||||||
|
// Channels lists the configured live channels.
|
||||||
|
func (a *App) Channels() []appreg.Channel {
|
||||||
|
out := make([]appreg.Channel, 0, len(a.cfg.Channels))
|
||||||
|
for _, id := range a.ChannelIDs() {
|
||||||
|
out = append(out, appreg.Channel{ID: id, Label: a.Label(id)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch cold-starts the app and waits for its home UI.
|
||||||
|
func (a *App) Launch(c *adb.Client) error {
|
||||||
|
c.EnsureAwake()
|
||||||
|
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||||
|
c.ForceStop(a.Package())
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if err := uiflow.MonkeyLaunch(c, a.Package()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.LaunchSettle))
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
return uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{
|
||||||
|
DescContains: a.cfg.UI.LaunchDescContains,
|
||||||
|
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPlay navigates to a live channel and waits for playback.
|
||||||
|
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||||
|
id, err := a.Resolve(channel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ch := a.cfg.Channels[id]
|
||||||
|
fmt.Printf("[*] Auto-play %s…\n", a.Label(id))
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
|
||||||
|
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{
|
||||||
|
DescRE: a.cfg.UI.LiveTabDescRE,
|
||||||
|
}, time.Duration(a.cfg.Timeouts.LiveTab)); err != nil {
|
||||||
|
return fmt.Errorf("live tab: %w", err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTab))
|
||||||
|
|
||||||
|
if err := a.tapChipThenPlay(c, ch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||||
|
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDefaults — the synthetic MPD carries a single 1080p + AAC pair, so a
|
||||||
|
// resolution filter is right here, and the rewriter must run.
|
||||||
|
func (a *App) VideoSelect() string { return "res=1280x720:for=best" }
|
||||||
|
func (a *App) AudioSelect() string { return "lang=en:for=best" }
|
||||||
|
func (a *App) RewriterName() string { return Name }
|
||||||
|
func (a *App) LiveWaitSeconds() int { return 2 }
|
||||||
|
func (a *App) TSReadyBytes() int64 { return 256 << 10 }
|
||||||
84
apps/modules/rte/rte_test.go
Normal file
84
apps/modules/rte/rte_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time proof the plugin satisfies every interface the host expects.
|
||||||
|
var (
|
||||||
|
_ appreg.App = (*App)(nil)
|
||||||
|
_ appreg.StreamDefaults = (*App)(nil)
|
||||||
|
_ mpd.Rewriter = (*Rewriter)(nil)
|
||||||
|
_ mpd.KIDResolver = (*Rewriter)(nil)
|
||||||
|
_ mpd.Primer = (*Rewriter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func testConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Channels: map[string]Channel{
|
||||||
|
"chanone": {Label: "Chan One", ChipDesc: []string{"^chanone$"}},
|
||||||
|
"chantwo": {Label: "Chan Two"},
|
||||||
|
},
|
||||||
|
Origins: map[string]string{
|
||||||
|
"vc11": "https://origin.test/live/tc-1/vc11/vc11.isml/",
|
||||||
|
"vc12": "https://origin.test/live/tc-1/vc12/vc12.isml/",
|
||||||
|
},
|
||||||
|
KIDs: map[string]string{
|
||||||
|
"vc11": "df163382-1ddd-fdd5-bec9-822c1ec0f052",
|
||||||
|
},
|
||||||
|
}.withDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveKIDMapsBackToOrigin(t *testing.T) {
|
||||||
|
r := NewRewriterWith(testConfig())
|
||||||
|
ch, base, ok := r.ResolveKID("df1633821dddfdd5bec9822c1ec0f052")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("KID should resolve to a channel")
|
||||||
|
}
|
||||||
|
if ch != "vc11" {
|
||||||
|
t.Errorf("channel = %q, want vc11", ch)
|
||||||
|
}
|
||||||
|
if !strings.Contains(base, "vc11.isml") {
|
||||||
|
t.Errorf("origin base = %q", base)
|
||||||
|
}
|
||||||
|
if _, _, ok := r.ResolveKID("00000000000000000000000000000000"); ok {
|
||||||
|
t.Error("unknown KID must not resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginFromXMLUsesConfiguredMarker(t *testing.T) {
|
||||||
|
r := NewRewriterWith(testConfig())
|
||||||
|
xml := `<MPD><BaseURL>https://origin.test/live/tc-1/vc12/vc12.isml/dash/</BaseURL></MPD>`
|
||||||
|
ch, base := r.originFromXML(xml)
|
||||||
|
if ch != "vc12" {
|
||||||
|
t.Errorf("channel = %q, want vc12", ch)
|
||||||
|
}
|
||||||
|
if base != "https://origin.test/live/tc-1/vc12/vc12.isml/" {
|
||||||
|
t.Errorf("base = %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no channel identifiable at all, the rewrite must fail rather than emit a
|
||||||
|
// manifest pointing nowhere.
|
||||||
|
func TestRewriteWithoutChannelFails(t *testing.T) {
|
||||||
|
r := NewRewriterWith(testConfig())
|
||||||
|
if _, err := r.Rewrite([]byte(`<MPD/>`), ""); err == nil {
|
||||||
|
t.Fatal("expected an error with no channel and no KID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No provider identity may be compiled in: an empty config must yield no origins
|
||||||
|
// and no KIDs, so a fresh clone cannot stream without its local values file.
|
||||||
|
func TestNoHardcodedOrigins(t *testing.T) {
|
||||||
|
cfg := Config{}.withDefaults()
|
||||||
|
if len(cfg.Origins) != 0 || len(cfg.KIDs) != 0 {
|
||||||
|
t.Fatal("origins/KIDs must come from module.yaml, not from code")
|
||||||
|
}
|
||||||
|
if cfg.Package != "" || cfg.LicenseURL != "" {
|
||||||
|
t.Fatal("package/license URL must not be defaulted in code")
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/modules/rte/steps.go
Normal file
61
apps/modules/rte/steps.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package rte
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tapChipThenPlay is this app's navigation idiom: on the Live tab, tap the
|
||||||
|
// channel chip, then the large hero Play button that appears. If the hero button
|
||||||
|
// is already on screen, tap it directly.
|
||||||
|
func (a *App) tapChipThenPlay(c *adb.Client, ch Channel) error {
|
||||||
|
playPats := uiflow.CompileRes(ch.PlayDesc)
|
||||||
|
chipPats := uiflow.CompileRes(ch.ChipDesc)
|
||||||
|
|
||||||
|
nodes, err := c.DumpUI(a.CacheDir())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cand := a.findHeroPlay(nodes, playPats); cand != nil {
|
||||||
|
fmt.Printf("[*] tap Play @ %d,%d\n", cand.CX(), cand.CY())
|
||||||
|
return c.Tap(cand.CX(), cand.CY())
|
||||||
|
}
|
||||||
|
|
||||||
|
chip := adb.FindSmallest(nodes, chipPats)
|
||||||
|
if chip == nil {
|
||||||
|
chip, err = c.WaitFor(a.CacheDir(), nil, chipPats, time.Duration(a.cfg.Timeouts.ChipWait))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("chip not found: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] tap chip @ %d,%d\n", chip.CX(), chip.CY())
|
||||||
|
_ = c.Tap(chip.CX(), chip.CY())
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterChip))
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Duration(a.cfg.Timeouts.PlayWait))
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
nodes, err = c.DumpUI(a.CacheDir())
|
||||||
|
if err == nil {
|
||||||
|
if cand := a.findHeroPlay(nodes, playPats); cand != nil {
|
||||||
|
fmt.Printf("[*] tap Play @ %d,%d\n", cand.CX(), cand.CY())
|
||||||
|
return c.Tap(cand.CX(), cand.CY())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Play button did not appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findHeroPlay accepts a Play match only if it is big enough to be the hero
|
||||||
|
// button rather than a small list-item play icon.
|
||||||
|
func (a *App) findHeroPlay(nodes []adb.Node, playPats []*regexp.Regexp) *adb.Node {
|
||||||
|
cand := adb.FindLargest(nodes, nil, playPats)
|
||||||
|
if cand == nil || cand.Area() < a.cfg.HeroMinArea {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
151
apps/modules/tg4/catalog.go
Normal file
151
apps/modules/tg4/catalog.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/brightcove"
|
||||||
|
"drmdecryption/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playbackConfig is the app's remote config blob. It is decoded generically: the
|
||||||
|
// field names a channel uses come from module.yaml (token_field / stream_id_field),
|
||||||
|
// so a renamed or added field needs no code change.
|
||||||
|
type playbackConfig map[string]any
|
||||||
|
|
||||||
|
func fetchPlaybackConfig(url string) (playbackConfig, error) {
|
||||||
|
if url == "" {
|
||||||
|
return nil, fmt.Errorf("playback_config_url required (set it in module.yaml or --%s.playback-config-url)", Name)
|
||||||
|
}
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("playback config HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var pc playbackConfig
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&pc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// str reads a string field, tolerating numbers.
|
||||||
|
func (pc playbackConfig) str(field string) string {
|
||||||
|
if field == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := pc[field].(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%.0f", v)
|
||||||
|
case json.Number:
|
||||||
|
return v.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags reports the boolean switches in the playback config, for `--list`.
|
||||||
|
func (pc playbackConfig) Flags() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for k, v := range pc {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
out[k] = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChannel returns the channel's label, Brightcove video id and playback
|
||||||
|
// token, consulting the remote playback config for anything not pinned locally.
|
||||||
|
func (a *App) resolveChannel(pc playbackConfig, id string) (label, videoID, token string, err error) {
|
||||||
|
ch, ok := a.cfg.Channels[id]
|
||||||
|
if !ok {
|
||||||
|
return "", "", "", fmt.Errorf("unknown channel %q", id)
|
||||||
|
}
|
||||||
|
label = a.Label(id)
|
||||||
|
videoID = ch.VideoID
|
||||||
|
if videoID == "" {
|
||||||
|
videoID = pc.str(ch.StreamIDField)
|
||||||
|
}
|
||||||
|
token = pc.str(ch.TokenField)
|
||||||
|
|
||||||
|
if videoID == "" {
|
||||||
|
if ch.PhoneOnly {
|
||||||
|
return "", "", "", fmt.Errorf("channel %q has no catalog entry — capture it from the phone: drm capture --app %s --channel %s", id, Name, id)
|
||||||
|
}
|
||||||
|
return "", "", "", fmt.Errorf("no video id for channel %q (set video_id or stream_id_field in %s)", id, a.ConfigPath())
|
||||||
|
}
|
||||||
|
if token == "" && ch.TokenField != "" {
|
||||||
|
return label, videoID, "", fmt.Errorf("no playback token for channel %q (field %s)", id, ch.TokenField)
|
||||||
|
}
|
||||||
|
return label, videoID, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve implements app.Catalog: build streamd-ready credentials for a channel
|
||||||
|
// without touching a phone.
|
||||||
|
func (a *App) Resolve(channel string) (session.Stream, error) {
|
||||||
|
id, err := a.Base.Resolve(channel)
|
||||||
|
if err != nil {
|
||||||
|
return session.Stream{}, err
|
||||||
|
}
|
||||||
|
pc, err := fetchPlaybackConfig(a.cfg.PlaybackConfigURL)
|
||||||
|
if err != nil {
|
||||||
|
return session.Stream{}, err
|
||||||
|
}
|
||||||
|
label, videoID, token, err := a.resolveChannel(pc, id)
|
||||||
|
if err != nil {
|
||||||
|
return session.Stream{}, err
|
||||||
|
}
|
||||||
|
vid, err := brightcove.FetchPlayback(a.cfg.AccountID, videoID, token, a.cfg.PolicyKey)
|
||||||
|
if err != nil {
|
||||||
|
return session.Stream{}, err
|
||||||
|
}
|
||||||
|
hls, license := brightcove.PickHLSAndLicense(vid)
|
||||||
|
if hls == "" {
|
||||||
|
return session.Stream{}, fmt.Errorf("no HLS source in playback response for %s", videoID)
|
||||||
|
}
|
||||||
|
title := label
|
||||||
|
if vid.Name != "" {
|
||||||
|
title = vid.Name
|
||||||
|
}
|
||||||
|
return session.Stream{
|
||||||
|
Name: a.cfg.StreamNamePrefix + "-" + strings.ToLower(id),
|
||||||
|
Title: title,
|
||||||
|
App: Name,
|
||||||
|
Channel: strings.ToLower(id),
|
||||||
|
MPD: hls,
|
||||||
|
HeadersJSON: "{}",
|
||||||
|
Rewriter: "none",
|
||||||
|
LicenseURL: license,
|
||||||
|
AccountID: a.cfg.AccountID,
|
||||||
|
VideoID: videoID,
|
||||||
|
PlaybackURL: brightcove.PlaybackURL(a.cfg.AccountID, videoID),
|
||||||
|
ManifestType: "hls",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannels resolves each configured channel's video id for `catalog --list`.
|
||||||
|
func (a *App) ListChannels() ([]appreg.CatalogRow, map[string]bool, error) {
|
||||||
|
pc, err := fetchPlaybackConfig(a.cfg.PlaybackConfigURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rows := make([]appreg.CatalogRow, 0, len(a.cfg.Channels))
|
||||||
|
for _, id := range a.ChannelIDs() {
|
||||||
|
ch := a.cfg.Channels[id]
|
||||||
|
vid := ch.VideoID
|
||||||
|
if vid == "" {
|
||||||
|
vid = pc.str(ch.StreamIDField)
|
||||||
|
}
|
||||||
|
rows = append(rows, appreg.CatalogRow{ID: id, CatalogID: vid, Label: a.Label(id)})
|
||||||
|
}
|
||||||
|
return rows, pc.Flags(), nil
|
||||||
|
}
|
||||||
172
apps/modules/tg4/config.go
Normal file
172
apps/modules/tg4/config.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name is the module id (--app tg4).
|
||||||
|
const Name = "tg4"
|
||||||
|
|
||||||
|
// Channel is one live channel. VideoID may be empty when the id must be read from
|
||||||
|
// the remote playback config instead.
|
||||||
|
type Channel struct {
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
// VideoID is the Brightcove video id for this live.
|
||||||
|
VideoID string `yaml:"video_id"`
|
||||||
|
// TokenField names the playback-config field holding this live's playback token.
|
||||||
|
TokenField string `yaml:"token_field"`
|
||||||
|
// StreamIDField names the playback-config field holding this live's video id,
|
||||||
|
// used when VideoID is empty.
|
||||||
|
StreamIDField string `yaml:"stream_id_field"`
|
||||||
|
// LiveCardIndex is this channel's position in the app's Live card list.
|
||||||
|
LiveCardIndex int `yaml:"live_card_index"`
|
||||||
|
// PhoneOnly marks a channel with no catalog entry — capture via the phone UI.
|
||||||
|
PhoneOnly bool `yaml:"phone_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is apps/modules/tg4/module.yaml.
|
||||||
|
type Config struct {
|
||||||
|
provider.Common `yaml:",inline"`
|
||||||
|
|
||||||
|
// Brightcove account credentials. Secrets: never defaulted in code.
|
||||||
|
AccountID string `yaml:"account_id"`
|
||||||
|
PolicyKey string `yaml:"policy_key"`
|
||||||
|
PlaybackConfigURL string `yaml:"playback_config_url"`
|
||||||
|
|
||||||
|
Channels map[string]Channel `yaml:"channels"`
|
||||||
|
|
||||||
|
// StreamNamePrefix prefixes generated stream names (default: module name).
|
||||||
|
StreamNamePrefix string `yaml:"stream_name_prefix"`
|
||||||
|
|
||||||
|
Cards uiflow.CardOpts `yaml:"cards"`
|
||||||
|
UI UI `yaml:"ui"`
|
||||||
|
Timeouts Timeouts `yaml:"timeouts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI holds the markers and fallbacks the Live-page navigation uses.
|
||||||
|
type UI struct {
|
||||||
|
// LaunchDescContains: any of these content-descriptions means the app is up.
|
||||||
|
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||||
|
// LaunchResourceContains: any of these view ids means the app is up. More
|
||||||
|
// stable than descriptions, which are localized.
|
||||||
|
LaunchResourceContains []string `yaml:"launch_resource_contains"`
|
||||||
|
// LiveLabel is the menu/title text identifying the Live page.
|
||||||
|
LiveLabel string `yaml:"live_label"`
|
||||||
|
// LiveTitleResource marks the Live page title node.
|
||||||
|
LiveTitleResource string `yaml:"live_title_resource"`
|
||||||
|
// DrawerDesc are the content-descriptions of the drawer (hamburger) button.
|
||||||
|
DrawerDesc []string `yaml:"drawer_desc"`
|
||||||
|
// DrawerFallbackX/Y is tapped when the drawer button cannot be identified.
|
||||||
|
DrawerFallbackX int `yaml:"drawer_fallback_x"`
|
||||||
|
DrawerFallbackY int `yaml:"drawer_fallback_y"`
|
||||||
|
// MenuMaxX / MenuMinY / MenuMaxY bound where a drawer menu entry can sit.
|
||||||
|
MenuMaxX int `yaml:"menu_max_x"`
|
||||||
|
MenuMinY int `yaml:"menu_min_y"`
|
||||||
|
MenuMaxY int `yaml:"menu_max_y"`
|
||||||
|
// TitleMaxY bounds where the Live page title can sit.
|
||||||
|
TitleMaxY int `yaml:"title_max_y"`
|
||||||
|
// PlaybackDescContains / PlaybackResourceContains are player-up signals.
|
||||||
|
PlaybackDescContains []string `yaml:"playback_desc_contains"`
|
||||||
|
PlaybackResourceContains []string `yaml:"playback_resource_contains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeouts for the launch / autoplay sequence. Written as "25s" in module.yaml.
|
||||||
|
type Timeouts struct {
|
||||||
|
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||||
|
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||||
|
LiveNav provider.Duration `yaml:"live_nav"`
|
||||||
|
Card provider.Duration `yaml:"card"`
|
||||||
|
AfterCard provider.Duration `yaml:"after_card"`
|
||||||
|
Playback provider.Duration `yaml:"playback"`
|
||||||
|
AfterLiveTap provider.Duration `yaml:"after_live_tap"`
|
||||||
|
AfterDrawer provider.Duration `yaml:"after_drawer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
if c.StreamNamePrefix == "" {
|
||||||
|
c.StreamNamePrefix = Name
|
||||||
|
}
|
||||||
|
c.Cards = c.Cards.WithDefaults()
|
||||||
|
if len(c.Cards.DescDeny) == 0 {
|
||||||
|
c.Cards.DescDeny = []string{"Search", "Profile", "Cast", "Open", "Closed"}
|
||||||
|
}
|
||||||
|
if len(c.Cards.TextDeny) == 0 {
|
||||||
|
c.Cards.TextDeny = []string{"catch-up"}
|
||||||
|
}
|
||||||
|
u := &c.UI
|
||||||
|
if len(u.LaunchDescContains) == 0 && len(u.LaunchResourceContains) == 0 {
|
||||||
|
u.LaunchDescContains = []string{"live", "home"}
|
||||||
|
}
|
||||||
|
if u.LiveLabel == "" {
|
||||||
|
u.LiveLabel = "live"
|
||||||
|
}
|
||||||
|
if u.LiveTitleResource == "" {
|
||||||
|
u.LiveTitleResource = "live_fragment_text_view"
|
||||||
|
}
|
||||||
|
if len(u.DrawerDesc) == 0 {
|
||||||
|
u.DrawerDesc = []string{"Open", "Closed"}
|
||||||
|
}
|
||||||
|
if u.DrawerFallbackX == 0 {
|
||||||
|
u.DrawerFallbackX = 68
|
||||||
|
}
|
||||||
|
if u.DrawerFallbackY == 0 {
|
||||||
|
u.DrawerFallbackY = 183
|
||||||
|
}
|
||||||
|
if u.MenuMaxX == 0 {
|
||||||
|
u.MenuMaxX = 700
|
||||||
|
}
|
||||||
|
if u.MenuMinY == 0 {
|
||||||
|
u.MenuMinY = 250
|
||||||
|
}
|
||||||
|
if u.MenuMaxY == 0 {
|
||||||
|
u.MenuMaxY = 700
|
||||||
|
}
|
||||||
|
if u.TitleMaxY == 0 {
|
||||||
|
u.TitleMaxY = 400
|
||||||
|
}
|
||||||
|
if len(u.PlaybackDescContains) == 0 {
|
||||||
|
u.PlaybackDescContains = []string{"video player"}
|
||||||
|
}
|
||||||
|
if len(u.PlaybackResourceContains) == 0 {
|
||||||
|
u.PlaybackResourceContains = []string{"brightcove_video_view"}
|
||||||
|
}
|
||||||
|
t := &c.Timeouts
|
||||||
|
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(30 * time.Second))
|
||||||
|
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(5 * time.Second))
|
||||||
|
t.LiveNav = provider.Duration(t.LiveNav.D(25 * time.Second))
|
||||||
|
t.Card = provider.Duration(t.Card.D(25 * time.Second))
|
||||||
|
t.AfterCard = provider.Duration(t.AfterCard.D(3 * time.Second))
|
||||||
|
t.Playback = provider.Duration(t.Playback.D(60 * time.Second))
|
||||||
|
t.AfterLiveTap = provider.Duration(t.AfterLiveTap.D(3 * time.Second))
|
||||||
|
t.AfterDrawer = provider.Duration(t.AfterDrawer.D(1200 * time.Millisecond))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag overrides, bound before any app is constructed.
|
||||||
|
var flags struct {
|
||||||
|
packageName string
|
||||||
|
accountID string
|
||||||
|
policyKey string
|
||||||
|
playbackConfigURL string
|
||||||
|
caHash string
|
||||||
|
proxyBin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (Config, modcfg.Result, error) {
|
||||||
|
var cfg Config
|
||||||
|
res, err := modcfg.Load(Name, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, res, err
|
||||||
|
}
|
||||||
|
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||||
|
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||||
|
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||||
|
cfg.AccountID = modcfg.Override(Name, "account_id", flags.accountID, cfg.AccountID)
|
||||||
|
cfg.PolicyKey = modcfg.Override(Name, "policy_key", flags.policyKey, cfg.PolicyKey)
|
||||||
|
cfg.PlaybackConfigURL = modcfg.Override(Name, "playback_config_url", flags.playbackConfigURL, cfg.PlaybackConfigURL)
|
||||||
|
return cfg.withDefaults(), res, nil
|
||||||
|
}
|
||||||
62
apps/modules/tg4/keys.go
Normal file
62
apps/modules/tg4/keys.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"drmdecryption/brightcove"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
"drmdecryption/session"
|
||||||
|
"drmdecryption/wvkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrichWithKeys fetches the HLS master, extracts the Widevine PSSH and resolves
|
||||||
|
// every content key through the local CDM. Brightcove returns multiple CONTENT
|
||||||
|
// keys and a downloader needs all of them.
|
||||||
|
func (a *App) EnrichWithKeys(info *session.Stream, python, wvd string) error {
|
||||||
|
if info == nil || info.MPD == "" {
|
||||||
|
return fmt.Errorf("missing manifest URL")
|
||||||
|
}
|
||||||
|
if info.LicenseURL == "" {
|
||||||
|
return fmt.Errorf("missing license_url")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(wvd) == "" {
|
||||||
|
return fmt.Errorf("key fetch requires --wvd (path to .wvd device file)")
|
||||||
|
}
|
||||||
|
body, err := brightcove.Get(info.MPD)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hls master: %w", err)
|
||||||
|
}
|
||||||
|
pssh := brightcove.ExtractWidevinePSSH(body)
|
||||||
|
if pssh == "" {
|
||||||
|
return fmt.Errorf("no Widevine PSSH in HLS master")
|
||||||
|
}
|
||||||
|
info.PSSH = pssh
|
||||||
|
info.PrimaryKID = brightcove.PrimaryKID(body)
|
||||||
|
|
||||||
|
all, err := wvkey.FetchRawAll(wvkey.Options{
|
||||||
|
Python: python,
|
||||||
|
Script: filepath.Join(repo.Root(), "apps", "wvkey", "wvkey.py"),
|
||||||
|
WVD: wvd,
|
||||||
|
PSSH: pssh,
|
||||||
|
LicenseURL: info.LicenseURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return fmt.Errorf("license returned no content keys")
|
||||||
|
}
|
||||||
|
info.Keys = all
|
||||||
|
info.Key = all[0]
|
||||||
|
if info.PrimaryKID != "" {
|
||||||
|
for _, k := range all {
|
||||||
|
if strings.HasPrefix(strings.ToLower(k), info.PrimaryKID+":") {
|
||||||
|
info.Key = k
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
152
apps/modules/tg4/steps.go
Normal file
152
apps/modules/tg4/steps.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openLivePage navigates to the Live page: if a menu entry is visible tap it,
|
||||||
|
// otherwise open the side drawer first. Ported from the Starlark hook this module
|
||||||
|
// used to carry, with every selector and coordinate now coming from config.
|
||||||
|
func (a *App) openLivePage(c *adb.Client) error {
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
nodes, err := c.DumpUI(a.CacheDir())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.onLivePage(nodes) {
|
||||||
|
fmt.Println("[*] already on Live")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if live := a.findMenuLive(nodes); live != nil {
|
||||||
|
fmt.Printf("[*] tap Live @ %d,%d\n", live.CX(), live.CY())
|
||||||
|
_ = c.Tap(live.CX(), live.CY())
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTap))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.openDrawer(c) {
|
||||||
|
fmt.Printf("[*] drawer fallback tap %d,%d\n", a.cfg.UI.DrawerFallbackX, a.cfg.UI.DrawerFallbackY)
|
||||||
|
_ = c.Tap(a.cfg.UI.DrawerFallbackX, a.cfg.UI.DrawerFallbackY)
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterDrawer))
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Duration(a.cfg.Timeouts.LiveNav))
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
nodes, err := c.DumpUI(a.CacheDir())
|
||||||
|
if err == nil {
|
||||||
|
if live := a.findMenuLive(nodes); live != nil {
|
||||||
|
fmt.Printf("[*] tap Live @ %d,%d\n", live.CX(), live.CY())
|
||||||
|
_ = c.Tap(live.CX(), live.CY())
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTap))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a.openDrawer(c)
|
||||||
|
}
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline = time.Now().Add(time.Duration(a.cfg.Timeouts.LiveNav))
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if nodes, err := c.DumpUI(a.CacheDir()); err == nil && a.onLivePage(nodes) {
|
||||||
|
fmt.Printf("[*] Live page ready\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Live page did not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
// onLivePage is true when the Live title is showing and at least one content card
|
||||||
|
// is on screen.
|
||||||
|
func (a *App) onLivePage(nodes []adb.Node) bool {
|
||||||
|
hasTitle := false
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(n.ResourceID, a.cfg.UI.LiveTitleResource) ||
|
||||||
|
(n.Y1 < a.cfg.UI.TitleMaxY && !n.Clickable) {
|
||||||
|
hasTitle = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTitle {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(uiflow.Cards(nodes, a.cfg.Cards)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDrawer opens the side menu. Returns false when the drawer button could not
|
||||||
|
// be identified, so the caller can fall back to a fixed tap.
|
||||||
|
func (a *App) openDrawer(c *adb.Client) bool {
|
||||||
|
nodes, err := c.DumpUI(a.CacheDir())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Already open?
|
||||||
|
for _, n := range nodes {
|
||||||
|
desc := strings.TrimSpace(n.Desc)
|
||||||
|
for _, d := range a.cfg.UI.DrawerDesc {
|
||||||
|
if desc == d && d == "Closed" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) &&
|
||||||
|
n.Clickable && n.X2 < a.cfg.UI.MenuMaxX {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ham := a.findDrawerButton(nodes)
|
||||||
|
if ham == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] tap menu %q @ %d,%d\n", ham.Desc, ham.CX(), ham.CY())
|
||||||
|
_ = c.Tap(ham.CX(), ham.CY())
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterDrawer))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) findDrawerButton(nodes []adb.Node) *adb.Node {
|
||||||
|
for i := range nodes {
|
||||||
|
n := &nodes[i]
|
||||||
|
if !n.Clickable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := strings.TrimSpace(n.Desc)
|
||||||
|
for _, d := range a.cfg.UI.DrawerDesc {
|
||||||
|
if desc == d {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Geometric fallback: a small clickable node in the top-left corner.
|
||||||
|
for i := range nodes {
|
||||||
|
n := &nodes[i]
|
||||||
|
if n.Clickable && n.X1 < 50 && n.Y1 < 200 && n.X2 < 200 && n.Y2 < 300 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMenuLive locates the drawer's Live entry.
|
||||||
|
func (a *App) findMenuLive(nodes []adb.Node) *adb.Node {
|
||||||
|
for i := range nodes {
|
||||||
|
n := &nodes[i]
|
||||||
|
if !n.Clickable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.X2 <= a.cfg.UI.MenuMaxX && n.Y1 > a.cfg.UI.MenuMinY && n.Y1 < a.cfg.UI.MenuMaxY {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
125
apps/modules/tg4/tg4.go
Normal file
125
apps/modules/tg4/tg4.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Package tg4 is the TG4 app module: Brightcove HLS with a raw Widevine license.
|
||||||
|
// Its channels can be resolved from the public catalog (no phone), or captured
|
||||||
|
// through the phone MITM. Account id, policy key, video ids and the playback
|
||||||
|
// config URL all come from the local, untracked apps/modules/tg4/module.yaml, a
|
||||||
|
// flag, or the environment — never from this source.
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
"drmdecryption/uiflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
appreg.Register(Name, New)
|
||||||
|
appreg.RegisterFlags(bindFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindFlags(fs *flag.FlagSet) {
|
||||||
|
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||||
|
fs.StringVar(&flags.accountID, Name+".account-id", "", "override "+Name+" Brightcove account id")
|
||||||
|
fs.StringVar(&flags.policyKey, Name+".policy-key", "", "override "+Name+" Brightcove policy key")
|
||||||
|
fs.StringVar(&flags.playbackConfigURL, Name+".playback-config-url", "", "override "+Name+" playback config URL")
|
||||||
|
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||||
|
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is the TG4 app plugin.
|
||||||
|
type App struct {
|
||||||
|
*provider.Base
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the plugin, loading values from module.yaml + flags + env.
|
||||||
|
func New() (appreg.App, error) {
|
||||||
|
cfg, res, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
labels := make(map[string]string, len(cfg.Channels))
|
||||||
|
for id, ch := range cfg.Channels {
|
||||||
|
labels[id] = ch.Label
|
||||||
|
}
|
||||||
|
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMode: Brightcove serves a raw octet-stream Widevine license.
|
||||||
|
func (a *App) KeyMode() string { return "raw" }
|
||||||
|
|
||||||
|
// CaptureHints: the MITM yields the license URL and HLS master; the PSSH is read
|
||||||
|
// from the master afterwards.
|
||||||
|
func (a *App) CaptureHints() capture.Hints {
|
||||||
|
return a.Hints("license_url", "mpd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPDRewriter: HLS needs no manifest rewrite.
|
||||||
|
func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||||
|
|
||||||
|
// Channels lists the configured live channels.
|
||||||
|
func (a *App) Channels() []appreg.Channel {
|
||||||
|
out := make([]appreg.Channel, 0, len(a.cfg.Channels))
|
||||||
|
for _, id := range a.ChannelIDs() {
|
||||||
|
out = append(out, appreg.Channel{ID: id, Label: a.Label(id)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch cold-starts the app and waits for its home UI.
|
||||||
|
func (a *App) Launch(c *adb.Client) error {
|
||||||
|
c.EnsureAwake()
|
||||||
|
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||||
|
c.ForceStop(a.Package())
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
if err := uiflow.MonkeyLaunch(c, a.Package()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.LaunchSettle))
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
return uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{
|
||||||
|
DescContains: a.cfg.UI.LaunchDescContains,
|
||||||
|
ResourceContains: a.cfg.UI.LaunchResourceContains,
|
||||||
|
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPlay opens the Live page and taps this channel's card.
|
||||||
|
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||||
|
id, err := a.Base.Resolve(channel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ch := a.cfg.Channels[id]
|
||||||
|
fmt.Printf("[*] Auto-play %s…\n", a.Label(id))
|
||||||
|
|
||||||
|
if err := a.openLivePage(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uiflow.TapCard(c, a.CacheDir(), ch.LiveCardIndex, time.Duration(a.cfg.Timeouts.Card), a.cfg.Cards); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(a.cfg.Timeouts.AfterCard))
|
||||||
|
|
||||||
|
// Soft-fail: this player often starts without a dumpsys audio session, so a
|
||||||
|
// UI signal is the real confirmation and capture should continue regardless.
|
||||||
|
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||||
|
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||||
|
SoftFail: true,
|
||||||
|
OrDescContains: a.cfg.UI.PlaybackDescContains,
|
||||||
|
OrResourceContains: a.cfg.UI.PlaybackResourceContains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDefaults — multi-KID SAMPLE-AES HLS: take the best variant and give the
|
||||||
|
// muxer more bytes before probing, since early audio config is often incomplete.
|
||||||
|
func (a *App) VideoSelect() string { return "for=best" }
|
||||||
|
func (a *App) AudioSelect() string { return "for=best" }
|
||||||
|
func (a *App) RewriterName() string { return "none" }
|
||||||
|
func (a *App) LiveWaitSeconds() int { return 6 }
|
||||||
|
func (a *App) TSReadyBytes() int64 { return 2 << 20 }
|
||||||
129
apps/modules/tg4/tg4_test.go
Normal file
129
apps/modules/tg4/tg4_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package tg4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appreg "drmdecryption/app"
|
||||||
|
"drmdecryption/modcfg"
|
||||||
|
"drmdecryption/modules/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time proof the plugin satisfies every interface the host expects.
|
||||||
|
var (
|
||||||
|
_ appreg.App = (*App)(nil)
|
||||||
|
_ appreg.Catalog = (*App)(nil)
|
||||||
|
_ appreg.StreamDefaults = (*App)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func testApp(t *testing.T) *App {
|
||||||
|
t.Helper()
|
||||||
|
cfg := Config{
|
||||||
|
AccountID: "acct",
|
||||||
|
PolicyKey: "pk",
|
||||||
|
PlaybackConfigURL: "https://cfg.test/playback.json",
|
||||||
|
Channels: map[string]Channel{
|
||||||
|
"main": {Label: "Main", VideoID: "111", TokenField: "mainToken", LiveCardIndex: 0},
|
||||||
|
"extra": {Label: "Extra", StreamIDField: "extraStreamId", TokenField: "extraToken", LiveCardIndex: 1},
|
||||||
|
"phone": {Label: "Phone only", PhoneOnly: true, LiveCardIndex: 3},
|
||||||
|
},
|
||||||
|
Common: provider.Common{
|
||||||
|
Aliases: map[string]string{"primary": "main", "second": "extra"},
|
||||||
|
},
|
||||||
|
}.withDefaults()
|
||||||
|
labels := map[string]string{}
|
||||||
|
for id, ch := range cfg.Channels {
|
||||||
|
labels[id] = ch.Label
|
||||||
|
}
|
||||||
|
return &App{Base: provider.New(Name, cfg.Common, labels, modcfg.Result{Path: "test"}), cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasesResolveFromConfig(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"main": "main", "MAIN": "main", "primary": "main",
|
||||||
|
"second": "extra", "extra": "extra",
|
||||||
|
} {
|
||||||
|
got, err := a.Base.Resolve(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve %q: %v", in, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := a.Base.Resolve("nope"); err == nil {
|
||||||
|
t.Error("unknown channel must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A video id pinned in config wins; otherwise it is read from the named remote
|
||||||
|
// playback-config field. Both paths are data-driven, no channel name switch.
|
||||||
|
func TestResolveChannelReadsConfiguredFields(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
pc := playbackConfig{
|
||||||
|
"mainToken": "tok-main",
|
||||||
|
"extraToken": "tok-extra",
|
||||||
|
"extraStreamId": "222",
|
||||||
|
}
|
||||||
|
_, vid, tok, err := a.resolveChannel(pc, "main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("main: %v", err)
|
||||||
|
}
|
||||||
|
if vid != "111" || tok != "tok-main" {
|
||||||
|
t.Errorf("main → video %q token %q", vid, tok)
|
||||||
|
}
|
||||||
|
_, vid, tok, err = a.resolveChannel(pc, "extra")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extra: %v", err)
|
||||||
|
}
|
||||||
|
if vid != "222" || tok != "tok-extra" {
|
||||||
|
t.Errorf("extra → video %q token %q", vid, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phone-only channel must say so rather than fail with a confusing catalog error.
|
||||||
|
func TestPhoneOnlyChannelExplainsItself(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
_, _, _, err := a.resolveChannel(playbackConfig{}, "phone")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("phone-only channel must not resolve from the catalog")
|
||||||
|
}
|
||||||
|
if want := "drm capture"; !contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q should point at %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackConfigCoercesNumericIDs(t *testing.T) {
|
||||||
|
pc := playbackConfig{"a": "123", "b": float64(456), "c": true}
|
||||||
|
if pc.str("a") != "123" {
|
||||||
|
t.Error("string field")
|
||||||
|
}
|
||||||
|
if pc.str("b") != "456" {
|
||||||
|
t.Errorf("numeric field = %q, want 456", pc.str("b"))
|
||||||
|
}
|
||||||
|
if pc.str("missing") != "" || pc.str("") != "" {
|
||||||
|
t.Error("missing field must be empty")
|
||||||
|
}
|
||||||
|
if !pc.Flags()["c"] {
|
||||||
|
t.Error("bool flags should be reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No provider identity may be compiled in.
|
||||||
|
func TestNoHardcodedCredentials(t *testing.T) {
|
||||||
|
cfg := Config{}.withDefaults()
|
||||||
|
if cfg.AccountID != "" || cfg.PolicyKey != "" || cfg.PlaybackConfigURL != "" || cfg.Package != "" {
|
||||||
|
t.Fatal("account id / policy key / config URL / package must come from module.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
37
apps/pkg/README.md
Normal file
37
apps/pkg/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# pkg — shared Go libraries
|
||||||
|
|
||||||
|
Imported by `apps/capture`, `apps/agent`, `apps/streamd`, `apps/proxy`, `apps/cli`
|
||||||
|
and `apps/modules` as module `drmdecryption` via:
|
||||||
|
|
||||||
|
```go
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---------|------|
|
||||||
|
| `adb` | ADB client (shell, push/pull, UI dump, tap) |
|
||||||
|
| `app` | App plugin interface + registry (+ optional Catalog / StreamDefaults) |
|
||||||
|
| `uiflow` | Phone-UI primitives app modules drive playback with |
|
||||||
|
| `modcfg` | Load an app module's local `module.yaml` + env overrides |
|
||||||
|
| `proxy` | Push/start/stop appproxy, CA install/reinject, pull captures |
|
||||||
|
| `phonecap` | One phone MITM job (proxy → launch → autoplay → wvkey → session) |
|
||||||
|
| `wvkey` | Shells out to `apps/wvkey/wvkey.py` |
|
||||||
|
| `brightcove` | Brightcove Playback API + HLS Widevine extraction (platform, not provider) |
|
||||||
|
| `capture` | Parse on-device capture JSON + score candidate manifest URLs |
|
||||||
|
| `session` | Write `outputs/<app>/<stamp>/` |
|
||||||
|
| `mpd` | Manifest rewriter interface, registry, synthetic live MPD builder, local server |
|
||||||
|
| `repo` | Find repo root (`apps/pkg/go.mod`) |
|
||||||
|
|
||||||
|
Nothing here names a streaming provider. Provider logic lives in `apps/modules/<name>`;
|
||||||
|
provider values live in that module's gitignored `module.yaml`.
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Go must be on PATH (or set GOROOT)
|
||||||
|
go -C apps/pkg test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
No binary of its own — everything ships in `bin/drm`, built from `apps/cli`.
|
||||||
374
apps/pkg/adb/adb.go
Normal file
374
apps/pkg/adb/adb.go
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
package adb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps the adb CLI. When Serial is set, every invocation uses -s SERIAL.
|
||||||
|
type Client struct {
|
||||||
|
Bin string
|
||||||
|
Serial string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
bin := "adb"
|
||||||
|
if p, err := exec.LookPath("adb"); err == nil {
|
||||||
|
bin = p
|
||||||
|
}
|
||||||
|
return &Client{Bin: bin}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSerial returns a copy that targets one device.
|
||||||
|
func (c *Client) WithSerial(serial string) *Client {
|
||||||
|
out := *c
|
||||||
|
out.Serial = strings.TrimSpace(serial)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) prefix(args []string) []string {
|
||||||
|
if c.Serial == "" {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
return append([]string{"-s", c.Serial}, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Run(args ...string) (string, string, error) {
|
||||||
|
cmd := exec.Command(c.Bin, c.prefix(args)...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
return stdout.String(), stderr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Out(args ...string) string {
|
||||||
|
out, _, _ := c.Run(args...)
|
||||||
|
return strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) EnsureDevice() error {
|
||||||
|
state := c.Out("get-state")
|
||||||
|
if state != "device" {
|
||||||
|
if c.Serial != "" {
|
||||||
|
return fmt.Errorf("adb device %s not ready (state=%q)", c.Serial, state)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("adb device not ready (state=%q)", state)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is one row from `adb devices -l`.
|
||||||
|
type Device struct {
|
||||||
|
Serial string
|
||||||
|
State string
|
||||||
|
Model string
|
||||||
|
Product string
|
||||||
|
USB string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDevices returns every adb device row (any state). Uses a serial-less client.
|
||||||
|
func ListDevices(bin string) ([]Device, error) {
|
||||||
|
if bin == "" {
|
||||||
|
bin = New().Bin
|
||||||
|
}
|
||||||
|
cmd := exec.Command(bin, "devices", "-l")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("adb devices: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
var out []Device
|
||||||
|
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "List of devices") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := Device{Serial: fields[0], State: fields[1]}
|
||||||
|
for _, f := range fields[2:] {
|
||||||
|
if strings.HasPrefix(f, "model:") {
|
||||||
|
d.Model = strings.TrimPrefix(f, "model:")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(f, "product:") {
|
||||||
|
d.Product = strings.TrimPrefix(f, "product:")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(f, "usb:") {
|
||||||
|
d.USB = strings.TrimPrefix(f, "usb:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageInstalled is true when `pm path <pkg>` returns a path. Never installs.
|
||||||
|
func (c *Client) PackageInstalled(pkg string) bool {
|
||||||
|
pkg = strings.TrimSpace(pkg)
|
||||||
|
if pkg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out := c.Out("shell", "pm", "path", pkg)
|
||||||
|
return strings.Contains(out, "package:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceStop stops the package so the phone is free for the next job.
|
||||||
|
func (c *Client) ForceStop(pkg string) {
|
||||||
|
if pkg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("am", "force-stop", pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAwake wakes the screen. Call this BEFORE launching the target app.
|
||||||
|
// It must not press HOME or swipe after the app is open — dumpsys always
|
||||||
|
// contains "StatusBar", and a HOME key looks like "the app just exits".
|
||||||
|
func (c *Client) EnsureAwake() {
|
||||||
|
_, _ = c.Shell("input", "keyevent", "KEYCODE_WAKEUP")
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
_, _ = c.Shell("wm", "dismiss-keyguard")
|
||||||
|
|
||||||
|
if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) {
|
||||||
|
_, _ = c.Shell("input", "swipe", "540", "2000", "540", "600", "300")
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
c.DismissShadeIfFocused()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentFocusIsLockOrShade(dumpsys string) bool {
|
||||||
|
for _, line := range strings.Split(dumpsys, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.Contains(line, "mCurrentFocus=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
low := strings.ToLower(line)
|
||||||
|
return strings.Contains(low, "notificationshade") ||
|
||||||
|
strings.Contains(low, "keyguard") ||
|
||||||
|
strings.Contains(low, "lockscreen")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissShadeIfFocused presses BACK only when the notification shade has focus.
|
||||||
|
func (c *Client) DismissShadeIfFocused() {
|
||||||
|
if currentFocusIsLockOrShade(c.Out("shell", "dumpsys", "window")) {
|
||||||
|
_, _ = c.Shell("input", "keyevent", "KEYCODE_BACK")
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Shell(args ...string) (string, error) {
|
||||||
|
all := append([]string{"shell"}, args...)
|
||||||
|
out, errOut, err := c.Run(all...)
|
||||||
|
if err != nil {
|
||||||
|
if strings.TrimSpace(errOut) != "" {
|
||||||
|
return out, fmt.Errorf("%w: %s", err, strings.TrimSpace(errOut))
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Push(local, remote string) error {
|
||||||
|
_, errOut, err := c.Run("push", local, remote)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adb push: %w (%s)", err, strings.TrimSpace(errOut))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Pull(remote, local string) error {
|
||||||
|
_, errOut, err := c.Run("pull", remote, local)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adb pull: %w (%s)", err, strings.TrimSpace(errOut))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Tap(x, y int) error {
|
||||||
|
_, err := c.Shell("input", "tap", strconv.Itoa(x), strconv.Itoa(y))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe performs an input swipe over durationMs milliseconds.
|
||||||
|
func (c *Client) Swipe(x1, y1, x2, y2, durationMs int) error {
|
||||||
|
if durationMs <= 0 {
|
||||||
|
durationMs = 300
|
||||||
|
}
|
||||||
|
_, err := c.Shell("input", "swipe",
|
||||||
|
strconv.Itoa(x1), strconv.Itoa(y1),
|
||||||
|
strconv.Itoa(x2), strconv.Itoa(y2),
|
||||||
|
strconv.Itoa(durationMs))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is one uiautomator element.
|
||||||
|
type Node struct {
|
||||||
|
Text string
|
||||||
|
Desc string
|
||||||
|
Class string
|
||||||
|
ResourceID string
|
||||||
|
Clickable bool
|
||||||
|
X1, Y1 int
|
||||||
|
X2, Y2 int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n Node) CX() int { return (n.X1 + n.X2) / 2 }
|
||||||
|
func (n Node) CY() int { return (n.Y1 + n.Y2) / 2 }
|
||||||
|
func (n Node) Area() int {
|
||||||
|
return (n.X2 - n.X1) * (n.Y2 - n.Y1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reNode = regexp.MustCompile(`<node [^>]+>`)
|
||||||
|
reAttr = func(k string) *regexp.Regexp { return regexp.MustCompile(k + `="([^"]*)"`) }
|
||||||
|
reBounds = regexp.MustCompile(`\[(\d+),(\d+)\]\[(\d+),(\d+)\]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseNodes(xml string) []Node {
|
||||||
|
var out []Node
|
||||||
|
for _, m := range reNode.FindAllString(xml, -1) {
|
||||||
|
attr := func(k string) string {
|
||||||
|
mm := reAttr(k).FindStringSubmatch(m)
|
||||||
|
if len(mm) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mm[1]
|
||||||
|
}
|
||||||
|
b := attr("bounds")
|
||||||
|
bm := reBounds.FindStringSubmatch(b)
|
||||||
|
if len(bm) != 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x1, _ := strconv.Atoi(bm[1])
|
||||||
|
y1, _ := strconv.Atoi(bm[2])
|
||||||
|
x2, _ := strconv.Atoi(bm[3])
|
||||||
|
y2, _ := strconv.Atoi(bm[4])
|
||||||
|
out = append(out, Node{
|
||||||
|
Text: attr("text"),
|
||||||
|
Desc: attr("content-desc"),
|
||||||
|
Class: attr("class"),
|
||||||
|
ResourceID: attr("resource-id"),
|
||||||
|
Clickable: attr("clickable") == "true",
|
||||||
|
X1: x1, Y1: y1, X2: x2, Y2: y2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DumpUI pulls a uiautomator hierarchy.
|
||||||
|
func (c *Client) DumpUI(cacheDir string) ([]Node, error) {
|
||||||
|
remote := "/sdcard/streamd_uidump.xml"
|
||||||
|
if _, err := c.Shell("uiautomator", "dump", remote); err != nil {
|
||||||
|
// dump often returns exit 0 with message; ignore soft failures
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
local := cacheDir + string(os.PathSeparator) + "uidump.xml"
|
||||||
|
if err := c.Pull(remote, local); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseNodes(string(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLargest returns the largest node matching any text/desc regex.
|
||||||
|
func FindLargest(nodes []Node, textPats, descPats []*regexp.Regexp) *Node {
|
||||||
|
var hits []Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
ok := false
|
||||||
|
if n.Text != "" {
|
||||||
|
for _, p := range textPats {
|
||||||
|
if p.MatchString(n.Text) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Desc != "" {
|
||||||
|
for _, p := range descPats {
|
||||||
|
if p.MatchString(n.Desc) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
hits = append(hits, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
best := hits[0]
|
||||||
|
for _, h := range hits[1:] {
|
||||||
|
if h.Area() > best.Area() {
|
||||||
|
best = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &best
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSmallest prefers tiny chips over hero buttons.
|
||||||
|
func FindSmallest(nodes []Node, descPats []*regexp.Regexp) *Node {
|
||||||
|
var hits []Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Desc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range descPats {
|
||||||
|
if p.MatchString(n.Desc) {
|
||||||
|
hits = append(hits, n)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
best := hits[0]
|
||||||
|
for _, h := range hits[1:] {
|
||||||
|
if h.Area() < best.Area() {
|
||||||
|
best = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &best
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitFor polls DumpUI until a match appears or timeout.
|
||||||
|
func (c *Client) WaitFor(cacheDir string, textPats, descPats []*regexp.Regexp, timeout time.Duration) (*Node, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
nodes, err := c.DumpUI(cacheDir)
|
||||||
|
if err == nil {
|
||||||
|
if n := FindLargest(nodes, textPats, descPats); n != nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ui node not found within %s", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackActive is true when the package media session is PLAYING.
|
||||||
|
func (c *Client) PlaybackActive(pkg string) bool {
|
||||||
|
out := c.Out("shell", "dumpsys", "media_session")
|
||||||
|
if !strings.Contains(out, pkg) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(out, "state=PLAYING") || strings.Contains(out, "PLAYING(")
|
||||||
|
}
|
||||||
154
apps/pkg/app/app.go
Normal file
154
apps/pkg/app/app.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
"drmdecryption/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Channel is a named live target inside an app (e.g. rteone).
|
||||||
|
type Channel struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is a per-streaming-app plugin, compiled in from apps/modules/<name>. The
|
||||||
|
// capture core stays generic: an app supplies its package name, UI automation,
|
||||||
|
// capture hints, key mode and optional MPD rewrite.
|
||||||
|
type App interface {
|
||||||
|
Name() string
|
||||||
|
Package() string
|
||||||
|
LicenseURL() string
|
||||||
|
Launch(c *adb.Client) error
|
||||||
|
AutoPlay(c *adb.Client, channel string) error
|
||||||
|
CaptureHints() capture.Hints
|
||||||
|
MPDRewriter() mpd.Rewriter
|
||||||
|
Channels() []Channel
|
||||||
|
HasChannel(id string) bool
|
||||||
|
|
||||||
|
// ProxyBin is the on-device MITM binary to push.
|
||||||
|
ProxyBin() string
|
||||||
|
// CAHash is the MITM CA subject hash to reinject.
|
||||||
|
CAHash() string
|
||||||
|
// KeyMode selects the wvkey mode: "modulardrm" or "raw".
|
||||||
|
KeyMode() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog is implemented by apps whose channels can be resolved without a phone
|
||||||
|
// (a public playback catalog). Drives the `catalog` subcommand.
|
||||||
|
type Catalog interface {
|
||||||
|
// Resolve returns streamd-ready credentials for a channel id.
|
||||||
|
Resolve(channel string) (session.Stream, error)
|
||||||
|
// EnrichWithKeys fills PSSH + KID:KEY using the local CDM.
|
||||||
|
EnrichWithKeys(info *session.Stream, python, wvd string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogRow is one channel in a catalog listing.
|
||||||
|
type CatalogRow struct {
|
||||||
|
ID string
|
||||||
|
CatalogID string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogLister is the optional richer listing a catalog app may provide: each
|
||||||
|
// channel's resolved catalog id plus whatever feature flags the provider
|
||||||
|
// publishes alongside it.
|
||||||
|
type CatalogLister interface {
|
||||||
|
ListChannels() (rows []CatalogRow, flags map[string]bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDefaults is implemented by apps that need particular downloader settings.
|
||||||
|
// It replaces name-sniffing in the media supervisor.
|
||||||
|
type StreamDefaults interface {
|
||||||
|
// VideoSelect / AudioSelect are N_m3u8DL-RE selector expressions.
|
||||||
|
VideoSelect() string
|
||||||
|
AudioSelect() string
|
||||||
|
// RewriterName is the registered mpd rewriter to run, or "none".
|
||||||
|
RewriterName() string
|
||||||
|
// LiveWaitSeconds is the downloader's live refresh wait.
|
||||||
|
LiveWaitSeconds() int
|
||||||
|
// TSReadyBytes is how much muxed output to buffer before probing.
|
||||||
|
TSReadyBytes() int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransparentMITM is implemented by apps that must use iptables REDIRECT capture
|
||||||
|
// (no Wi‑Fi http_proxy) so a phone VPN (e.g. NordVPN UK for BBC) can stay on.
|
||||||
|
type TransparentMITM interface {
|
||||||
|
UseTransparentMITM() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.RWMutex
|
||||||
|
registry = map[string]func() (App, error){}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register adds a constructor for --app <name>. Called from each module's init().
|
||||||
|
func Register(name string, ctor func() (App, error)) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
registry[name] = ctor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get constructs a registered app by name.
|
||||||
|
func Get(name string) (App, error) {
|
||||||
|
mu.RLock()
|
||||||
|
ctor, ok := registry[name]
|
||||||
|
mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown app %q — compiled-in apps: %v", name, Names())
|
||||||
|
}
|
||||||
|
return ctor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names lists registered app ids, sorted.
|
||||||
|
func Names() []string {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
out := make([]string, 0, len(registry))
|
||||||
|
for k := range registry {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// All constructs every registered app, skipping those that fail to build.
|
||||||
|
func All() []App {
|
||||||
|
out := []App{}
|
||||||
|
for _, n := range Names() {
|
||||||
|
a, err := Get(n)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagHooks are registered from each module's init() so overrides can be bound
|
||||||
|
// to the module's config holder BEFORE any app is constructed.
|
||||||
|
var flagHooks []func(*flag.FlagSet)
|
||||||
|
|
||||||
|
// RegisterFlags records a module's flag binder. Called from init().
|
||||||
|
func RegisterFlags(bind func(*flag.FlagSet)) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
flagHooks = append(flagHooks, bind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindFlags lets every compiled-in module register its value overrides on fs.
|
||||||
|
// Call this before fs.Parse, and construct apps only afterwards.
|
||||||
|
func BindFlags(fs *flag.FlagSet) {
|
||||||
|
mu.RLock()
|
||||||
|
hooks := append([]func(*flag.FlagSet){}, flagHooks...)
|
||||||
|
mu.RUnlock()
|
||||||
|
for _, h := range hooks {
|
||||||
|
h(fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
apps/pkg/brightcove/brightcove.go
Normal file
161
apps/pkg/brightcove/brightcove.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// Package brightcove talks to the Brightcove Playback API and reads Widevine
|
||||||
|
// material out of an HLS master. Brightcove is a platform, not a provider: this
|
||||||
|
// package holds no account, policy key, video id or channel knowledge.
|
||||||
|
package brightcove
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EdgeAPI is the Playback API base. Overridable for tests or a proxy.
|
||||||
|
var EdgeAPI = "https://edge.api.brightcove.com/playback/v1"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// SESSION-KEY / KEY lines put URI and KEYFORMAT in either order.
|
||||||
|
reWidevinePSSH = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`)
|
||||||
|
reWidevinePSSH2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`)
|
||||||
|
reKeyID = regexp.MustCompile(`(?i)keyId=([0-9a-f]{32})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WidevineKeySystem is the key_systems map key for Widevine sources.
|
||||||
|
const WidevineKeySystem = "com.widevine.alpha"
|
||||||
|
|
||||||
|
// Video is the subset of a Playback API response we use.
|
||||||
|
type Video struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Sources []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Src string `json:"src"`
|
||||||
|
KeySystems map[string]struct {
|
||||||
|
LicenseURL string `json:"license_url"`
|
||||||
|
} `json:"key_systems"`
|
||||||
|
} `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackURL is the canonical (token-free) Playback API URL for a video.
|
||||||
|
func PlaybackURL(accountID, videoID string) string {
|
||||||
|
return fmt.Sprintf("%s/accounts/%s/videos/%s", EdgeAPI, accountID, videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchPlayback resolves a video through the Playback API. livePlaybackToken may
|
||||||
|
// be empty for assets that do not need one.
|
||||||
|
func FetchPlayback(accountID, videoID, livePlaybackToken, policyKey string) (Video, error) {
|
||||||
|
var v Video
|
||||||
|
if policyKey == "" {
|
||||||
|
return v, fmt.Errorf("policy key required")
|
||||||
|
}
|
||||||
|
if accountID == "" {
|
||||||
|
return v, fmt.Errorf("account id required")
|
||||||
|
}
|
||||||
|
u := PlaybackURL(accountID, videoID)
|
||||||
|
if livePlaybackToken != "" {
|
||||||
|
u += "?livePlaybackToken=" + livePlaybackToken
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json;pk="+policyKey)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return v, fmt.Errorf("brightcove playback HTTP %d: %s", resp.StatusCode, Trim(string(body), 200))
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &v); err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickHLSAndLicense chooses the best HLS source: prefer a DVR playlist that
|
||||||
|
// carries a Widevine license URL, then any Widevine source, then any DVR
|
||||||
|
// playlist, then any HLS at all.
|
||||||
|
func PickHLSAndLicense(v Video) (hls, license string) {
|
||||||
|
var dvr, dvrWV, anyWV, anyHLS string
|
||||||
|
var dvrLic, anyLic string
|
||||||
|
for _, s := range v.Sources {
|
||||||
|
if !strings.Contains(strings.ToLower(s.Type), "mpegurl") && !strings.Contains(strings.ToLower(s.Src), ".m3u8") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := s.Src
|
||||||
|
lic := ""
|
||||||
|
if ks, ok := s.KeySystems[WidevineKeySystem]; ok {
|
||||||
|
lic = ks.LicenseURL
|
||||||
|
}
|
||||||
|
isDVR := strings.Contains(src, "playlist-hls-dvr.m3u8") || strings.Contains(src, "-dvr")
|
||||||
|
switch {
|
||||||
|
case isDVR && lic != "" && dvrWV == "":
|
||||||
|
dvrWV, dvrLic = src, lic
|
||||||
|
case isDVR && dvr == "":
|
||||||
|
dvr = src
|
||||||
|
case lic != "" && anyWV == "":
|
||||||
|
anyWV, anyLic = src, lic
|
||||||
|
case anyHLS == "":
|
||||||
|
anyHLS = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case dvrWV != "":
|
||||||
|
return dvrWV, dvrLic
|
||||||
|
case anyWV != "":
|
||||||
|
return anyWV, anyLic
|
||||||
|
case dvr != "":
|
||||||
|
return dvr, ""
|
||||||
|
default:
|
||||||
|
return anyHLS, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractWidevinePSSH returns the base64 Widevine init data from an HLS master.
|
||||||
|
func ExtractWidevinePSSH(master string) string {
|
||||||
|
if m := reWidevinePSSH.FindStringSubmatch(master); len(m) == 2 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
if m := reWidevinePSSH2.FindStringSubmatch(master); len(m) == 2 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryKID returns the first keyId= seen in an HLS master, lowercased.
|
||||||
|
func PrimaryKID(master string) string {
|
||||||
|
if kids := reKeyID.FindAllStringSubmatch(master, -1); len(kids) > 0 {
|
||||||
|
return strings.ToLower(kids[0][1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fetches a URL as text (used for the HLS master).
|
||||||
|
func Get(url string) (string, error) {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim shortens a string for error messages.
|
||||||
|
func Trim(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
284
apps/pkg/capture/capture.go
Normal file
284
apps/pkg/capture/capture.go
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hints tell the waiter which fields / log tags mean a capture is complete.
|
||||||
|
type Hints struct {
|
||||||
|
RemoteCaps []string // paths on device to pull
|
||||||
|
RemoteLog string
|
||||||
|
Require []string // json keys: auth, pid, pssh, mpd
|
||||||
|
// BestEffort: return early when any useful field appears; at timeout return
|
||||||
|
// whatever was collected (error only if completely empty).
|
||||||
|
BestEffort bool
|
||||||
|
MPDLogRE *regexp.Regexp
|
||||||
|
// Score ranks candidate manifest URLs. Zero value falls back to
|
||||||
|
// DefaultScoreCfg, which has no provider hosts.
|
||||||
|
Score ScoreCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteCapPaths are the on-device locations the MITM may persist its capture
|
||||||
|
// JSON to. The rte_cap.json entries are legacy names still written by proxies
|
||||||
|
// already deployed on phones in the field.
|
||||||
|
var RemoteCapPaths = []string{
|
||||||
|
"/data/local/tmp/appproxy_cap.json",
|
||||||
|
"/data/local/tmp/rte_cap.json",
|
||||||
|
"/sdcard/Download/rte_cap.json",
|
||||||
|
"/storage/emulated/0/Download/rte_cap.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteLogPath is where the on-device MITM writes its log.
|
||||||
|
const RemoteLogPath = "/data/local/tmp/appproxy.log"
|
||||||
|
|
||||||
|
// DefaultHints is the device-layout baseline every app starts from. Callers set
|
||||||
|
// Require (and optionally Score) for their own DRM shape.
|
||||||
|
func DefaultHints() Hints {
|
||||||
|
return Hints{
|
||||||
|
RemoteCaps: append([]string{}, RemoteCapPaths...),
|
||||||
|
RemoteLog: RemoteLogPath,
|
||||||
|
MPDLogRE: regexp.MustCompile(`\[MPD\] (https://\S+)`),
|
||||||
|
Score: DefaultScoreCfg(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPassiveHints is for a bare capture run: dump whatever the MITM sees.
|
||||||
|
func DefaultPassiveHints() Hints {
|
||||||
|
h := DefaultHints()
|
||||||
|
h.BestEffort = true
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// score applies the hints' URL scoring, defaulting when unset.
|
||||||
|
func (h Hints) score(u string) int {
|
||||||
|
if len(h.Score.Deny) == 0 && len(h.Score.Hosts) == 0 {
|
||||||
|
return DefaultScoreCfg().Score(u)
|
||||||
|
}
|
||||||
|
return h.Score.Score(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data is the merged capture payload before key fetch.
|
||||||
|
type Data map[string]string
|
||||||
|
|
||||||
|
func (d Data) Get(k string) string { return d[k] }
|
||||||
|
|
||||||
|
// Wait pulls device JSON + local mirrored log until required fields exist.
|
||||||
|
func Wait(c *adb.Client, hints Hints, localLog string, localCap string, timeout time.Duration) (Data, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
lastNote := ""
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
cap := Data{}
|
||||||
|
for _, remote := range hints.RemoteCaps {
|
||||||
|
_ = os.Remove(localCap)
|
||||||
|
if err := c.Pull(remote, localCap); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(localCap)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
cap[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
logText := ""
|
||||||
|
if b, err := os.ReadFile(localLog); err == nil {
|
||||||
|
logText = string(b)
|
||||||
|
}
|
||||||
|
enrichFromProxyLog(cap, logText, hints)
|
||||||
|
|
||||||
|
if hints.BestEffort {
|
||||||
|
if hasUseful(cap, hints) {
|
||||||
|
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
|
||||||
|
return cap, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missing := false
|
||||||
|
for _, k := range hints.Require {
|
||||||
|
if strings.TrimSpace(cap[k]) == "" {
|
||||||
|
missing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !missing {
|
||||||
|
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
|
||||||
|
return cap, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
have := []string{}
|
||||||
|
keys := hints.Require
|
||||||
|
if hints.BestEffort {
|
||||||
|
keys = []string{"mpd", "license_url", "pssh", "auth", "pid"}
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if cap[k] != "" {
|
||||||
|
have = append(have, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note := "json=" + strings.Join(have, ",")
|
||||||
|
if note == "json=" {
|
||||||
|
note = "json=none"
|
||||||
|
}
|
||||||
|
if strings.Contains(logText, "[LIC]") {
|
||||||
|
note += " log=LIC"
|
||||||
|
}
|
||||||
|
if strings.Contains(logText, "[PSSH]") {
|
||||||
|
note += " log=PSSH"
|
||||||
|
}
|
||||||
|
if strings.Contains(logText, "[MPD]") || strings.Contains(logText, "[MAN]") || strings.Contains(logText, "[MEDIA]") || strings.Contains(logText, "[MS]") {
|
||||||
|
note += " log=MAN"
|
||||||
|
}
|
||||||
|
if note != lastNote {
|
||||||
|
fmt.Println(" …" + note)
|
||||||
|
lastNote = note
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
if hints.BestEffort {
|
||||||
|
// Final pull after timeout — return whatever we got.
|
||||||
|
cap := Data{}
|
||||||
|
for _, remote := range hints.RemoteCaps {
|
||||||
|
_ = os.Remove(localCap)
|
||||||
|
if err := c.Pull(remote, localCap); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(localCap)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
cap[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
logText := ""
|
||||||
|
if b, err := os.ReadFile(localLog); err == nil {
|
||||||
|
logText = string(b)
|
||||||
|
}
|
||||||
|
enrichFromProxyLog(cap, logText, hints)
|
||||||
|
if len(cap) > 0 {
|
||||||
|
_ = os.WriteFile(localCap, mustJSON(cap), 0o644)
|
||||||
|
return cap, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("timed out — no DRM/manifest traffic seen (play something on the phone)")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("timed out waiting for capture (%v)", hints.Require)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUseful(cap Data, hints Hints) bool {
|
||||||
|
// Real stream signal only — ignore catalog/EPG URLs parked in "mpd".
|
||||||
|
if mpd := strings.TrimSpace(cap["mpd"]); mpd != "" && hints.score(mpd) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, k := range []string{"license_url", "pssh", "auth", "pid"} {
|
||||||
|
if strings.TrimSpace(cap[k]) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reLogLIC = regexp.MustCompile(`\[LIC\]\s+(?:POST|GET)\s+(https://\S+)`)
|
||||||
|
reLogMPD = regexp.MustCompile(`\[MPD\]\s+(https://\S+)`)
|
||||||
|
reLogMAN = regexp.MustCompile(`\[MAN\]\s+GET\s+(https://\S+)`)
|
||||||
|
reLogMEDIA = regexp.MustCompile(`\[MEDIA\]\s+(https://\S+)`)
|
||||||
|
reLogPSSH = regexp.MustCompile(`\[PSSH\]\s+(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func enrichFromProxyLog(cap Data, logText string, hints Hints) {
|
||||||
|
if logText == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bestMPD := ""
|
||||||
|
bestScore := 0
|
||||||
|
consider := func(u string) {
|
||||||
|
u = strings.TrimRight(u, ".,)")
|
||||||
|
if u == "" || !strings.HasPrefix(u, "http") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc := hints.score(u)
|
||||||
|
// Ignore EPG/schedule/metrics noise (score <= 0).
|
||||||
|
if sc <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sc > bestScore {
|
||||||
|
bestScore = sc
|
||||||
|
bestMPD = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hints.MPDLogRE != nil {
|
||||||
|
for _, m := range hints.MPDLogRE.FindAllStringSubmatch(logText, -1) {
|
||||||
|
consider(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reLogMPD.FindAllStringSubmatch(logText, -1) {
|
||||||
|
consider(m[1])
|
||||||
|
}
|
||||||
|
for _, m := range reLogMAN.FindAllStringSubmatch(logText, -1) {
|
||||||
|
consider(m[1])
|
||||||
|
}
|
||||||
|
for _, m := range reLogMEDIA.FindAllStringSubmatch(logText, -1) {
|
||||||
|
consider(m[1])
|
||||||
|
}
|
||||||
|
if bestMPD != "" && (cap["mpd"] == "" || hints.score(bestMPD) > hints.score(cap["mpd"])) {
|
||||||
|
cap["mpd"] = bestMPD
|
||||||
|
}
|
||||||
|
// Drop a previously stored non-manifest "mpd" (e.g. schedules feed).
|
||||||
|
if cap["mpd"] != "" && hints.score(cap["mpd"]) <= 0 {
|
||||||
|
delete(cap, "mpd")
|
||||||
|
}
|
||||||
|
if cap["license_url"] == "" {
|
||||||
|
if ms := reLogLIC.FindAllStringSubmatch(logText, -1); len(ms) > 0 {
|
||||||
|
cap["license_url"] = strings.TrimRight(ms[len(ms)-1][1], ".,)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cap["pssh"] == "" {
|
||||||
|
if ms := reLogPSSH.FindAllStringSubmatch(logText, -1); len(ms) > 0 {
|
||||||
|
cap["pssh"] = ms[len(ms)-1][1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(d Data) []byte {
|
||||||
|
b, _ := json.MarshalIndent(map[string]string(d), "", " ")
|
||||||
|
return append(b, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorLog starts a background `adb exec-out log` equivalent via continuous pull.
|
||||||
|
// For v1 we periodically pull the remote log file into localLog.
|
||||||
|
func MirrorLogLoop(c *adb.Client, remote, local string, stop <-chan struct{}) {
|
||||||
|
_ = os.MkdirAll(filepath.Dir(local), 0o755)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = c.Pull(remote, local)
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/pkg/capture/score.go
Normal file
100
apps/pkg/capture/score.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ScoreCfg ranks candidate URLs seen by the MITM so a schedule/EPG/analytics URL
|
||||||
|
// is never mistaken for a stream manifest. Providers supply their own hosts and
|
||||||
|
// noise needles; this package ships only format-level scoring.
|
||||||
|
type ScoreCfg struct {
|
||||||
|
// Deny: any match scores the URL out entirely.
|
||||||
|
Deny []string `yaml:"deny"`
|
||||||
|
// Hosts: known-good manifest hosts for this provider.
|
||||||
|
Hosts []string `yaml:"hosts"`
|
||||||
|
// HostBonus is added when a Hosts entry matches (default 50).
|
||||||
|
HostBonus int `yaml:"host_bonus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultScoreCfg is the provider-neutral baseline: catalog/analytics shapes that
|
||||||
|
// are never a manifest for anyone.
|
||||||
|
func DefaultScoreCfg() ScoreCfg {
|
||||||
|
return ScoreCfg{
|
||||||
|
Deny: []string{
|
||||||
|
"schedules", "bylistingtime", "maxlistings", "bycallsign",
|
||||||
|
"/feed.", "playback_config", "config.json",
|
||||||
|
},
|
||||||
|
HostBonus: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge overlays a provider's hosts and extra deny needles on the baseline.
|
||||||
|
func (s ScoreCfg) Merge(other ScoreCfg) ScoreCfg {
|
||||||
|
out := s
|
||||||
|
out.Deny = append(append([]string{}, s.Deny...), other.Deny...)
|
||||||
|
out.Hosts = append(append([]string{}, s.Hosts...), other.Hosts...)
|
||||||
|
if other.HostBonus != 0 {
|
||||||
|
out.HostBonus = other.HostBonus
|
||||||
|
}
|
||||||
|
if out.HostBonus == 0 {
|
||||||
|
out.HostBonus = 50
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score rates a URL as a live manifest. Zero or negative means "not a manifest".
|
||||||
|
func (s ScoreCfg) Score(u string) int {
|
||||||
|
lu := strings.ToLower(u)
|
||||||
|
path := lu
|
||||||
|
if i := strings.Index(path, "?"); i >= 0 {
|
||||||
|
path = path[:i]
|
||||||
|
}
|
||||||
|
for _, bad := range s.Deny {
|
||||||
|
if bad != "" && strings.Contains(lu, strings.ToLower(bad)) {
|
||||||
|
return -100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
bonus := s.HostBonus
|
||||||
|
if bonus == 0 {
|
||||||
|
bonus = 50
|
||||||
|
}
|
||||||
|
for _, host := range s.Hosts {
|
||||||
|
if host != "" && strings.Contains(lu, strings.ToLower(host)) {
|
||||||
|
score += bonus
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Format-level signals — true for any provider.
|
||||||
|
if strings.Contains(path, "playlist-hls") || strings.Contains(path, "playlist.m3u8") {
|
||||||
|
score += 40
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "chunklist") {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, ".m3u8") {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, ".mpd") || strings.Contains(path, "manifest.mpd") {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
if strings.Contains(path, ".isml") || strings.Contains(path, "/manifest") {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyArgs renders this config as flags for the on-device MITM, which runs on
|
||||||
|
// the phone and cannot read an app module's values file itself.
|
||||||
|
func (s ScoreCfg) ProxyArgs() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, h := range s.Hosts {
|
||||||
|
if h != "" {
|
||||||
|
out = append(out, "-score-host", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range s.Deny {
|
||||||
|
if d != "" {
|
||||||
|
out = append(out, "-score-deny", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
72
apps/pkg/capture/score_test.go
Normal file
72
apps/pkg/capture/score_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// provider-shaped config, supplied the way a module would.
|
||||||
|
func testScoreCfg() ScoreCfg {
|
||||||
|
return DefaultScoreCfg().Merge(ScoreCfg{
|
||||||
|
Deny: []string{"feed.entertainment", "metrics.example", "noise.example"},
|
||||||
|
Hosts: []string{"live.example.com", "fastly.live.example.com"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreCfgScore(t *testing.T) {
|
||||||
|
cfg := testScoreCfg()
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
want string // "pos" or "neg"
|
||||||
|
}{
|
||||||
|
{"https://feed.entertainment.tv.example.eu/f/1uC-gC/prd-all-schedules?byListingTime=1~2", "neg"},
|
||||||
|
{"https://metrics.example.com/v2/tracker?foo=.m3u8", "neg"},
|
||||||
|
{"https://cdn.example.com/smarttv/playback_config.json", "neg"},
|
||||||
|
{"https://live.example.com/live/foo/manifest.mpd", "pos"},
|
||||||
|
{"https://fastly.live.example.com/x/playlist-hls.m3u8", "pos"},
|
||||||
|
{"https://unknown-host.test/video/master.m3u8", "pos"},
|
||||||
|
{"https://unknown-host.test/live/vc11.isml/Manifest", "pos"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
sc := cfg.Score(tc.url)
|
||||||
|
if tc.want == "pos" && sc <= 0 {
|
||||||
|
t.Errorf("score(%q)=%d, want > 0", tc.url, sc)
|
||||||
|
}
|
||||||
|
if tc.want == "neg" && sc > 0 {
|
||||||
|
t.Errorf("score(%q)=%d, want <= 0", tc.url, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A known provider host must outrank a bare manifest on an unknown host, so the
|
||||||
|
// best candidate in a noisy MITM log is the provider's own origin.
|
||||||
|
func TestScoreCfgPrefersKnownHost(t *testing.T) {
|
||||||
|
cfg := testScoreCfg()
|
||||||
|
known := cfg.Score("https://live.example.com/live/foo/manifest.mpd")
|
||||||
|
unknown := cfg.Score("https://somewhere.test/live/foo/manifest.mpd")
|
||||||
|
if known <= unknown {
|
||||||
|
t.Fatalf("known host %d should outrank unknown host %d", known, unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultScoreCfgNeedsNoProviderHosts(t *testing.T) {
|
||||||
|
cfg := DefaultScoreCfg()
|
||||||
|
if cfg.Score("https://anything.test/playlist.m3u8") <= 0 {
|
||||||
|
t.Fatal("format-level scoring must work without provider hosts")
|
||||||
|
}
|
||||||
|
if cfg.Score("https://anything.test/api/schedules?x=1") > 0 {
|
||||||
|
t.Fatal("schedule feeds must score out with the baseline config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasUsefulIgnoresSchedulesMPD(t *testing.T) {
|
||||||
|
hints := DefaultPassiveHints()
|
||||||
|
hints.Score = testScoreCfg()
|
||||||
|
cap := Data{
|
||||||
|
"mpd": "https://feed.entertainment.tv.example.eu/f/1uC-gC/prd-all-schedules?byListingTime=1~2",
|
||||||
|
}
|
||||||
|
if hasUseful(cap, hints) {
|
||||||
|
t.Fatal("schedules feed must not count as useful capture")
|
||||||
|
}
|
||||||
|
cap["license_url"] = "https://widevine.example/license"
|
||||||
|
if !hasUseful(cap, hints) {
|
||||||
|
t.Fatal("license_url should count as useful")
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/pkg/go.mod
Normal file
5
apps/pkg/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module drmdecryption
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
apps/pkg/go.sum
Normal file
4
apps/pkg/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
90
apps/pkg/modcfg/modcfg.go
Normal file
90
apps/pkg/modcfg/modcfg.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Package modcfg loads an app module's values from its local, untracked
|
||||||
|
// apps/modules/<name>/module.yaml, with environment-variable overrides.
|
||||||
|
//
|
||||||
|
// Compiled-in module code is tracked in git and must contain no account ids,
|
||||||
|
// policy keys, video ids, license URLs or origin hostnames. Those live here, in
|
||||||
|
// a file git ignores, and can also be supplied by flag or environment so a run
|
||||||
|
// needs no file at all.
|
||||||
|
package modcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"drmdecryption/repo"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dir returns the directory holding a module's local values.
|
||||||
|
func Dir(name string) string {
|
||||||
|
return filepath.Join(repo.Root(), "apps", "modules", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the module's values file path.
|
||||||
|
func Path(name string) string {
|
||||||
|
return filepath.Join(Dir(name), "module.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result reports where a module's values came from, for `drm modules`.
|
||||||
|
type Result struct {
|
||||||
|
Path string
|
||||||
|
Loaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads apps/modules/<name>/module.yaml into dst. A missing file is not an
|
||||||
|
// error — the module runs on its Go defaults plus flags/env.
|
||||||
|
func Load(name string, dst any) (Result, error) {
|
||||||
|
path := Path(name)
|
||||||
|
res := Result{Path: path}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(b, dst); err != nil {
|
||||||
|
return res, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
res.Loaded = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvKey is the environment variable a field override is read from:
|
||||||
|
// TG4_POLICY_KEY for module "tg4", field "policy_key".
|
||||||
|
func EnvKey(module, field string) string {
|
||||||
|
clean := func(s string) string {
|
||||||
|
s = strings.ToUpper(s)
|
||||||
|
s = strings.ReplaceAll(s, "-", "_")
|
||||||
|
s = strings.ReplaceAll(s, ".", "_")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return clean(module) + "_" + clean(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env returns the environment override for a field, or "".
|
||||||
|
func Env(module, field string) string {
|
||||||
|
return strings.TrimSpace(os.Getenv(EnvKey(module, field)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override picks the first non-empty of: flag value, environment, current value.
|
||||||
|
// Use it in a module's config resolution so precedence is uniform.
|
||||||
|
func Override(module, field, flagVal, current string) string {
|
||||||
|
if strings.TrimSpace(flagVal) != "" {
|
||||||
|
return flagVal
|
||||||
|
}
|
||||||
|
if v := Env(module, field); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve makes a module-relative path absolute against the repo root.
|
||||||
|
func Resolve(p string) string {
|
||||||
|
if p == "" || filepath.IsAbs(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join(repo.Root(), p)
|
||||||
|
}
|
||||||
50
apps/pkg/mpd/kid.go
Normal file
50
apps/pkg/mpd/kid.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var reDefaultKID = regexp.MustCompile(`(?i)default_KID="([^"]+)"`)
|
||||||
|
|
||||||
|
// KIDToUUID normalizes any hex-ish KID spelling to dashed UUID form.
|
||||||
|
func KIDToUUID(kid string) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range kid {
|
||||||
|
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := strings.ToLower(b.String())
|
||||||
|
if len(h) != 32 {
|
||||||
|
return "", fmt.Errorf("invalid KID %q", kid)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KIDHex strips dashes and lowercases a KID (the form used as a map key).
|
||||||
|
func KIDHex(kid string) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(kid, "-", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractDefaultKID pulls cenc:default_KID out of an MPD, as dashed UUID.
|
||||||
|
func ExtractDefaultKID(xmlText string) string {
|
||||||
|
m := reDefaultKID.FindStringSubmatch(xmlText)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u, err := KIDToUUID(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// XMLEscape escapes text for inclusion in an XML element body.
|
||||||
|
func XMLEscape(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
_ = xml.EscapeText(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
15
apps/pkg/mpd/mpd.go
Normal file
15
apps/pkg/mpd/mpd.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
// Rewriter transforms an upstream live MPD into something N_m3u8DL-RE can
|
||||||
|
// refresh safely. Apps that need DAI/colon stripping or synthetic timelines
|
||||||
|
// implement this; others return Passthrough.
|
||||||
|
type Rewriter interface {
|
||||||
|
Name() string
|
||||||
|
Rewrite(upstreamXML []byte, kidHint string) (out []byte, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passthrough leaves the MPD unchanged.
|
||||||
|
type Passthrough struct{}
|
||||||
|
|
||||||
|
func (Passthrough) Name() string { return "none" }
|
||||||
|
func (Passthrough) Rewrite(in []byte, _ string) ([]byte, error) { return in, nil }
|
||||||
59
apps/pkg/mpd/registry.go
Normal file
59
apps/pkg/mpd/registry.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
regMu sync.RWMutex
|
||||||
|
registry = map[string]func() Rewriter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register("none", func() Rewriter { return Passthrough{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register makes a rewriter resolvable by name, so consumers (streamd) can pick
|
||||||
|
// one from stored config without importing the provider that implements it. A
|
||||||
|
// factory is registered rather than an instance because a rewriter may carry
|
||||||
|
// per-stream fallback state.
|
||||||
|
func Register(name string, newRewriter func() Rewriter) {
|
||||||
|
regMu.Lock()
|
||||||
|
defer regMu.Unlock()
|
||||||
|
registry[strings.ToLower(name)] = newRewriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup builds a fresh rewriter for a name. An empty name, "none" or an unknown
|
||||||
|
// name yields Passthrough with ok=false, so callers can tell "no rewrite needed"
|
||||||
|
// from "rewrite with X".
|
||||||
|
func Lookup(name string) (Rewriter, bool) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if key == "" || key == "none" {
|
||||||
|
return Passthrough{}, false
|
||||||
|
}
|
||||||
|
regMu.RLock()
|
||||||
|
newRewriter, ok := registry[key]
|
||||||
|
regMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return Passthrough{}, false
|
||||||
|
}
|
||||||
|
rw := newRewriter()
|
||||||
|
if _, isPass := rw.(Passthrough); isPass {
|
||||||
|
return rw, false
|
||||||
|
}
|
||||||
|
return rw, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names lists registered rewriter names.
|
||||||
|
func Names() []string {
|
||||||
|
regMu.RLock()
|
||||||
|
defer regMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(registry))
|
||||||
|
for k := range registry {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
104
apps/pkg/mpd/server.go
Normal file
104
apps/pkg/mpd/server.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KIDResolver is implemented by rewriters that can recover the live channel from
|
||||||
|
// a KID alone. The local server uses it so a rewrite still works after the
|
||||||
|
// upstream (ad-stitched) manifest session has expired and returns 410.
|
||||||
|
type KIDResolver interface {
|
||||||
|
ResolveKID(kidHex string) (channel, originBase string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primer is implemented by rewriters that want to be told the fallback channel
|
||||||
|
// and origin discovered on earlier requests.
|
||||||
|
type Primer interface {
|
||||||
|
Prime(channel, originBase string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalServer serves a rewritten live MPD on 127.0.0.1 for a downloader to
|
||||||
|
// refresh. Every GET re-fetches upstream and re-runs the rewriter.
|
||||||
|
type LocalServer struct {
|
||||||
|
URL string
|
||||||
|
Upstream string
|
||||||
|
Key string // KID:KEY
|
||||||
|
Headers map[string]string
|
||||||
|
|
||||||
|
rw Rewriter
|
||||||
|
kidHint string
|
||||||
|
ln net.Listener
|
||||||
|
httpServer *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartLocal starts the rewrite proxy. rw must not be nil; pass Passthrough{}
|
||||||
|
// to serve upstream unchanged.
|
||||||
|
func StartLocal(upstream, key string, headers map[string]string, rw Rewriter) (*LocalServer, error) {
|
||||||
|
if rw == nil {
|
||||||
|
rw = Passthrough{}
|
||||||
|
}
|
||||||
|
s := &LocalServer{Upstream: upstream, Key: key, Headers: headers, rw: rw}
|
||||||
|
if key != "" && strings.Contains(key, ":") {
|
||||||
|
s.kidHint = strings.SplitN(key, ":", 2)[0]
|
||||||
|
if r, ok := rw.(KIDResolver); ok {
|
||||||
|
if ch, base, found := r.ResolveKID(KIDHex(s.kidHint)); found {
|
||||||
|
if p, ok := rw.(Primer); ok {
|
||||||
|
p.Prime(ch, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ln = ln
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", s.handle)
|
||||||
|
s.httpServer = &http.Server{Handler: mux}
|
||||||
|
s.URL = fmt.Sprintf("http://%s/manifest.mpd", ln.Addr().String())
|
||||||
|
go func() { _ = s.httpServer.Serve(ln) }()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalServer) Close() {
|
||||||
|
if s.httpServer != nil {
|
||||||
|
_ = s.httpServer.Close()
|
||||||
|
}
|
||||||
|
if s.ln != nil {
|
||||||
|
_ = s.ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := ""
|
||||||
|
req, err := http.NewRequest(http.MethodGet, s.Upstream, nil)
|
||||||
|
if err == nil {
|
||||||
|
for k, v := range s.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
if resp, err := client.Do(req); err == nil {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(b) > 0 {
|
||||||
|
body = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Upstream being gone is not fatal: a rewriter with a KID fallback can still
|
||||||
|
// publish the encoder timeline so the downloader keeps pulling segments.
|
||||||
|
payload, err := s.rw.Rewrite([]byte(body), s.kidHint)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 502)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/dash+xml")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
}
|
||||||
142
apps/pkg/mpd/smooth.go
Normal file
142
apps/pkg/mpd/smooth.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultMSSTimescale is the Smooth Streaming tick rate (100ns units).
|
||||||
|
const DefaultMSSTimescale = 10_000_000
|
||||||
|
|
||||||
|
var (
|
||||||
|
reSegDur = regexp.MustCompile(`\bd="(\d+)"`)
|
||||||
|
reSegT = regexp.MustCompile(`\bt="(\d+)"`)
|
||||||
|
reSegR = regexp.MustCompile(`\br="(\d+)"`)
|
||||||
|
reSegEntry = regexp.MustCompile(`<c\s+([^/]*)/>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScaleMSS converts a Smooth Streaming tick to another timescale without
|
||||||
|
// overflowing int64 (MSS times are ~1e16; mss*48000 does not fit).
|
||||||
|
func ScaleMSS(mss, fromScale, toScale int64) int64 {
|
||||||
|
if fromScale <= 0 {
|
||||||
|
fromScale = DefaultMSSTimescale
|
||||||
|
}
|
||||||
|
return mss/fromScale*toScale + (mss%fromScale)*toScale/fromScale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid is the segment duration + phase of one encoder channel, per media type.
|
||||||
|
type Grid struct {
|
||||||
|
VDur, ADur int64
|
||||||
|
VMod, AMod int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridSpec describes how to learn a Grid from an origin's Smooth manifest.
|
||||||
|
type GridSpec struct {
|
||||||
|
// ManifestPath is appended to the origin base (e.g. "Manifest").
|
||||||
|
ManifestPath string
|
||||||
|
// VideoName / AudioName are StreamIndex Name (or Type) attributes.
|
||||||
|
VideoName, AudioName string
|
||||||
|
// Timescales the DASH output uses.
|
||||||
|
VideoTimescale, AudioTimescale int64
|
||||||
|
MSSTimescale int64
|
||||||
|
// FallbackSegmentDuration in MSS ticks when the manifest has no d="".
|
||||||
|
FallbackSegmentDuration int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gridMu sync.Mutex
|
||||||
|
gridCache = map[string]Grid{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// LearnGrid fetches the origin Smooth manifest once per channel and derives the
|
||||||
|
// segment duration and phase offset for video and audio.
|
||||||
|
func LearnGrid(channel, originBase string, spec GridSpec) (Grid, error) {
|
||||||
|
gridMu.Lock()
|
||||||
|
if g, ok := gridCache[channel]; ok {
|
||||||
|
gridMu.Unlock()
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
gridMu.Unlock()
|
||||||
|
|
||||||
|
if !strings.HasSuffix(originBase, "/") {
|
||||||
|
originBase += "/"
|
||||||
|
}
|
||||||
|
url := originBase + spec.ManifestPath
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return Grid{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return Grid{}, err
|
||||||
|
}
|
||||||
|
text := string(body)
|
||||||
|
video := ParseMSSStreamTimes(text, spec.VideoName)
|
||||||
|
audio := ParseMSSStreamTimes(text, spec.AudioName)
|
||||||
|
mssDur := spec.FallbackSegmentDuration
|
||||||
|
if m := reSegDur.FindStringSubmatch(text); len(m) == 2 {
|
||||||
|
if v, e := strconv.ParseInt(m[1], 10, 64); e == nil {
|
||||||
|
mssDur = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(video) == 0 || len(audio) == 0 {
|
||||||
|
return Grid{}, fmt.Errorf("encoder Manifest missing streams at %s", url)
|
||||||
|
}
|
||||||
|
ms := spec.MSSTimescale
|
||||||
|
vDur := ScaleMSS(mssDur, ms, spec.VideoTimescale)
|
||||||
|
aDur := ScaleMSS(mssDur, ms, spec.AudioTimescale)
|
||||||
|
g := Grid{
|
||||||
|
VDur: vDur,
|
||||||
|
ADur: aDur,
|
||||||
|
VMod: ScaleMSS(video[len(video)-1], ms, spec.VideoTimescale) % vDur,
|
||||||
|
AMod: ScaleMSS(audio[len(audio)-1], ms, spec.AudioTimescale) % aDur,
|
||||||
|
}
|
||||||
|
gridMu.Lock()
|
||||||
|
gridCache[channel] = g
|
||||||
|
gridMu.Unlock()
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMSSStreamTimes expands the <c> timeline of one StreamIndex into
|
||||||
|
// absolute MSS tick times. streamName matches Name="" or Type="".
|
||||||
|
func ParseMSSStreamTimes(manifestXML, streamName string) []int64 {
|
||||||
|
reBlock := regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bName="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
|
||||||
|
m := reBlock.FindStringSubmatch(manifestXML)
|
||||||
|
if m == nil {
|
||||||
|
reBlock = regexp.MustCompile(`(?is)<StreamIndex\b[^>]*\bType="` + regexp.QuoteMeta(streamName) + `"[^>]*>(.*?)</StreamIndex>`)
|
||||||
|
m = reBlock.FindStringSubmatch(manifestXML)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var times []int64
|
||||||
|
tCur := int64(-1)
|
||||||
|
for _, c := range reSegEntry.FindAllStringSubmatch(m[1], -1) {
|
||||||
|
attrs := c[1]
|
||||||
|
tm := reSegT.FindStringSubmatch(attrs)
|
||||||
|
dm := reSegDur.FindStringSubmatch(attrs)
|
||||||
|
rm := reSegR.FindStringSubmatch(attrs)
|
||||||
|
if tm != nil {
|
||||||
|
tCur, _ = strconv.ParseInt(tm[1], 10, 64)
|
||||||
|
}
|
||||||
|
if tCur < 0 || dm == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, _ := strconv.ParseInt(dm[1], 10, 64)
|
||||||
|
r := int64(0)
|
||||||
|
if rm != nil {
|
||||||
|
r, _ = strconv.ParseInt(rm[1], 10, 64)
|
||||||
|
}
|
||||||
|
for i := int64(0); i <= r; i++ {
|
||||||
|
times = append(times, tCur)
|
||||||
|
tCur += d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return times
|
||||||
|
}
|
||||||
280
apps/pkg/mpd/synthetic.go
Normal file
280
apps/pkg/mpd/synthetic.go
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyntheticConfig describes a live encoder's DASH grid well enough to publish a
|
||||||
|
// synthetic manifest for it. Every value a provider could differ on lives here;
|
||||||
|
// nothing in this package names a provider.
|
||||||
|
type SyntheticConfig struct {
|
||||||
|
// Timescales.
|
||||||
|
MSSTimescale int64 `yaml:"mss_timescale"`
|
||||||
|
VideoTimescale int64 `yaml:"video_timescale"`
|
||||||
|
AudioTimescale int64 `yaml:"audio_timescale"`
|
||||||
|
|
||||||
|
// Smooth Streaming stream names used to learn the segment grid.
|
||||||
|
ManifestPath string `yaml:"manifest_path"`
|
||||||
|
VideoName string `yaml:"video_name"`
|
||||||
|
AudioName string `yaml:"audio_name"`
|
||||||
|
|
||||||
|
// Segment path appended to the origin base for DASH segments.
|
||||||
|
SegmentPathSuffix string `yaml:"segment_path_suffix"`
|
||||||
|
|
||||||
|
// Representation attributes.
|
||||||
|
VideoBandwidth int `yaml:"video_bandwidth"`
|
||||||
|
AudioBandwidth int `yaml:"audio_bandwidth"`
|
||||||
|
VideoCodecs string `yaml:"video_codecs"`
|
||||||
|
AudioCodecs string `yaml:"audio_codecs"`
|
||||||
|
Width int `yaml:"width"`
|
||||||
|
Height int `yaml:"height"`
|
||||||
|
AudioSamplingRate int `yaml:"audio_sampling_rate"`
|
||||||
|
AudioChannels int `yaml:"audio_channels"`
|
||||||
|
Lang string `yaml:"lang"`
|
||||||
|
|
||||||
|
// Segment naming. "{channel}" is substituted; empty means derive as
|
||||||
|
// <channel>-<stream name>=<bandwidth>[-$Time$].<ext>.
|
||||||
|
VideoInitTemplate string `yaml:"video_init_template"`
|
||||||
|
VideoMediaTemplate string `yaml:"video_media_template"`
|
||||||
|
AudioInitTemplate string `yaml:"audio_init_template"`
|
||||||
|
AudioMediaTemplate string `yaml:"audio_media_template"`
|
||||||
|
SegmentExt string `yaml:"segment_ext"`
|
||||||
|
|
||||||
|
// Timeline shape.
|
||||||
|
LiveEdgeOffset float64 `yaml:"live_edge_offset_s"`
|
||||||
|
WindowSegments int64 `yaml:"window_segments"`
|
||||||
|
FallbackSegmentDuration int64 `yaml:"fallback_segment_duration"`
|
||||||
|
|
||||||
|
// MPD-level durations, as ISO-8601 strings.
|
||||||
|
MinUpdatePeriod string `yaml:"min_update_period"`
|
||||||
|
MinBufferTime string `yaml:"min_buffer_time"`
|
||||||
|
TimeShiftBufferDepth string `yaml:"time_shift_buffer_depth"`
|
||||||
|
MaxSegmentDuration string `yaml:"max_segment_duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaults fills anything left zero with the common Smooth-to-DASH values.
|
||||||
|
// These are container mechanics, not provider identity, so defaulting is safe.
|
||||||
|
func (c SyntheticConfig) WithDefaults() SyntheticConfig {
|
||||||
|
if c.MSSTimescale == 0 {
|
||||||
|
c.MSSTimescale = DefaultMSSTimescale
|
||||||
|
}
|
||||||
|
if c.VideoTimescale == 0 {
|
||||||
|
c.VideoTimescale = 600
|
||||||
|
}
|
||||||
|
if c.AudioTimescale == 0 {
|
||||||
|
c.AudioTimescale = 48_000
|
||||||
|
}
|
||||||
|
if c.ManifestPath == "" {
|
||||||
|
c.ManifestPath = "Manifest"
|
||||||
|
}
|
||||||
|
if c.VideoName == "" {
|
||||||
|
c.VideoName = "video"
|
||||||
|
}
|
||||||
|
if c.AudioName == "" {
|
||||||
|
c.AudioName = "audio_128k"
|
||||||
|
}
|
||||||
|
if c.SegmentPathSuffix == "" {
|
||||||
|
c.SegmentPathSuffix = "dash/"
|
||||||
|
}
|
||||||
|
if c.VideoBandwidth == 0 {
|
||||||
|
c.VideoBandwidth = 6_000_000
|
||||||
|
}
|
||||||
|
if c.AudioBandwidth == 0 {
|
||||||
|
c.AudioBandwidth = 128_000
|
||||||
|
}
|
||||||
|
if c.VideoCodecs == "" {
|
||||||
|
c.VideoCodecs = "avc1.640028"
|
||||||
|
}
|
||||||
|
if c.AudioCodecs == "" {
|
||||||
|
c.AudioCodecs = "mp4a.40.2"
|
||||||
|
}
|
||||||
|
if c.Width == 0 {
|
||||||
|
c.Width = 1920
|
||||||
|
}
|
||||||
|
if c.Height == 0 {
|
||||||
|
c.Height = 1080
|
||||||
|
}
|
||||||
|
if c.AudioSamplingRate == 0 {
|
||||||
|
c.AudioSamplingRate = 48_000
|
||||||
|
}
|
||||||
|
if c.AudioChannels == 0 {
|
||||||
|
c.AudioChannels = 2
|
||||||
|
}
|
||||||
|
if c.Lang == "" {
|
||||||
|
c.Lang = "en"
|
||||||
|
}
|
||||||
|
if c.SegmentExt == "" {
|
||||||
|
c.SegmentExt = "dash"
|
||||||
|
}
|
||||||
|
if c.LiveEdgeOffset == 0 {
|
||||||
|
c.LiveEdgeOffset = 12
|
||||||
|
}
|
||||||
|
if c.WindowSegments == 0 {
|
||||||
|
c.WindowSegments = 6
|
||||||
|
}
|
||||||
|
if c.FallbackSegmentDuration == 0 {
|
||||||
|
c.FallbackSegmentDuration = 38_400_000
|
||||||
|
}
|
||||||
|
if c.MinUpdatePeriod == "" {
|
||||||
|
c.MinUpdatePeriod = "PT2S"
|
||||||
|
}
|
||||||
|
if c.MinBufferTime == "" {
|
||||||
|
c.MinBufferTime = "PT8S"
|
||||||
|
}
|
||||||
|
if c.TimeShiftBufferDepth == "" {
|
||||||
|
c.TimeShiftBufferDepth = "PT1M"
|
||||||
|
}
|
||||||
|
if c.MaxSegmentDuration == "" {
|
||||||
|
c.MaxSegmentDuration = "PT4S"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridSpec is the LearnGrid view of this config, exported so a provider module can
|
||||||
|
// learn the same encoder grid the builder uses.
|
||||||
|
func (c SyntheticConfig) GridSpec() GridSpec {
|
||||||
|
return GridSpec{
|
||||||
|
ManifestPath: c.ManifestPath,
|
||||||
|
VideoName: c.VideoName,
|
||||||
|
AudioName: c.AudioName,
|
||||||
|
VideoTimescale: c.VideoTimescale,
|
||||||
|
AudioTimescale: c.AudioTimescale,
|
||||||
|
MSSTimescale: c.MSSTimescale,
|
||||||
|
FallbackSegmentDuration: c.FallbackSegmentDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SyntheticConfig) segName(tmpl, channel, stream string, bandwidth int, withTime bool) string {
|
||||||
|
if tmpl != "" {
|
||||||
|
return strings.ReplaceAll(tmpl, "{channel}", channel)
|
||||||
|
}
|
||||||
|
if withTime {
|
||||||
|
return fmt.Sprintf("%s-%s=%d-$Time$.%s", channel, stream, bandwidth, c.SegmentExt)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s=%d.%s", channel, stream, bandwidth, c.SegmentExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type anchor struct {
|
||||||
|
V, A int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
anchorMu sync.Mutex
|
||||||
|
anchors = map[string]*anchor{}
|
||||||
|
)
|
||||||
|
|
||||||
|
const syntheticTemplate = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:cenc="urn:mpeg:cenc:2013"
|
||||||
|
profiles="urn:mpeg:dash:profile:isoff-live:2011" type="dynamic"
|
||||||
|
availabilityStartTime="1970-01-01T00:00:00Z" publishTime="%s"
|
||||||
|
minimumUpdatePeriod="%s" minBufferTime="%s" timeShiftBufferDepth="%s"
|
||||||
|
maxSegmentDuration="%s">
|
||||||
|
<BaseURL>%s</BaseURL>
|
||||||
|
<Period id="1" start="PT0S">
|
||||||
|
<AdaptationSet id="1" contentType="video" mimeType="video/mp4" lang="%s">
|
||||||
|
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
||||||
|
<Representation id="r0" bandwidth="%d" codecs="%s" width="%d" height="%d">
|
||||||
|
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
||||||
|
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
||||||
|
</SegmentTemplate>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
<AdaptationSet id="2" contentType="audio" mimeType="audio/mp4" lang="%s">
|
||||||
|
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
||||||
|
<Representation id="r1" bandwidth="%d" codecs="%s" audioSamplingRate="%d">
|
||||||
|
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="%d"/>
|
||||||
|
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
||||||
|
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
||||||
|
</SegmentTemplate>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
</Period>
|
||||||
|
</MPD>
|
||||||
|
`
|
||||||
|
|
||||||
|
// BuildSynthetic publishes a dynamic MPD on the encoder's own segment grid, so a
|
||||||
|
// downloader keeps fetching live segments even once the upstream (ad-stitched)
|
||||||
|
// manifest session has expired.
|
||||||
|
func BuildSynthetic(cfg SyntheticConfig, channel, originBase, kid string) ([]byte, error) {
|
||||||
|
cfg = cfg.WithDefaults()
|
||||||
|
if !strings.HasSuffix(originBase, "/") {
|
||||||
|
originBase += "/"
|
||||||
|
}
|
||||||
|
segBase := originBase + cfg.SegmentPathSuffix
|
||||||
|
kidU, err := KIDToUUID(kid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grid, err := LearnGrid(channel, originBase, cfg.GridSpec())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Float seconds so sub-second phase stays close to the encoder.
|
||||||
|
now := float64(time.Now().UTC().UnixNano())/1e9 - cfg.LiveEdgeOffset
|
||||||
|
align := func(ts, dur, mod int64) int64 {
|
||||||
|
raw := int64(now * float64(ts))
|
||||||
|
return raw - (raw-mod)%dur
|
||||||
|
}
|
||||||
|
vt := align(cfg.VideoTimescale, grid.VDur, grid.VMod)
|
||||||
|
at := align(cfg.AudioTimescale, grid.ADur, grid.AMod)
|
||||||
|
maxCount := cfg.WindowSegments
|
||||||
|
|
||||||
|
anchorMu.Lock()
|
||||||
|
a := anchors[channel]
|
||||||
|
if a == nil {
|
||||||
|
a = &anchor{
|
||||||
|
V: vt - (maxCount-1)*grid.VDur,
|
||||||
|
A: at - (maxCount-1)*grid.ADur,
|
||||||
|
}
|
||||||
|
anchors[channel] = a
|
||||||
|
}
|
||||||
|
vStart, aStart := a.V, a.A
|
||||||
|
if vt > vStart+(maxCount-1)*grid.VDur {
|
||||||
|
vStart = vt - (maxCount-1)*grid.VDur
|
||||||
|
aStart = at - (maxCount-1)*grid.ADur
|
||||||
|
a.V, a.A = vStart, aStart
|
||||||
|
} else if vt < vStart {
|
||||||
|
vStart = vt - (maxCount-1)*grid.VDur
|
||||||
|
aStart = at - (maxCount-1)*grid.ADur
|
||||||
|
a.V, a.A = vStart, aStart
|
||||||
|
}
|
||||||
|
anchorMu.Unlock()
|
||||||
|
|
||||||
|
vCount := (vt-vStart)/grid.VDur + 1
|
||||||
|
aCount := (at-aStart)/grid.ADur + 1
|
||||||
|
if vCount < 1 {
|
||||||
|
vCount = 1
|
||||||
|
}
|
||||||
|
if aCount < 1 {
|
||||||
|
aCount = 1
|
||||||
|
}
|
||||||
|
if vCount > maxCount {
|
||||||
|
vStart = vt - (maxCount-1)*grid.VDur
|
||||||
|
aStart = at - (maxCount-1)*grid.ADur
|
||||||
|
vCount, aCount = maxCount, maxCount
|
||||||
|
anchorMu.Lock()
|
||||||
|
anchors[channel] = &anchor{V: vStart, A: aStart}
|
||||||
|
anchorMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
published := time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
|
||||||
|
vInit := cfg.segName(cfg.VideoInitTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, false)
|
||||||
|
vMedia := cfg.segName(cfg.VideoMediaTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, true)
|
||||||
|
aInit := cfg.segName(cfg.AudioInitTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, false)
|
||||||
|
aMedia := cfg.segName(cfg.AudioMediaTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, true)
|
||||||
|
|
||||||
|
out := fmt.Sprintf(syntheticTemplate,
|
||||||
|
published, cfg.MinUpdatePeriod, cfg.MinBufferTime, cfg.TimeShiftBufferDepth, cfg.MaxSegmentDuration,
|
||||||
|
XMLEscape(segBase),
|
||||||
|
cfg.Lang, kidU,
|
||||||
|
cfg.VideoBandwidth, cfg.VideoCodecs, cfg.Width, cfg.Height,
|
||||||
|
cfg.VideoTimescale, vInit, vMedia, vStart, grid.VDur, vCount-1,
|
||||||
|
cfg.Lang, kidU,
|
||||||
|
cfg.AudioBandwidth, cfg.AudioCodecs, cfg.AudioSamplingRate, cfg.AudioChannels,
|
||||||
|
cfg.AudioTimescale, aInit, aMedia, aStart, grid.ADur, aCount-1,
|
||||||
|
)
|
||||||
|
return []byte(out), nil
|
||||||
|
}
|
||||||
264
apps/pkg/mpd/synthetic_test.go
Normal file
264
apps/pkg/mpd/synthetic_test.go
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
package mpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Smooth Streaming manifest shaped like a live encoder serves: 4s segments at
|
||||||
|
// the 10MHz MSS tick rate.
|
||||||
|
const smoothManifest = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<SmoothStreamingMedia MajorVersion="2" MinorVersion="0" TimeScale="10000000" IsLive="TRUE">
|
||||||
|
<StreamIndex Type="video" Name="video" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(video={start time})">
|
||||||
|
<QualityLevel Index="0" Bitrate="6000000" FourCC="H264" MaxWidth="1920" MaxHeight="1080"/>
|
||||||
|
<c t="17000000000000" d="40000000" r="3"/>
|
||||||
|
</StreamIndex>
|
||||||
|
<StreamIndex Type="audio" Name="audio_128k" Chunks="0" QualityLevels="1" Url="QualityLevels({bitrate})/Fragments(audio_128k={start time})">
|
||||||
|
<QualityLevel Index="0" Bitrate="128000" FourCC="AACL" SamplingRate="48000"/>
|
||||||
|
<c t="17000000000000" d="40000000" r="3"/>
|
||||||
|
</StreamIndex>
|
||||||
|
</SmoothStreamingMedia>`
|
||||||
|
|
||||||
|
func originServer(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/Manifest") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
_, _ = w.Write([]byte(smoothManifest))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv.URL + "/live/test.isml/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetGridCache keeps tests independent — the grid and anchor caches are global
|
||||||
|
// so a live stream learns its encoder phase only once.
|
||||||
|
func resetGridCache(channel string) {
|
||||||
|
gridMu.Lock()
|
||||||
|
delete(gridCache, channel)
|
||||||
|
gridMu.Unlock()
|
||||||
|
anchorMu.Lock()
|
||||||
|
delete(anchors, channel)
|
||||||
|
anchorMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSyntheticShape(t *testing.T) {
|
||||||
|
resetGridCache("vctest")
|
||||||
|
base := originServer(t)
|
||||||
|
out, err := BuildSynthetic(SyntheticConfig{}, "vctest", base, "df163382-1ddd-fdd5-bec9-822c1ec0f052")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(out)
|
||||||
|
|
||||||
|
// The default grid is the one the live RTE pipeline was tuned with.
|
||||||
|
for _, want := range []string{
|
||||||
|
`type="dynamic"`,
|
||||||
|
`minimumUpdatePeriod="PT2S"`,
|
||||||
|
`minBufferTime="PT8S"`,
|
||||||
|
`timeShiftBufferDepth="PT1M"`,
|
||||||
|
`maxSegmentDuration="PT4S"`,
|
||||||
|
`cenc:default_KID="df163382-1ddd-fdd5-bec9-822c1ec0f052"`,
|
||||||
|
`timescale="600"`,
|
||||||
|
`timescale="48000"`,
|
||||||
|
`bandwidth="6000000"`,
|
||||||
|
`bandwidth="128000"`,
|
||||||
|
`codecs="avc1.640028"`,
|
||||||
|
`codecs="mp4a.40.2"`,
|
||||||
|
`width="1920" height="1080"`,
|
||||||
|
`audioSamplingRate="48000"`,
|
||||||
|
`initialization="vctest-video=6000000.dash"`,
|
||||||
|
`media="vctest-video=6000000-$Time$.dash"`,
|
||||||
|
`initialization="vctest-audio_128k=128000.dash"`,
|
||||||
|
`media="vctest-audio_128k=128000-$Time$.dash"`,
|
||||||
|
base + "dash/",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("synthetic MPD missing %q\n---\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The published window must be WindowSegments long: r = count-1.
|
||||||
|
func TestBuildSyntheticWindowSize(t *testing.T) {
|
||||||
|
resetGridCache("vcwin")
|
||||||
|
base := originServer(t)
|
||||||
|
out, err := BuildSynthetic(SyntheticConfig{WindowSegments: 6}, "vcwin", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="(\d+)"/>`)
|
||||||
|
ms := re.FindAllStringSubmatch(string(out), -1)
|
||||||
|
if len(ms) != 2 {
|
||||||
|
t.Fatalf("want 2 SegmentTimelines, got %d", len(ms))
|
||||||
|
}
|
||||||
|
for i, m := range ms {
|
||||||
|
r, _ := strconv.Atoi(m[3])
|
||||||
|
if r != 5 {
|
||||||
|
t.Errorf("timeline %d: r=%d, want 5 (6 segments)", i, r)
|
||||||
|
}
|
||||||
|
d, _ := strconv.Atoi(m[2])
|
||||||
|
// 4s segments: 4*600=2400 video ticks, 4*48000=192000 audio ticks.
|
||||||
|
want := []int{2400, 192000}[i]
|
||||||
|
if d != want {
|
||||||
|
t.Errorf("timeline %d: d=%d, want %d", i, d, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment times must land on the encoder's grid, i.e. share its phase offset,
|
||||||
|
// or the downloader requests segments the origin does not have.
|
||||||
|
func TestBuildSyntheticAlignsToEncoderPhase(t *testing.T) {
|
||||||
|
resetGridCache("vcphase")
|
||||||
|
base := originServer(t)
|
||||||
|
grid, err := LearnGrid("vcphase", base, SyntheticConfig{}.WithDefaults().GridSpec())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := BuildSynthetic(SyntheticConfig{}, "vcphase", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="\d+"/>`)
|
||||||
|
ms := re.FindAllStringSubmatch(string(out), -1)
|
||||||
|
vStart, _ := strconv.ParseInt(ms[0][1], 10, 64)
|
||||||
|
if got := ((vStart-grid.VMod)%grid.VDur + grid.VDur) % grid.VDur; got != 0 {
|
||||||
|
t.Errorf("video start %d is off-grid by %d (dur=%d mod=%d)", vStart, got, grid.VDur, grid.VMod)
|
||||||
|
}
|
||||||
|
aStart, _ := strconv.ParseInt(ms[1][1], 10, 64)
|
||||||
|
if got := ((aStart-grid.AMod)%grid.ADur + grid.ADur) % grid.ADur; got != 0 {
|
||||||
|
t.Errorf("audio start %d is off-grid by %d (dur=%d mod=%d)", aStart, got, grid.ADur, grid.AMod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every provider-shaped literal must be overridable from config.
|
||||||
|
func TestBuildSyntheticHonoursConfig(t *testing.T) {
|
||||||
|
resetGridCache("ch9")
|
||||||
|
base := originServer(t)
|
||||||
|
cfg := SyntheticConfig{
|
||||||
|
VideoName: "video",
|
||||||
|
AudioName: "audio_128k",
|
||||||
|
VideoBandwidth: 3_000_000,
|
||||||
|
AudioBandwidth: 96_000,
|
||||||
|
VideoCodecs: "hvc1.1.6.L93.B0",
|
||||||
|
AudioCodecs: "mp4a.40.5",
|
||||||
|
Width: 1280,
|
||||||
|
Height: 720,
|
||||||
|
Lang: "ga",
|
||||||
|
SegmentPathSuffix: "cmaf/",
|
||||||
|
VideoInitTemplate: "{channel}-v-init.m4s",
|
||||||
|
VideoMediaTemplate: "{channel}-v-$Time$.m4s",
|
||||||
|
MinUpdatePeriod: "PT4S",
|
||||||
|
}
|
||||||
|
out, err := BuildSynthetic(cfg, "ch9", base, "df1633821dddfdd5bec9822c1ec0f052")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(out)
|
||||||
|
for _, want := range []string{
|
||||||
|
`bandwidth="3000000"`, `bandwidth="96000"`,
|
||||||
|
`codecs="hvc1.1.6.L93.B0"`, `codecs="mp4a.40.5"`,
|
||||||
|
`width="1280" height="720"`, `lang="ga"`,
|
||||||
|
`minimumUpdatePeriod="PT4S"`,
|
||||||
|
base + "cmaf/",
|
||||||
|
`initialization="ch9-v-init.m4s"`,
|
||||||
|
`media="ch9-v-$Time$.m4s"`,
|
||||||
|
// audio templates were left empty, so they derive from name + bandwidth
|
||||||
|
`initialization="ch9-audio_128k=96000.dash"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q\n---\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `width="1920"`) || strings.Contains(got, `codecs="avc1`) {
|
||||||
|
t.Error("a default leaked past the config override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSyntheticRejectsBadKID(t *testing.T) {
|
||||||
|
resetGridCache("vcbad")
|
||||||
|
base := originServer(t)
|
||||||
|
if _, err := BuildSynthetic(SyntheticConfig{}, "vcbad", base, "not-a-kid"); err == nil {
|
||||||
|
t.Fatal("expected an error for a malformed KID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaleMSSDoesNotOverflow(t *testing.T) {
|
||||||
|
// A real MSS time (~1.7e16); mss*48000 overflows int64 if done naively.
|
||||||
|
const mss = int64(17_000_000_000_000_000)
|
||||||
|
got := ScaleMSS(mss, DefaultMSSTimescale, 48_000)
|
||||||
|
want := int64(17_000_000_000_000_000 / 10_000_000 * 48_000)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ScaleMSS = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got < 0 {
|
||||||
|
t.Error("ScaleMSS overflowed to a negative value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMSSStreamTimesExpandsRepeats(t *testing.T) {
|
||||||
|
times := ParseMSSStreamTimes(smoothManifest, "video")
|
||||||
|
if len(times) != 4 {
|
||||||
|
t.Fatalf("got %d segment times, want 4 (r=3)", len(times))
|
||||||
|
}
|
||||||
|
for i, ts := range times {
|
||||||
|
want := int64(17_000_000_000_000) + int64(i)*40_000_000
|
||||||
|
if ts != want {
|
||||||
|
t.Errorf("time[%d] = %d, want %d", i, ts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ParseMSSStreamTimes(smoothManifest, "nope") != nil {
|
||||||
|
t.Error("unknown stream name should yield no times")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKIDToUUIDAndHex(t *testing.T) {
|
||||||
|
u, err := KIDToUUID("DF1633821DDDFDD5BEC9822C1EC0F052")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
|
||||||
|
t.Errorf("KIDToUUID = %q", u)
|
||||||
|
}
|
||||||
|
if KIDHex(u) != "df1633821dddfdd5bec9822c1ec0f052" {
|
||||||
|
t.Errorf("KIDHex = %q", KIDHex(u))
|
||||||
|
}
|
||||||
|
if _, err := KIDToUUID("abc"); err == nil {
|
||||||
|
t.Error("short KID should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDefaultKID(t *testing.T) {
|
||||||
|
xml := fmt.Sprintf(`<ContentProtection cenc:default_KID="%s"/>`, "DF163382-1DDD-FDD5-BEC9-822C1EC0F052")
|
||||||
|
if got := ExtractDefaultKID(xml); got != "df163382-1ddd-fdd5-bec9-822c1ec0f052" {
|
||||||
|
t.Errorf("ExtractDefaultKID = %q", got)
|
||||||
|
}
|
||||||
|
if ExtractDefaultKID(`<MPD/>`) != "" {
|
||||||
|
t.Error("no KID should yield empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unregistered or "none" rewriter must report that no rewrite is needed.
|
||||||
|
func TestRewriterRegistry(t *testing.T) {
|
||||||
|
if _, needed := Lookup("none"); needed {
|
||||||
|
t.Error(`"none" must not need a rewrite`)
|
||||||
|
}
|
||||||
|
if _, needed := Lookup(""); needed {
|
||||||
|
t.Error("empty name must not need a rewrite")
|
||||||
|
}
|
||||||
|
if _, needed := Lookup("nosuchprovider"); needed {
|
||||||
|
t.Error("unknown name must not need a rewrite")
|
||||||
|
}
|
||||||
|
Register("testrw", func() Rewriter { return Passthrough{} })
|
||||||
|
// Registering a Passthrough still means "no rewrite needed".
|
||||||
|
if _, needed := Lookup("testrw"); needed {
|
||||||
|
t.Error("a passthrough rewriter must not report needing a rewrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
463
apps/pkg/phonecap/phonecap.go
Normal file
463
apps/pkg/phonecap/phonecap.go
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
// Package phonecap runs one phone MITM capture: proxy → launch → autoplay →
|
||||||
|
// wait → wvkey → session → optional force-stop. Used by capture.exe and agent.
|
||||||
|
package phonecap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/proxy"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
"drmdecryption/session"
|
||||||
|
"drmdecryption/wvkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCAHash is the subject hash of the bundled MITM CA, used when an app
|
||||||
|
// module does not state its own.
|
||||||
|
const DefaultCAHash = "6c3578b4"
|
||||||
|
|
||||||
|
// Options for a single capture job on one device.
|
||||||
|
type Options struct {
|
||||||
|
App app.App
|
||||||
|
Channel string
|
||||||
|
Client *adb.Client // serial-scoped when multi-device
|
||||||
|
Root string
|
||||||
|
Python string
|
||||||
|
WVD string // path to .wvd device file (required for key fetch)
|
||||||
|
UserAgent string // optional license-request User-Agent
|
||||||
|
Wait time.Duration
|
||||||
|
NoAutoPlay bool
|
||||||
|
// KeyMode: "auto" (default), "modulardrm", or "raw".
|
||||||
|
KeyMode string
|
||||||
|
// CloseApp force-stops the package when the job ends (success or failure).
|
||||||
|
CloseApp bool
|
||||||
|
// ClearProxy clears global http_proxy after the job.
|
||||||
|
ClearProxy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the captured session plus on-disk path.
|
||||||
|
type Result struct {
|
||||||
|
Session session.Session
|
||||||
|
Path string
|
||||||
|
Key string
|
||||||
|
MPD string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPassive starts the MITM only — no app launch / auto-play. Waits for
|
||||||
|
// whatever DRM/manifest traffic the phone generates, prints it, and writes
|
||||||
|
// outputs/capture/<stamp>/ when anything useful appears.
|
||||||
|
func RunPassive(opt Options) (res Result, err error) {
|
||||||
|
if opt.Client == nil {
|
||||||
|
opt.Client = adb.New()
|
||||||
|
}
|
||||||
|
if opt.Root == "" {
|
||||||
|
opt.Root = repo.Root()
|
||||||
|
}
|
||||||
|
if opt.Wait <= 0 {
|
||||||
|
opt.Wait = 180 * time.Second
|
||||||
|
}
|
||||||
|
c := opt.Client
|
||||||
|
root := opt.Root
|
||||||
|
|
||||||
|
if opt.ClearProxy {
|
||||||
|
defer ClearProxyLater(c)
|
||||||
|
}
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyBin, caHash := resolveProxy(nil, root)
|
||||||
|
if err := proxy.PushAndStart(c, proxyBin); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
proxy.ReinjectCA(c, caHash)
|
||||||
|
|
||||||
|
fmt.Println("[*] Passive capture — open any app and start playback on the phone")
|
||||||
|
fmt.Println(" (no auto-play; Ctrl+C will not save — wait for traffic or raise --wait)")
|
||||||
|
|
||||||
|
hints := capture.DefaultPassiveHints()
|
||||||
|
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
|
||||||
|
serialTag := c.Serial
|
||||||
|
if serialTag == "" {
|
||||||
|
serialTag = "default"
|
||||||
|
}
|
||||||
|
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
|
||||||
|
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
|
||||||
|
stop := make(chan struct{})
|
||||||
|
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
|
||||||
|
defer close(stop)
|
||||||
|
|
||||||
|
fmt.Println("[*] Waiting for any license / PSSH / manifest…")
|
||||||
|
fmt.Println(" watch:", localLog)
|
||||||
|
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
licenseURL := capData.Get("license_url")
|
||||||
|
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
|
||||||
|
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
|
||||||
|
capData["pssh"] = pssh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[+] Capture:")
|
||||||
|
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
|
||||||
|
v := capData.Get(k)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := k
|
||||||
|
if k == "license_url" {
|
||||||
|
label = "license"
|
||||||
|
}
|
||||||
|
fmt.Println(" ", label+":", trim(v, 110))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := ""
|
||||||
|
if capData.Get("pssh") != "" && licenseURL != "" {
|
||||||
|
k, kerr := fetchKey(opt, root, capData, licenseURL)
|
||||||
|
if kerr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] key fetch skipped: %v\n", kerr)
|
||||||
|
} else {
|
||||||
|
key = k
|
||||||
|
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("[*] Not enough fields for wvkey (need pssh + license_url) — raw capture saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := session.Session{
|
||||||
|
Channel: opt.Channel,
|
||||||
|
PSSH: capData.Get("pssh"),
|
||||||
|
Auth: capData.Get("auth"),
|
||||||
|
PID: capData.Get("pid"),
|
||||||
|
Key: key,
|
||||||
|
MPD: capData.Get("mpd"),
|
||||||
|
LicenseURL: licenseURL,
|
||||||
|
}
|
||||||
|
path, err := session.Write(root, "capture", sess)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
fmt.Println("[+] session:", path)
|
||||||
|
res.Session = sess
|
||||||
|
res.Path = path
|
||||||
|
res.Key = key
|
||||||
|
res.MPD = sess.MPD
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the full phone capture pipeline for a registered app module.
|
||||||
|
func Run(opt Options) (res Result, err error) {
|
||||||
|
if opt.App == nil {
|
||||||
|
return res, fmt.Errorf("app plugin required (pass --app, or omit --app for passive capture)")
|
||||||
|
}
|
||||||
|
if opt.Client == nil {
|
||||||
|
opt.Client = adb.New()
|
||||||
|
}
|
||||||
|
if opt.Root == "" {
|
||||||
|
opt.Root = repo.Root()
|
||||||
|
}
|
||||||
|
if opt.Wait <= 0 {
|
||||||
|
opt.Wait = 180 * time.Second
|
||||||
|
}
|
||||||
|
c := opt.Client
|
||||||
|
a := opt.App
|
||||||
|
root := opt.Root
|
||||||
|
|
||||||
|
// Defers run LIFO: close app first, then clear proxy.
|
||||||
|
if opt.ClearProxy {
|
||||||
|
defer ClearProxyLater(c)
|
||||||
|
}
|
||||||
|
if opt.CloseApp {
|
||||||
|
defer func() {
|
||||||
|
fmt.Printf("[*] Closing %s…\n", a.Package())
|
||||||
|
c.ForceStop(a.Package())
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyBin, caHash := resolveProxy(a, root)
|
||||||
|
hints := a.CaptureHints()
|
||||||
|
useTProxy := false
|
||||||
|
if tm, ok := a.(app.TransparentMITM); ok && tm.UseTransparentMITM() {
|
||||||
|
useTProxy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if useTProxy {
|
||||||
|
// Transparent REDIRECT — keeps phone VPN (BBC UK geo) working.
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
remoteDir := "/data/local/tmp/capture/" + a.Package()
|
||||||
|
if err := proxy.StartTransparent(c, proxyBin, a.Package(), "8080", remoteDir, false); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
defer proxy.StopTransparent(c)
|
||||||
|
hints.RemoteCaps = []string{remoteDir + "/cap.json"}
|
||||||
|
hints.RemoteLog = remoteDir + "/appproxy.log"
|
||||||
|
fmt.Println("[*] Transparent MITM (VPN OK) — package", a.Package())
|
||||||
|
} else {
|
||||||
|
if err := proxy.PushAndStart(c, proxyBin, hints.Score.ProxyArgs()...); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
proxy.ReinjectCA(c, caHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Launch(c); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] launch: %v\n", err)
|
||||||
|
}
|
||||||
|
if !opt.NoAutoPlay {
|
||||||
|
if err := a.AutoPlay(c, opt.Channel); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] auto-play failed: %v\n", err)
|
||||||
|
fmt.Println("[*] Open the channel on the phone manually and start playback…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("[*] Open the channel on the phone and wait for playback…")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Join(root, ".cache"), 0o755)
|
||||||
|
serialTag := c.Serial
|
||||||
|
if serialTag == "" {
|
||||||
|
serialTag = "default"
|
||||||
|
}
|
||||||
|
localLog := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+".log")
|
||||||
|
localCap := filepath.Join(root, ".cache", "appproxy-"+sanitize(serialTag)+"_cap.json")
|
||||||
|
stop := make(chan struct{})
|
||||||
|
go capture.MirrorLogLoop(c, hints.RemoteLog, localLog, stop)
|
||||||
|
defer close(stop)
|
||||||
|
|
||||||
|
need := hints.Require
|
||||||
|
if len(need) == 0 {
|
||||||
|
need = []string{"manifest"}
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] Waiting for %s…\n", strings.Join(need, " + "))
|
||||||
|
fmt.Println(" watch:", localLog)
|
||||||
|
capData, err := capture.Wait(c, hints, localLog, localCap, opt.Wait)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
licenseURL := first(capData.Get("license_url"), a.LicenseURL())
|
||||||
|
keyMode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
|
||||||
|
if keyMode == "" || keyMode == "auto" {
|
||||||
|
keyMode = strings.ToLower(strings.TrimSpace(a.KeyMode()))
|
||||||
|
}
|
||||||
|
// Brightcove/HLS: proxy often captures license + master URL but not PSSH.
|
||||||
|
// Skip for clear/MPD-only apps (KeyMode none).
|
||||||
|
if keyMode != "none" && keyMode != "clear" {
|
||||||
|
if strings.TrimSpace(capData.Get("pssh")) == "" && strings.TrimSpace(capData.Get("mpd")) != "" {
|
||||||
|
if pssh, err := extractHLSPSSH(capData.Get("mpd")); err == nil && pssh != "" {
|
||||||
|
capData["pssh"] = pssh
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] HLS PSSH extract: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mpdURL := strings.TrimSpace(capData.Get("mpd"))
|
||||||
|
// Console: always surface the MPD we will use (BBC clear streams only need this).
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("========== MPD ==========")
|
||||||
|
if mpdURL != "" {
|
||||||
|
fmt.Println(mpdURL)
|
||||||
|
} else {
|
||||||
|
fmt.Println("(none)")
|
||||||
|
}
|
||||||
|
fmt.Println("=========================")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("[+] Capture:")
|
||||||
|
for _, k := range []string{"pid", "mpd", "license_url", "auth", "pssh"} {
|
||||||
|
v := capData.Get(k)
|
||||||
|
if k == "license_url" {
|
||||||
|
v = first(v, licenseURL)
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := k
|
||||||
|
if k == "license_url" {
|
||||||
|
label = "license"
|
||||||
|
}
|
||||||
|
fmt.Println(" ", label+":", trim(v, 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := ""
|
||||||
|
if keyMode == "none" || keyMode == "clear" {
|
||||||
|
fmt.Println("[*] Key mode none — skipping wvkey (clear / MPD-only capture)")
|
||||||
|
} else {
|
||||||
|
var kerr error
|
||||||
|
key, kerr = fetchKey(opt, root, capData, licenseURL)
|
||||||
|
if kerr != nil {
|
||||||
|
return res, kerr
|
||||||
|
}
|
||||||
|
fmt.Println("[+] key:", strings.ReplaceAll(key, "\n", " | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := session.Session{
|
||||||
|
Channel: opt.Channel,
|
||||||
|
PSSH: capData.Get("pssh"),
|
||||||
|
Auth: capData.Get("auth"),
|
||||||
|
PID: capData.Get("pid"),
|
||||||
|
Key: key,
|
||||||
|
MPD: mpdURL,
|
||||||
|
LicenseURL: licenseURL,
|
||||||
|
}
|
||||||
|
path, err := session.Write(root, a.Name(), sess)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
fmt.Println("[+] session:", path)
|
||||||
|
if mpdURL != "" {
|
||||||
|
fmt.Println("[+] mpd:", mpdURL)
|
||||||
|
}
|
||||||
|
res.Session = sess
|
||||||
|
res.Path = path
|
||||||
|
res.Key = key
|
||||||
|
res.MPD = sess.MPD
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearProxyLater clears http_proxy (exported for agent defer helpers).
|
||||||
|
func ClearProxyLater(c *adb.Client) {
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProxy(a app.App, root string) (bin, caHash string) {
|
||||||
|
bin = filepath.Join(root, "bin", "proxy-android-arm64")
|
||||||
|
caHash = DefaultCAHash
|
||||||
|
if a != nil {
|
||||||
|
if p := a.ProxyBin(); p != "" {
|
||||||
|
bin = p
|
||||||
|
}
|
||||||
|
if h := a.CAHash(); h != "" {
|
||||||
|
caHash = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(bin); err != nil {
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(root, "bin", "proxy-android-arm64"),
|
||||||
|
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
|
||||||
|
} {
|
||||||
|
if st, e := os.Stat(p); e == nil && !st.IsDir() {
|
||||||
|
bin = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bin, caHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKey(opt Options, root string, cap capture.Data, licenseURL string) (string, error) {
|
||||||
|
if strings.TrimSpace(opt.WVD) == "" {
|
||||||
|
return "", fmt.Errorf("wvkey requires --wvd (path to .wvd device file)")
|
||||||
|
}
|
||||||
|
py := opt.Python
|
||||||
|
if py == "" {
|
||||||
|
py = filepath.Join(root, ".venv", "Scripts", "python.exe")
|
||||||
|
if _, err := os.Stat(py); err != nil {
|
||||||
|
py = filepath.Join(root, ".venv", "bin", "python")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wopt := wvkey.Options{
|
||||||
|
Python: py,
|
||||||
|
Script: filepath.Join(root, "apps", "wvkey", "wvkey.py"),
|
||||||
|
WVD: opt.WVD,
|
||||||
|
PSSH: cap.Get("pssh"),
|
||||||
|
Auth: cap.Get("auth"),
|
||||||
|
PID: cap.Get("pid"),
|
||||||
|
LicenseURL: licenseURL,
|
||||||
|
UserAgent: opt.UserAgent,
|
||||||
|
}
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(opt.KeyMode))
|
||||||
|
if mode == "" || mode == "auto" {
|
||||||
|
mode = "modulardrm"
|
||||||
|
if opt.App != nil {
|
||||||
|
if m := strings.ToLower(strings.TrimSpace(opt.App.KeyMode())); m != "" {
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "raw":
|
||||||
|
// Brightcove returns multiple CONTENT keys; NRE needs all of them.
|
||||||
|
keys, err := wvkey.FetchRawAll(wopt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.Join(keys, "\n"), nil
|
||||||
|
default:
|
||||||
|
return wvkey.Fetch(wopt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reWVKeyURI = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`)
|
||||||
|
reWVKeyURI2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func extractHLSPSSH(masterURL string) (string, error) {
|
||||||
|
resp, err := http.Get(masterURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := string(body)
|
||||||
|
if m := reWVKeyURI.FindStringSubmatch(s); len(m) == 2 {
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
if m := reWVKeyURI2.FindStringSubmatch(s); len(m) == 2 {
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no Widevine PSSH in HLS master")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitize(s string) string {
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||||
|
return r
|
||||||
|
default:
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
}, s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
577
apps/pkg/proxy/proxy.go
Normal file
577
apps/pkg/proxy/proxy.go
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Universal on-device paths (still kill legacy rteproxy process names).
|
||||||
|
RemoteBin = "/data/local/tmp/appproxy"
|
||||||
|
RemoteCap = "/data/local/tmp/appproxy_cap.json"
|
||||||
|
RemoteLog = "/data/local/tmp/appproxy.log"
|
||||||
|
RemoteTraffic = "/data/local/tmp/appproxy_traffic.jsonl"
|
||||||
|
RemoteCACrt = "/data/local/tmp/rteproxy-ca.crt"
|
||||||
|
DefaultCAHash = "6c3578b4"
|
||||||
|
)
|
||||||
|
|
||||||
|
var reWLANIPv4 = regexp.MustCompile(`(?m)^\s*inet\s+(\d{1,3}(?:\.\d{1,3}){3})/`)
|
||||||
|
|
||||||
|
// DeviceWLANIPv4 returns the phone's current wlan0 IPv4 address.
|
||||||
|
func DeviceWLANIPv4(c *adb.Client) (string, error) {
|
||||||
|
out := c.Out("shell", "ip", "-f", "inet", "addr", "show", "wlan0")
|
||||||
|
if m := reWLANIPv4.FindStringSubmatch(out); len(m) == 2 {
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
// Fallbacks used on some OEM builds.
|
||||||
|
for _, prop := range []string{"dhcp.wlan0.ipaddress", "dhcp.eth0.ipaddress"} {
|
||||||
|
if v := c.Out("shell", "getprop", prop); net.ParseIP(v) != nil && v != "0.0.0.0" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no wlan0 IPv4 (is Wi‑Fi connected?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceHTTPProxyAddr returns "<wlan-ip>:port" for the on-device MITM.
|
||||||
|
// Loopback (127.0.0.1) must not be used: after MITM, some clients rewrite the
|
||||||
|
// upstream Host to the proxy address, and appproxy then dials 127.0.0.1:443.
|
||||||
|
func DeviceHTTPProxyAddr(c *adb.Client, port string) (string, error) {
|
||||||
|
if strings.TrimSpace(port) == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
ip, err := DeviceWLANIPv4(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(ip, port), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeLoopbackProxy(want string) bool {
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(want))
|
||||||
|
if err != nil {
|
||||||
|
host = strings.TrimSpace(want)
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
return host == "127.0.0.1" || host == "localhost" || host == "::1" || host == "0.0.0.0" || host == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureHTTPProxy points the device at the on-device mitm.
|
||||||
|
// Empty or loopback want is rewritten to the phone's WLAN IP:8080 so upstream
|
||||||
|
// MITM dials keep the real destination host (BBC/RTE/etc.).
|
||||||
|
func EnsureHTTPProxy(c *adb.Client, want string) error {
|
||||||
|
if looksLikeLoopbackProxy(want) {
|
||||||
|
port := "8080"
|
||||||
|
if hostport := strings.TrimSpace(want); hostport != "" {
|
||||||
|
if _, p, err := net.SplitHostPort(hostport); err == nil && p != "" {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr, err := DeviceHTTPProxyAddr(c, port)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
want = addr
|
||||||
|
}
|
||||||
|
cur := c.Out("shell", "settings", "get", "global", "http_proxy")
|
||||||
|
if cur == want {
|
||||||
|
fmt.Println("[+] HTTP proxy already", want)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] Setting HTTP proxy -> %s (was %q)\n", want, cur)
|
||||||
|
_, err := c.Shell("settings", "put", "global", "http_proxy", want)
|
||||||
|
got := c.Out("shell", "settings", "get", "global", "http_proxy")
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("failed to set http_proxy (got %q)", got)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHTTPProxy disables the global HTTP proxy and stops any leftover mitm.
|
||||||
|
// Always call this after a capture/agent job so the phone can play apps normally.
|
||||||
|
func ClearHTTPProxy(c *adb.Client) {
|
||||||
|
_, _ = c.Shell("settings", "put", "global", "http_proxy", ":0")
|
||||||
|
_, _ = c.Shell("settings", "delete", "global", "http_proxy")
|
||||||
|
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_host")
|
||||||
|
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_port")
|
||||||
|
_, _ = c.Shell("settings", "delete", "global", "global_http_proxy_exclusion_list")
|
||||||
|
stopProxy(c)
|
||||||
|
fmt.Println("[*] HTTP proxy cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopProxy(c *adb.Client) {
|
||||||
|
script := `
|
||||||
|
for name in appproxy rteproxy; do
|
||||||
|
pid=$(pidof $name 2>/dev/null || true)
|
||||||
|
if [ -n "$pid" ]; then kill $pid >/dev/null 2>&1 || true; fi
|
||||||
|
done
|
||||||
|
sleep 0.5
|
||||||
|
for name in appproxy rteproxy; do
|
||||||
|
pid=$(pidof $name 2>/dev/null || true)
|
||||||
|
if [ -n "$pid" ]; then kill -9 $pid >/dev/null 2>&1 || true; fi
|
||||||
|
done
|
||||||
|
# Root fallback — some builds ignore non-root kill.
|
||||||
|
if command -v su >/dev/null 2>&1; then
|
||||||
|
su -c 'killall appproxy rteproxy 2>/dev/null; killall -9 appproxy rteproxy 2>/dev/null; true' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
sleep 0.3
|
||||||
|
(pidof appproxy || pidof rteproxy) >/dev/null 2>&1 && echo STILL || echo STOPPED`
|
||||||
|
out, _ := c.Shell("sh", "-c", script)
|
||||||
|
if strings.Contains(out, "STILL") {
|
||||||
|
fmt.Println("[!] old appproxy still running after stop — port 8080 may stay busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraFlags renders extra device flags, quoting each value.
|
||||||
|
func extraFlags(args []string) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, a := range args {
|
||||||
|
b.WriteString(" ")
|
||||||
|
if strings.HasPrefix(a, "-") {
|
||||||
|
b.WriteString(a)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("'" + strings.ReplaceAll(a, "'", "") + "'")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushAndStart installs and launches the on-device mitm binary. extraArgs are
|
||||||
|
// appended to its command line — app modules pass their manifest-scoring hosts
|
||||||
|
// that way, since the device binary cannot read a module's values file.
|
||||||
|
func PushAndStart(c *adb.Client, localBin string, extraArgs ...string) error {
|
||||||
|
if st, err := os.Stat(localBin); err != nil || st.IsDir() {
|
||||||
|
return fmt.Errorf("missing proxy binary %s — build apps/proxy first (proxyctl build)", localBin)
|
||||||
|
}
|
||||||
|
fmt.Println("[*] Stopping any old appproxy/rteproxy...")
|
||||||
|
stopProxy(c)
|
||||||
|
|
||||||
|
fmt.Println("[*] Pushing appproxy...")
|
||||||
|
if err := c.Push(localBin, RemoteBin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", RemoteBin)
|
||||||
|
_, _ = c.Shell("rm", "-f", RemoteCap, "/data/local/tmp/rte_cap.json", "/sdcard/Download/rte_cap.json", "/storage/emulated/0/Download/rte_cap.json", RemoteLog, "/data/local/tmp/rteproxy.log")
|
||||||
|
|
||||||
|
starter := "#!/system/bin/sh\n" +
|
||||||
|
"exec " + RemoteBin +
|
||||||
|
" -listen :8080" +
|
||||||
|
" -out " + RemoteCap +
|
||||||
|
" -ca-dir /data/local/tmp" +
|
||||||
|
" -dns 1.1.1.1,1.0.0.1,8.8.8.8,192.168.1.1" +
|
||||||
|
" >>" + RemoteLog + " 2>&1\n"
|
||||||
|
|
||||||
|
tmp := filepath.Join(os.TempDir(), "start_appproxy.sh")
|
||||||
|
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
if err := c.Push(tmp, "/data/local/tmp/start_appproxy.sh"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy.sh")
|
||||||
|
|
||||||
|
// Keep backgrounded after adb exits.
|
||||||
|
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy.sh </dev/null >/dev/null 2>&1 &")
|
||||||
|
|
||||||
|
var pid, logHead string
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
pid = c.Out("shell", "pidof", "appproxy")
|
||||||
|
if pid == "" {
|
||||||
|
pid = c.Out("shell", "pidof", "rteproxy")
|
||||||
|
}
|
||||||
|
logHead = c.Out("shell", "head", "-12", RemoteLog)
|
||||||
|
if pid != "" && strings.Contains(logHead, "listening") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logHead != "" {
|
||||||
|
fmt.Println(logHead)
|
||||||
|
}
|
||||||
|
errLog := c.Out("shell", "cat", RemoteLog)
|
||||||
|
if strings.Contains(errLog, "address already in use") ||
|
||||||
|
(strings.Contains(errLog, "listen ") && strings.Contains(errLog, "bind:")) {
|
||||||
|
fmt.Fprintln(os.Stderr, errLog)
|
||||||
|
return fmt.Errorf("appproxy failed to bind :8080 (old process still holding the port?)")
|
||||||
|
}
|
||||||
|
if pid == "" || !strings.Contains(logHead, "listening") {
|
||||||
|
if errLog != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, errLog)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("appproxy failed to start")
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] appproxy pid=%s; capture -> %s\n", pid, RemoteCap)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLocalBin returns the first existing proxy binary under the repo.
|
||||||
|
func FindLocalBin(root string) string {
|
||||||
|
if root == "" {
|
||||||
|
root = repo.Root()
|
||||||
|
}
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(root, "bin", "proxy-android-arm64"),
|
||||||
|
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
|
||||||
|
filepath.Join(root, "apps", "proxy", "rteproxy-android-arm64"),
|
||||||
|
filepath.Join(root, "bin", "rteproxy-android-arm64"),
|
||||||
|
} {
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLocalCA returns the first existing MITM CA cert under the repo.
|
||||||
|
func FindLocalCA(root string) string {
|
||||||
|
if root == "" {
|
||||||
|
root = repo.Root()
|
||||||
|
}
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(root, "apps", "proxy", "rteproxy-ca.crt"),
|
||||||
|
filepath.Join(root, "data", "proxy-ca.crt"),
|
||||||
|
} {
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AndroidCAHash returns the OpenSSL subject_hash_old used for system CA files.
|
||||||
|
func AndroidCAHash(certPEM []byte) (string, error) {
|
||||||
|
block, _ := pem.Decode(certPEM)
|
||||||
|
if block == nil {
|
||||||
|
return "", fmt.Errorf("no PEM certificate found")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := md5.Sum(cert.RawSubject)
|
||||||
|
n := binary.LittleEndian.Uint32(sum[0:4])
|
||||||
|
return fmt.Sprintf("%08x", n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallCA pushes the MITM CA onto the device as HASH.0 and optionally Magisk-reinjects it.
|
||||||
|
// When reinject is true, runs the full Magisk conscrypt bind (needs root / Magisk busybox).
|
||||||
|
func InstallCA(c *adb.Client, localCA string, reinject bool) (hash string, err error) {
|
||||||
|
if localCA == "" {
|
||||||
|
localCA = FindLocalCA("")
|
||||||
|
}
|
||||||
|
if localCA == "" {
|
||||||
|
return "", fmt.Errorf("no local CA cert found (expected apps/proxy/rteproxy-ca.crt)")
|
||||||
|
}
|
||||||
|
pemBytes, err := os.ReadFile(localCA)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash, err = AndroidCAHash(pemBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] Installing CA %s (hash %s)\n", localCA, hash)
|
||||||
|
|
||||||
|
if err := c.Push(localCA, RemoteCACrt); err != nil {
|
||||||
|
return hash, err
|
||||||
|
}
|
||||||
|
// Also drop a copy named after the process for clarity.
|
||||||
|
_ = c.Push(localCA, "/data/local/tmp/appproxy-ca.crt")
|
||||||
|
|
||||||
|
tmpHash := filepath.Join(os.TempDir(), hash+".0")
|
||||||
|
if err := os.WriteFile(tmpHash, pemBytes, 0o644); err != nil {
|
||||||
|
return hash, err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpHash)
|
||||||
|
remoteHash := "/data/local/tmp/" + hash + ".0"
|
||||||
|
if err := c.Push(tmpHash, remoteHash); err != nil {
|
||||||
|
return hash, err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "644", remoteHash, RemoteCACrt)
|
||||||
|
fmt.Printf("[+] Pushed %s and %s\n", RemoteCACrt, remoteHash)
|
||||||
|
|
||||||
|
if reinject {
|
||||||
|
ReinjectCA(c, hash)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[*] Skipped Magisk reinject (pass -reinject / install-ca --reinject)")
|
||||||
|
fmt.Println(" User-CA path: Settings → Security → Install a certificate → CA certificate")
|
||||||
|
}
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the on-device mitm process.
|
||||||
|
func Stop(c *adb.Client) {
|
||||||
|
stopProxy(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
RemoteTProxyScript = "/data/local/tmp/tproxy_iptables.sh"
|
||||||
|
RemoteCaptureRoot = "/data/local/tmp/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartTransparent pushes appproxy in -transparent mode (no Wi‑Fi http_proxy),
|
||||||
|
// installs iptables UID REDIRECT for pkg, and writes captures under remoteDir.
|
||||||
|
func StartTransparent(c *adb.Client, localBin, pkg, port, remoteDir string, reinject bool) error {
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
if remoteDir == "" {
|
||||||
|
remoteDir = RemoteCaptureRoot + "/" + pkg
|
||||||
|
}
|
||||||
|
stopProxy(c)
|
||||||
|
_ = stopTransparentRules(c)
|
||||||
|
|
||||||
|
// Ensure no global HTTP proxy — transparent mode must not use one.
|
||||||
|
ClearHTTPProxy(c)
|
||||||
|
|
||||||
|
if reinject {
|
||||||
|
ReinjectCA(c, DefaultCAHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, err := os.Stat(localBin); err != nil || st.IsDir() {
|
||||||
|
return fmt.Errorf("missing proxy binary %s", localBin)
|
||||||
|
}
|
||||||
|
fmt.Println("[*] Pushing appproxy (transparent)...")
|
||||||
|
if err := c.Push(localBin, RemoteBin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", RemoteBin)
|
||||||
|
|
||||||
|
scriptLocal := filepath.Join(repo.Root(), "apps", "proxy", "device", "tproxy_iptables.sh")
|
||||||
|
if _, err := os.Stat(scriptLocal); err != nil {
|
||||||
|
return fmt.Errorf("missing %s", scriptLocal)
|
||||||
|
}
|
||||||
|
if err := c.Push(scriptLocal, RemoteTProxyScript); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", RemoteTProxyScript)
|
||||||
|
_, _ = c.Shell("mkdir", "-p", remoteDir)
|
||||||
|
_, _ = c.Shell("rm", "-f", remoteDir+"/cap.json", remoteDir+"/traffic.jsonl", remoteDir+"/appproxy.log")
|
||||||
|
|
||||||
|
starter := "#!/system/bin/sh\n" +
|
||||||
|
"mkdir -p '" + remoteDir + "'\n" +
|
||||||
|
"exec " + RemoteBin +
|
||||||
|
" -transparent" +
|
||||||
|
" -listen :" + port +
|
||||||
|
" -mitm-internal 127.0.0.1:18080" +
|
||||||
|
" -out-dir '" + remoteDir + "'" +
|
||||||
|
" -ca-dir /data/local/tmp" +
|
||||||
|
" -dns 1.1.1.1,1.0.0.1,8.8.8.8" +
|
||||||
|
" -log-all" +
|
||||||
|
" -v" +
|
||||||
|
"\n"
|
||||||
|
tmp := filepath.Join(os.TempDir(), "start_appproxy_tproxy.sh")
|
||||||
|
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
if err := c.Push(tmp, "/data/local/tmp/start_appproxy_tproxy.sh"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy_tproxy.sh")
|
||||||
|
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy_tproxy.sh </dev/null >/dev/null 2>&1 &")
|
||||||
|
|
||||||
|
var pid string
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
pid = c.Out("shell", "pidof", "appproxy")
|
||||||
|
if pid != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pid == "" {
|
||||||
|
return fmt.Errorf("appproxy failed to start (transparent)")
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] appproxy pid=%s; capture → %s\n", pid, remoteDir)
|
||||||
|
|
||||||
|
out, errOut, err := c.Run("shell", "su", "-c", "sh "+RemoteTProxyScript+" start "+pkg+" "+port)
|
||||||
|
fmt.Print(out)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("iptables: %v (%s)", err, strings.TrimSpace(errOut+out))
|
||||||
|
}
|
||||||
|
fmt.Println("[+] iptables REDIRECT installed (UK VPN can stay ON; do not set Wi‑Fi proxy)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTransparent removes iptables rules and stops appproxy (does not require Wi‑Fi proxy clear beyond safety).
|
||||||
|
func StopTransparent(c *adb.Client) {
|
||||||
|
_ = stopTransparentRules(c)
|
||||||
|
stopProxy(c)
|
||||||
|
ClearHTTPProxy(c)
|
||||||
|
fmt.Println("[*] transparent capture stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTransparentRules(c *adb.Client) error {
|
||||||
|
out, errOut, err := c.Run("shell", "su", "-c", "sh "+RemoteTProxyScript+" stop")
|
||||||
|
if strings.TrimSpace(out) != "" {
|
||||||
|
fmt.Print(out)
|
||||||
|
}
|
||||||
|
if err != nil && !strings.Contains(errOut+out, "STOPPED") {
|
||||||
|
return fmt.Errorf("iptables stop: %v %s", err, errOut)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullDir pulls regular files under remoteDir into destDir.
|
||||||
|
// Avoids `adb shell sh -c "ls …"` — on Windows that often lists `/` instead of the path.
|
||||||
|
func PullDir(c *adb.Client, remoteDir, destDir string) error {
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteDir = strings.TrimRight(strings.TrimSpace(remoteDir), "/")
|
||||||
|
// Prefer known capture artifacts; fall back to find -type f.
|
||||||
|
// Do not use `adb shell sh -c "ls …"` — on Windows that often lists `/`.
|
||||||
|
var names []string
|
||||||
|
for _, n := range []string{"cap.json", "traffic.jsonl", "appproxy.log"} {
|
||||||
|
if fileExistsOnDevice(c, remoteDir+"/"+n) {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
list := c.Out("shell", "find", remoteDir, "-maxdepth", "1", "-type", "f")
|
||||||
|
for _, line := range strings.Split(list, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, filepath.Base(filepath.Clean(line)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return fmt.Errorf("no files in %s", remoteDir)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
pulled := 0
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
remote := remoteDir + "/" + name
|
||||||
|
local := filepath.Join(destDir, name)
|
||||||
|
if err := c.Pull(remote, local); err != nil {
|
||||||
|
fmt.Printf("[!] pull %s: %v\n", remote, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] %s\n", local)
|
||||||
|
pulled++
|
||||||
|
}
|
||||||
|
if pulled == 0 {
|
||||||
|
return fmt.Errorf("failed to pull any files from %s", remoteDir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExistsOnDevice(c *adb.Client, remote string) bool {
|
||||||
|
_, _, err := c.Run("shell", "ls", remote)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullCaptures pulls traffic/cap/log into destDir (created if missing).
|
||||||
|
func PullCaptures(c *adb.Client, destDir string) error {
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, remote := range []string{RemoteTraffic, RemoteCap, RemoteLog} {
|
||||||
|
base := filepath.Base(remote)
|
||||||
|
local := filepath.Join(destDir, base)
|
||||||
|
if err := c.Pull(remote, local); err != nil {
|
||||||
|
fmt.Printf("[!] pull %s: %v\n", remote, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] %s\n", local)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverOutDir returns outputs/discover/<stamp> under the repo root.
|
||||||
|
func DiscoverOutDir(root, stamp string) string {
|
||||||
|
if root == "" {
|
||||||
|
root = repo.Root()
|
||||||
|
}
|
||||||
|
if stamp == "" {
|
||||||
|
stamp = time.Now().Format("20060102-150405")
|
||||||
|
}
|
||||||
|
return filepath.Join(root, "outputs", "discover", stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReinjectCA best-effort Magisk bind of the mitm CA into conscrypt.
|
||||||
|
func ReinjectCA(c *adb.Client, caHash string) {
|
||||||
|
if caHash == "" {
|
||||||
|
caHash = DefaultCAHash
|
||||||
|
}
|
||||||
|
// Bound the per-app nsenter loop — wait on hundreds of PIDs can hang forever.
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
BB=/data/adb/magisk/busybox
|
||||||
|
HASH=%s
|
||||||
|
[ -f /data/local/tmp/$HASH.0 ] || { echo CA_SKIP; exit 0; }
|
||||||
|
[ -x "$BB" ] || { echo CA_SKIP; exit 0; }
|
||||||
|
rm -rf /data/local/tmp/cacerts-overlay
|
||||||
|
mkdir -p /data/local/tmp/cacerts-overlay
|
||||||
|
cp /apex/com.android.conscrypt/cacerts/* /data/local/tmp/cacerts-overlay/ 2>/dev/null || true
|
||||||
|
cp /data/local/tmp/$HASH.0 /data/local/tmp/cacerts-overlay/$HASH.0
|
||||||
|
chmod 644 /data/local/tmp/cacerts-overlay/*
|
||||||
|
$BB mount -t tmpfs tmpfs /system/etc/security/cacerts 2>/dev/null || true
|
||||||
|
cp /data/local/tmp/cacerts-overlay/* /system/etc/security/cacerts/ 2>/dev/null || true
|
||||||
|
chmod 644 /system/etc/security/cacerts/* 2>/dev/null || true
|
||||||
|
chcon u:object_r:system_file:s0 /system/etc/security/cacerts/* 2>/dev/null || true
|
||||||
|
$BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
|
||||||
|
for Z in $(pidof zygote64 2>/dev/null); do
|
||||||
|
nsenter --mount=/proc/$Z/ns/mnt -- $BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
|
||||||
|
done
|
||||||
|
# Only rebind the target app if set; otherwise skip the full zygote-child sweep (hangs).
|
||||||
|
PKG_UID_FILE=/data/local/tmp/reinject_target_uid
|
||||||
|
if [ -f "$PKG_UID_FILE" ]; then
|
||||||
|
TUID=$(cat "$PKG_UID_FILE")
|
||||||
|
for PID in $(ps -A -o PID=,UID= 2>/dev/null | awk -v u="$TUID" '$2==u {print $1}'); do
|
||||||
|
nsenter --mount=/proc/$PID/ns/mnt -- $BB mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
ls /apex/com.android.conscrypt/cacerts/$HASH.0 2>/dev/null && echo CA_OK || echo CA_SKIP
|
||||||
|
`, caHash)
|
||||||
|
fmt.Println("[*] Re-injecting system CA (Magisk)...")
|
||||||
|
tmp := filepath.Join(os.TempDir(), "reinject_ca.sh")
|
||||||
|
_ = os.WriteFile(tmp, []byte("#!/system/bin/sh\n"+script), 0o755)
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
_ = c.Push(tmp, "/data/local/tmp/reinject_ca.sh")
|
||||||
|
_, _ = c.Shell("chmod", "755", "/data/local/tmp/reinject_ca.sh")
|
||||||
|
// Host-side timeout: su -mm + nsenter has hung on some Magisk builds.
|
||||||
|
type runResult struct {
|
||||||
|
out string
|
||||||
|
}
|
||||||
|
ch := make(chan runResult, 1)
|
||||||
|
go func() {
|
||||||
|
out, _, _ := c.Run("shell", "su", "-mm", "-c", "sh /data/local/tmp/reinject_ca.sh")
|
||||||
|
ch <- runResult{out: out}
|
||||||
|
}()
|
||||||
|
var out string
|
||||||
|
select {
|
||||||
|
case r := <-ch:
|
||||||
|
out = r.out
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
fmt.Println("[!] CA reinject timed out after 20s — continuing (CA likely already mounted)")
|
||||||
|
_, _ = c.Shell("su", "-c", "killall reinject_ca.sh 2>/dev/null; true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "CA_OK") {
|
||||||
|
fmt.Println("[+] System CA present in conscrypt")
|
||||||
|
} else {
|
||||||
|
fmt.Println("[!] CA inject skipped/failed — if TLS errors, re-run Magisk CA mount")
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/pkg/repo/root.go
Normal file
42
apps/pkg/repo/root.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Root finds the repository root (directory that contains apps/pkg/go.mod
|
||||||
|
// and is not itself under apps/ — i.e. the real checkout root).
|
||||||
|
func Root() string {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
wd = "."
|
||||||
|
}
|
||||||
|
cur := wd
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
// Require apps/pkg/go.mod AND a sibling marker so that walking from
|
||||||
|
// inside apps/pkg does not treat apps/ as the root (apps/pkg/go.mod
|
||||||
|
// would match the old top-level pkg/ layout check).
|
||||||
|
pkgMod := filepath.Join(cur, "apps", "pkg", "go.mod")
|
||||||
|
if _, err := os.Stat(pkgMod); err == nil {
|
||||||
|
if markerOK(cur) {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
|
||||||
|
func markerOK(root string) bool {
|
||||||
|
for _, name := range []string{"build.ps1", "README.md", "configs", "outputs"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
101
apps/pkg/session/session.go
Normal file
101
apps/pkg/session/session.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is the on-disk capture result (shared with restream tooling).
|
||||||
|
type Session struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
CapturedAt string `json:"captured_at"`
|
||||||
|
PSSH string `json:"pssh,omitempty"`
|
||||||
|
Auth string `json:"auth,omitempty"`
|
||||||
|
PID string `json:"pid,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
KID string `json:"kid,omitempty"`
|
||||||
|
KeyHex string `json:"key_hex,omitempty"`
|
||||||
|
MPD string `json:"mpd,omitempty"`
|
||||||
|
LicenseURL string `json:"license_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write creates outputs/<app>/<stamp>/session.json (+ field txt files + latest).
|
||||||
|
func Write(root, app string, s Session) (string, error) {
|
||||||
|
stamp := time.Now().Format("20060102-150405")
|
||||||
|
dir := filepath.Join(root, "outputs", app, stamp)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.App = app
|
||||||
|
if s.CapturedAt == "" {
|
||||||
|
s.CapturedAt = time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if s.Key != "" && s.KID == "" {
|
||||||
|
parts := strings.SplitN(s.Key, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
s.KID = parts[0]
|
||||||
|
s.KeyHex = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "session.json")
|
||||||
|
if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"pssh": s.PSSH,
|
||||||
|
"auth": s.Auth,
|
||||||
|
"pid": s.PID,
|
||||||
|
"key": s.Key,
|
||||||
|
"mpd": s.MPD,
|
||||||
|
}
|
||||||
|
for name, val := range fields {
|
||||||
|
if val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, name+".txt"), []byte(val+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
latest := filepath.Join(root, "outputs", app, "latest")
|
||||||
|
_ = os.RemoveAll(latest)
|
||||||
|
// Best-effort directory copy for Windows (symlinks often need admin).
|
||||||
|
if err := copyDir(dir, latest); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] latest link/copy: %v\n", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
in := filepath.Join(src, e.Name())
|
||||||
|
out := filepath.Join(dst, e.Name())
|
||||||
|
if e.IsDir() {
|
||||||
|
if err := copyDir(in, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(out, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
apps/pkg/session/stream.go
Normal file
66
apps/pkg/session/stream.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stream is a streamd-ready description of one playable channel, as produced by
|
||||||
|
// a provider catalog lookup. MPD holds the manifest URL whatever its type.
|
||||||
|
type Stream struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Keys []string `json:"keys,omitempty"`
|
||||||
|
PSSH string `json:"pssh,omitempty"`
|
||||||
|
PrimaryKID string `json:"primary_kid,omitempty"`
|
||||||
|
HeadersJSON string `json:"headers_json"`
|
||||||
|
Rewriter string `json:"rewriter"`
|
||||||
|
LicenseURL string `json:"license_url,omitempty"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
VideoID string `json:"video_id,omitempty"`
|
||||||
|
PlaybackURL string `json:"playback_url,omitempty"`
|
||||||
|
ManifestType string `json:"manifest_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteStream writes outputs/<app>/<stamp>/{session.json,mpd.txt,license.txt,…}
|
||||||
|
// and refreshes outputs/<app>/latest. The app name comes from the caller so no
|
||||||
|
// provider name is baked in here.
|
||||||
|
func WriteStream(root, app string, info Stream) (string, error) {
|
||||||
|
stamp := time.Now().Format("20060102-150405")
|
||||||
|
dir := filepath.Join(root, "outputs", app, stamp)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(info, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "session.json"), append(raw, '\n'), 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
write := func(name, val string) {
|
||||||
|
if val == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, name), []byte(val+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
write("mpd.txt", info.MPD)
|
||||||
|
write("license.txt", info.LicenseURL)
|
||||||
|
write("pssh.txt", info.PSSH)
|
||||||
|
write("key.txt", info.Key)
|
||||||
|
write("pid.txt", info.VideoID)
|
||||||
|
if len(info.Keys) > 0 {
|
||||||
|
write("keys.txt", strings.Join(info.Keys, "\n"))
|
||||||
|
}
|
||||||
|
latest := filepath.Join(root, "outputs", app, "latest")
|
||||||
|
_ = os.RemoveAll(latest)
|
||||||
|
_ = copyDir(dir, latest)
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
360
apps/pkg/uiflow/uiflow.go
Normal file
360
apps/pkg/uiflow/uiflow.go
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
// Package uiflow holds the phone-UI primitives app modules drive playback with:
|
||||||
|
// wait for a node, tap a node, wait for playback, find scrollable live cards.
|
||||||
|
// It knows nothing about any particular app — every threshold and pattern is an
|
||||||
|
// argument.
|
||||||
|
package uiflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pick selects among several matching nodes.
|
||||||
|
type Pick string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PickLargest Pick = "largest"
|
||||||
|
PickSmallest Pick = "smallest"
|
||||||
|
PickFirst Pick = "first"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match describes which UI node to look for. An empty Match never matches.
|
||||||
|
type Match struct {
|
||||||
|
TextRE []string
|
||||||
|
DescRE []string
|
||||||
|
TextContains []string
|
||||||
|
DescContains []string
|
||||||
|
ResourceContains []string
|
||||||
|
Pick Pick
|
||||||
|
MinArea int
|
||||||
|
}
|
||||||
|
|
||||||
|
type compiled struct {
|
||||||
|
textRE, descRE []*regexp.Regexp
|
||||||
|
m Match
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompileRes compiles case-insensitive patterns, skipping empties.
|
||||||
|
func CompileRes(pats []string) []*regexp.Regexp {
|
||||||
|
out := make([]*regexp.Regexp, 0, len(pats))
|
||||||
|
for _, p := range pats {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, regexp.MustCompile("(?i)"+p))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Match) compile() compiled {
|
||||||
|
return compiled{textRE: CompileRes(m.TextRE), descRE: CompileRes(m.DescRE), m: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c compiled) empty() bool {
|
||||||
|
return len(c.textRE) == 0 && len(c.descRE) == 0 &&
|
||||||
|
len(c.m.TextContains) == 0 && len(c.m.DescContains) == 0 && len(c.m.ResourceContains) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c compiled) matches(n adb.Node) bool {
|
||||||
|
if c.empty() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n.Text != "" {
|
||||||
|
for _, p := range c.textRE {
|
||||||
|
if p.MatchString(n.Text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range c.m.TextContains {
|
||||||
|
if s != "" && strings.Contains(strings.ToLower(n.Text), strings.ToLower(s)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Desc != "" {
|
||||||
|
for _, p := range c.descRE {
|
||||||
|
if p.MatchString(n.Desc) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range c.m.DescContains {
|
||||||
|
if s != "" && strings.Contains(strings.ToLower(n.Desc), strings.ToLower(s)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range c.m.ResourceContains {
|
||||||
|
if s != "" && strings.Contains(n.ResourceID, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickNode(hits []adb.Node, pick Pick, minArea int) *adb.Node {
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var best *adb.Node
|
||||||
|
switch pick {
|
||||||
|
case PickSmallest:
|
||||||
|
best = &hits[0]
|
||||||
|
for i := range hits {
|
||||||
|
if hits[i].Area() < best.Area() {
|
||||||
|
best = &hits[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PickFirst:
|
||||||
|
best = &hits[0]
|
||||||
|
default: // largest
|
||||||
|
best = &hits[0]
|
||||||
|
for i := range hits {
|
||||||
|
if hits[i].Area() > best.Area() {
|
||||||
|
best = &hits[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minArea > 0 && best.Area() < minArea {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find polls the UI until a node matches, returning it.
|
||||||
|
func Find(c *adb.Client, cacheDir string, m Match, timeout time.Duration) (*adb.Node, error) {
|
||||||
|
cm := m.compile()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
nodes, err := c.DumpUI(cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var hits []adb.Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
if cm.matches(n) {
|
||||||
|
hits = append(hits, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := pickNode(hits, m.Pick, m.MinArea); n != nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ui node not found within %s", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitUI waits for a node to exist without tapping it.
|
||||||
|
func WaitUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
|
||||||
|
_, err := Find(c, cacheDir, m, timeout)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TapUI waits for a node and taps its centre.
|
||||||
|
func TapUI(c *adb.Client, cacheDir string, m Match, timeout time.Duration) error {
|
||||||
|
n, err := Find(c, cacheDir, m, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] tap_ui %q/%q @ %d,%d\n", n.Text, n.Desc, n.CX(), n.CY())
|
||||||
|
return c.Tap(n.CX(), n.CY())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackOpts tunes WaitPlayback. The UI signals are a fallback for players
|
||||||
|
// whose audio session does not show up in dumpsys.
|
||||||
|
type PlaybackOpts struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
SoftFail bool
|
||||||
|
OrDescContains []string
|
||||||
|
OrResourceContains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitPlayback blocks until the package is playing audio, or a UI signal says the
|
||||||
|
// player is up. With SoftFail a timeout is logged and tolerated.
|
||||||
|
func WaitPlayback(c *adb.Client, cacheDir, pkg string, opt PlaybackOpts) error {
|
||||||
|
if opt.Timeout <= 0 {
|
||||||
|
opt.Timeout = 45 * time.Second
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(opt.Timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if c.PlaybackActive(pkg) {
|
||||||
|
fmt.Printf("[+] %s is PLAYING\n", pkg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(opt.OrDescContains) > 0 || len(opt.OrResourceContains) > 0 {
|
||||||
|
if nodes, err := c.DumpUI(cacheDir); err == nil {
|
||||||
|
for _, n := range nodes {
|
||||||
|
desc := strings.ToLower(n.Desc)
|
||||||
|
for _, d := range opt.OrDescContains {
|
||||||
|
if d != "" && strings.Contains(desc, strings.ToLower(d)) {
|
||||||
|
fmt.Printf("[+] UI signal %q\n", d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range opt.OrResourceContains {
|
||||||
|
if r != "" && strings.Contains(n.ResourceID, r) {
|
||||||
|
fmt.Printf("[+] resource signal %q\n", r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
if opt.SoftFail {
|
||||||
|
fmt.Printf("[!] timed out waiting for playback; continuing capture\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("timed out waiting for playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardOpts describes what a content card looks like in a given app: a clickable
|
||||||
|
// row at least MinH tall and MinW wide, below MinY, whose description/text is not
|
||||||
|
// chrome. Callers supply the denylists; nothing is hardcoded.
|
||||||
|
type CardOpts struct {
|
||||||
|
MinY, MinH, MinW int
|
||||||
|
DescDeny []string
|
||||||
|
TextDeny []string
|
||||||
|
// ScrollMax limits swipe attempts when the wanted index is off-screen.
|
||||||
|
ScrollMax int
|
||||||
|
// Swipe coordinates used to scroll the card list.
|
||||||
|
SwipeX, SwipeFromY, SwipeToY, SwipeMS int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaults fills unset geometry with values that suit a typical 1080x1920
|
||||||
|
// phone card list.
|
||||||
|
func (o CardOpts) WithDefaults() CardOpts {
|
||||||
|
if o.MinY <= 0 {
|
||||||
|
o.MinY = 300
|
||||||
|
}
|
||||||
|
if o.MinH <= 0 {
|
||||||
|
o.MinH = 250
|
||||||
|
}
|
||||||
|
if o.MinW <= 0 {
|
||||||
|
o.MinW = 600
|
||||||
|
}
|
||||||
|
if o.ScrollMax <= 0 {
|
||||||
|
o.ScrollMax = 8
|
||||||
|
}
|
||||||
|
if o.SwipeX == 0 {
|
||||||
|
o.SwipeX = 540
|
||||||
|
}
|
||||||
|
if o.SwipeFromY == 0 {
|
||||||
|
o.SwipeFromY = 1700
|
||||||
|
}
|
||||||
|
if o.SwipeToY == 0 {
|
||||||
|
o.SwipeToY = 700
|
||||||
|
}
|
||||||
|
if o.SwipeMS == 0 {
|
||||||
|
o.SwipeMS = 350
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards returns the content cards visible in a UI dump, top to bottom.
|
||||||
|
func Cards(nodes []adb.Node, opt CardOpts) []adb.Node {
|
||||||
|
opt = opt.WithDefaults()
|
||||||
|
var cards []adb.Node
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !n.Clickable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := n.Y2 - n.Y1
|
||||||
|
w := n.X2 - n.X1
|
||||||
|
if n.Y1 < opt.MinY || h < opt.MinH || w < opt.MinW {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsAny(n.Desc, opt.DescDeny) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if containsAnyFold(n.Text, opt.TextDeny) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cards = append(cards, n)
|
||||||
|
}
|
||||||
|
sortByTop(cards)
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitCard polls for the index-th content card, scrolling when it is not yet on
|
||||||
|
// screen (each scroll consumes one index, matching how the list advances).
|
||||||
|
func WaitCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) (*adb.Node, error) {
|
||||||
|
opt = opt.WithDefaults()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
target := index
|
||||||
|
var last int
|
||||||
|
scrolled := 0
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
nodes, err := c.DumpUI(cacheDir)
|
||||||
|
if err == nil {
|
||||||
|
cards := Cards(nodes, opt)
|
||||||
|
last = len(cards)
|
||||||
|
if target >= 0 && target < len(cards) {
|
||||||
|
card := cards[target]
|
||||||
|
return &card, nil
|
||||||
|
}
|
||||||
|
if target >= last && last > 0 && scrolled < opt.ScrollMax {
|
||||||
|
fmt.Printf("[*] card need local #%d (%d on screen) — scrolling\n", target, last)
|
||||||
|
_ = c.Swipe(opt.SwipeX, opt.SwipeFromY, opt.SwipeX, opt.SwipeToY, opt.SwipeMS)
|
||||||
|
scrolled++
|
||||||
|
if target > 0 {
|
||||||
|
target--
|
||||||
|
}
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("only saw %d cards (need index %d)", last, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TapCard waits for and taps the index-th content card.
|
||||||
|
func TapCard(c *adb.Client, cacheDir string, index int, timeout time.Duration, opt CardOpts) error {
|
||||||
|
card, err := WaitCard(c, cacheDir, index, timeout, opt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] tap card #%d @ %d,%d\n", index, card.CX(), card.CY())
|
||||||
|
return c.Tap(card.CX(), card.CY())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonkeyLaunch starts an app through its launcher intent.
|
||||||
|
func MonkeyLaunch(c *adb.Client, pkg string) error {
|
||||||
|
_, _, err := c.Run("shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(s string, needles []string) bool {
|
||||||
|
for _, n := range needles {
|
||||||
|
if n != "" && strings.Contains(s, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyFold(s string, needles []string) bool {
|
||||||
|
ls := strings.ToLower(s)
|
||||||
|
for _, n := range needles {
|
||||||
|
if n != "" && strings.Contains(ls, strings.ToLower(n)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortByTop(cards []adb.Node) {
|
||||||
|
for i := 0; i < len(cards); i++ {
|
||||||
|
for j := i + 1; j < len(cards); j++ {
|
||||||
|
if cards[j].Y1 < cards[i].Y1 {
|
||||||
|
cards[i], cards[j] = cards[j], cards[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
apps/pkg/wvkey/wvkey.go
Normal file
128
apps/pkg/wvkey/wvkey.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package wvkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options for the Python wvkey.py helper.
|
||||||
|
type Options struct {
|
||||||
|
Python string // e.g. .venv/Scripts/python.exe
|
||||||
|
Script string // path to wvkey.py
|
||||||
|
WVD string
|
||||||
|
PSSH string
|
||||||
|
Auth string
|
||||||
|
PID string
|
||||||
|
LicenseURL string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch runs wvkey.py --quiet (ModularDrm) and returns the first KID:KEY line.
|
||||||
|
func Fetch(opt Options) (string, error) {
|
||||||
|
lines, err := run(opt, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "", fmt.Errorf("wvkey produced no KID:KEY")
|
||||||
|
}
|
||||||
|
return lines[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRaw runs wvkey.py --mode raw (Brightcove / octet-stream license).
|
||||||
|
func FetchRaw(opt Options) (string, error) {
|
||||||
|
lines, err := run(opt, true, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "", fmt.Errorf("wvkey produced no KID:KEY")
|
||||||
|
}
|
||||||
|
return lines[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRawAll returns every CONTENT key from a raw license response.
|
||||||
|
func FetchRawAll(opt Options) ([]string, error) {
|
||||||
|
return run(opt, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(opt Options, raw, all bool) ([]string, error) {
|
||||||
|
py := opt.Python
|
||||||
|
if py == "" {
|
||||||
|
py = findPython()
|
||||||
|
}
|
||||||
|
script := opt.Script
|
||||||
|
if script == "" {
|
||||||
|
return nil, fmt.Errorf("wvkey script path required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opt.WVD) == "" {
|
||||||
|
return nil, fmt.Errorf("wvkey --wvd path required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opt.PSSH) == "" {
|
||||||
|
return nil, fmt.Errorf("wvkey --pssh required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opt.LicenseURL) == "" {
|
||||||
|
return nil, fmt.Errorf("wvkey --license-url required")
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
script,
|
||||||
|
"--wvd", opt.WVD,
|
||||||
|
"--pssh", opt.PSSH,
|
||||||
|
"--license-url", opt.LicenseURL,
|
||||||
|
"--quiet",
|
||||||
|
}
|
||||||
|
if raw {
|
||||||
|
args = append(args, "--mode", "raw")
|
||||||
|
} else {
|
||||||
|
args = append(args, "--mode", "modulardrm", "--auth", opt.Auth, "--pid", opt.PID)
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
args = append(args, "--all")
|
||||||
|
}
|
||||||
|
if opt.UserAgent != "" {
|
||||||
|
args = append(args, "--user-agent", opt.UserAgent)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(py, args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("wvkey failed: %w\n%s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.Count(line, ":") == 1 && len(line) > 40 {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("wvkey produced no KID:KEY (stderr=%s)", strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPython() string {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(".venv", "Scripts", "python.exe"),
|
||||||
|
filepath.Join(".venv", "bin", "python"),
|
||||||
|
"python3",
|
||||||
|
"python",
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if filepath.IsAbs(c) || strings.Contains(c, string(os.PathSeparator)) {
|
||||||
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath(c); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "python"
|
||||||
|
}
|
||||||
102
apps/proxy/README.md
Normal file
102
apps/proxy/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# proxy — on-device HTTPS MITM (appproxy)
|
||||||
|
|
||||||
|
Host CLI plus the Android binary that MITMs phone HTTPS, so a capture can see
|
||||||
|
license URLs, manifests and auth headers.
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/proxy/
|
||||||
|
proxyctlcmd/ host CLI, hosted by bin/drm as `drm proxy`
|
||||||
|
device/ linux/arm64 MITM source (module: appproxy)
|
||||||
|
rteproxy-ca.crt MITM CA (subject hash 6c3578b4)
|
||||||
|
```
|
||||||
|
|
||||||
|
The process name on the phone is **`appproxy`**. Some on-disk names still say
|
||||||
|
`rteproxy-*`, and those paths are still read, so an already-provisioned phone keeps
|
||||||
|
working.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go -C apps/cli build -o ../../bin/drm . # host CLI
|
||||||
|
./bin/drm proxy build # cross-compile the device binary
|
||||||
|
```
|
||||||
|
|
||||||
|
`proxy build` compiles `device/` for linux/arm64 into
|
||||||
|
`apps/proxy/proxy-android-arm64` and copies it to `bin/proxy-android-arm64`.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# trust the MITM CA (needs Magisk; mounts into the system store)
|
||||||
|
./bin/drm proxy install-ca --reinject
|
||||||
|
./bin/drm proxy reinject-ca # mount only, CA already pushed
|
||||||
|
|
||||||
|
# structured capture proxy
|
||||||
|
./bin/drm proxy start --install-ca --reinject
|
||||||
|
./bin/drm proxy stop
|
||||||
|
|
||||||
|
# record everything while you explore an unknown app
|
||||||
|
./bin/drm proxy discover --install-ca --reinject
|
||||||
|
./bin/drm proxy discover --force-stop <package> # so it inherits the CA mount
|
||||||
|
|
||||||
|
# pull artifacts without re-running, and release the phone
|
||||||
|
./bin/drm proxy pull
|
||||||
|
./bin/drm proxy clear-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Always clear the proxy when done, or the phone keeps pointing at a dead listener.
|
||||||
|
|
||||||
|
### Transparent mode (UK VPN OK — no Wi‑Fi HTTP proxy)
|
||||||
|
|
||||||
|
Root `iptables` redirects only one app’s TCP/443 into on-device `appproxy`. The
|
||||||
|
phone can stay on NordVPN UK; nothing sets `http_proxy`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# leave UK VPN connected on the phone, then:
|
||||||
|
./bin/drm proxy transparent --package bbc.iplayer.android --reinject
|
||||||
|
# open the app / play — Ctrl+C stops iptables and pulls files
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes on device (adb-readable):
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/local/tmp/capture/<package>/cap.json
|
||||||
|
/data/local/tmp/capture/<package>/traffic.jsonl
|
||||||
|
/data/local/tmp/capture/<package>/appproxy.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb pull /data/local/tmp/capture/bbc.iplayer.android ./bbc-cap
|
||||||
|
```
|
||||||
|
|
||||||
|
Force-stop the target app once after CA reinject so it inherits the Magisk CA mount.
|
||||||
|
|
||||||
|
The device binary cannot read an app module's values file, so a module's
|
||||||
|
manifest-scoring hosts are passed to it as flags (`--match`, `--score-host`,
|
||||||
|
`--score-deny`). `drm capture` does this automatically.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|---|---|
|
||||||
|
| `appproxy_traffic.jsonl` / `traffic.jsonl` | every HTTP(S) request/response (discover / transparent) |
|
||||||
|
| `appproxy_cap.json` / `cap.json` | structured mpd / license / auth / pssh when detected |
|
||||||
|
| `appproxy.log` | tagged lines: `[MPD]` `[LIC]` `[PSSH]` `[MAN]` `[TPROXY]` |
|
||||||
|
|
||||||
|
Pulled into `outputs/discover/<stamp>/` or `outputs/transparent/<stamp>/`.
|
||||||
|
|
||||||
|
## Device paths
|
||||||
|
|
||||||
|
| Remote | Role |
|
||||||
|
|---|---|
|
||||||
|
| `/data/local/tmp/appproxy` | binary |
|
||||||
|
| `/data/local/tmp/appproxy_cap.json` | structured capture (proxy mode) |
|
||||||
|
| `/data/local/tmp/capture/<pkg>/` | transparent out-dir (cap + traffic + log) |
|
||||||
|
| `/data/local/tmp/tproxy_iptables.sh` | UID REDIRECT helper |
|
||||||
|
| `/data/local/tmp/6c3578b4.0` | system CA hash file |
|
||||||
|
| Wi‑Fi `http_proxy` | used only in classic proxy mode — **not** in transparent mode |
|
||||||
|
|
||||||
|
**Full guide: [docs/capture.md](../../docs/capture.md)** — CA troubleshooting and how
|
||||||
|
to mine a discover dump.
|
||||||
BIN
apps/proxy/device/appproxy.exe
Normal file
BIN
apps/proxy/device/appproxy.exe
Normal file
Binary file not shown.
17
apps/proxy/device/build.sh
Normal file
17
apps/proxy/device/build.sh
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
GO="${GO:-$(command -v go || true)}"
|
||||||
|
if [[ -z "$GO" && -x /opt/homebrew/bin/go ]]; then
|
||||||
|
GO=/opt/homebrew/bin/go
|
||||||
|
fi
|
||||||
|
if [[ -z "$GO" ]]; then
|
||||||
|
echo "go not found; brew install go" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "building linux/arm64 with $GO"
|
||||||
|
OUT="${OUT:-../proxy-android-arm64}"
|
||||||
|
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 "$GO" build -ldflags='-s -w' -o "$OUT" .
|
||||||
|
file "$OUT"
|
||||||
|
ls -lh "$OUT"
|
||||||
|
# Prefer: from repo root → bin/proxyctl.exe build
|
||||||
14
apps/proxy/device/go.mod
Normal file
14
apps/proxy/device/go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
module appproxy
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
|
github.com/elazarl/goproxy v1.9.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
|
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
16
apps/proxy/device/go.sum
Normal file
16
apps/proxy/device/go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/elazarl/goproxy v1.9.2 h1:+vXRRSWrznMtBrAb559qfqC+Cny1Q3rR0l51Yu/3WUw=
|
||||||
|
github.com/elazarl/goproxy v1.9.2/go.mod h1:THdE5ix2clxX9lZzcICPpZ67d6CdrPZxdOYsNgU5e30=
|
||||||
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
1310
apps/proxy/device/main.go
Normal file
1310
apps/proxy/device/main.go
Normal file
File diff suppressed because it is too large
Load diff
56
apps/proxy/device/needles.go
Normal file
56
apps/proxy/device/needles.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// CA identity and file names. The legacy names are still honoured when already
|
||||||
|
// present on a device, so an existing phone does not need a CA reinject.
|
||||||
|
const (
|
||||||
|
caCertName = "appproxy-ca.crt"
|
||||||
|
caKeyName = "appproxy-ca.key"
|
||||||
|
legacyCACertName = "rteproxy-ca.crt"
|
||||||
|
legacyCAKeyName = "rteproxy-ca.key"
|
||||||
|
caOrganization = "appproxy MITM CA"
|
||||||
|
caCommonName = "appproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stringList is a repeatable string flag.
|
||||||
|
type stringList []string
|
||||||
|
|
||||||
|
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||||
|
|
||||||
|
func (s *stringList) Set(v string) error {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
*s = append(*s, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// matchNeedles (--match) mark extra provider traffic as interesting.
|
||||||
|
matchNeedles stringList
|
||||||
|
// scoreHosts (--score-host) are this provider's manifest origins.
|
||||||
|
scoreHosts stringList
|
||||||
|
// scoreDeny (--score-deny) are provider URLs that are never a manifest.
|
||||||
|
scoreDeny stringList
|
||||||
|
// mitmHosts (--mitm-host) force MITM even when a passthrough rule matches.
|
||||||
|
mitmHosts stringList
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatDeny are catalog/analytics URL shapes that are never a manifest for any
|
||||||
|
// provider. Provider-specific noise arrives via --score-deny.
|
||||||
|
var formatDeny = []string{
|
||||||
|
"schedules", "bylistingtime", "maxlistings", "bycallsign",
|
||||||
|
"/feed.", "playback_config", "config.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
func denyNeedles() []string {
|
||||||
|
out := make([]string, 0, len(formatDeny)+len(scoreDeny))
|
||||||
|
out = append(out, formatDeny...)
|
||||||
|
for _, d := range scoreDeny {
|
||||||
|
out = append(out, strings.ToLower(d))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
101
apps/proxy/device/passthrough.go
Normal file
101
apps/proxy/device/passthrough.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passthroughAll, when true, splices every host without MITM. Used to prove
|
||||||
|
// transparent redirect + VPN egress work before narrowing MITM targets.
|
||||||
|
var passthroughAll bool
|
||||||
|
|
||||||
|
// passthroughDefault, when true (transparent mode), MITM only forceMITM / open.live.
|
||||||
|
// Avoids breaking connectivity checks on Google/Firebase when Magisk CA is not
|
||||||
|
// in the app mount namespace.
|
||||||
|
var passthroughDefault bool
|
||||||
|
|
||||||
|
// forceMITM hosts (lowercase) always MITM even if a passthrough rule matches.
|
||||||
|
// Use for known license endpoints once identified: -mitm-host license.example.com
|
||||||
|
var forceMITM []string
|
||||||
|
|
||||||
|
// Hosts that must NOT be MITM'd for playback to work (CDN / media / BBC APIs).
|
||||||
|
// Transparent mode defaults to passthrough; carve in with open.live / -mitm-host.
|
||||||
|
func shouldPassthrough(host string) bool {
|
||||||
|
if passthroughAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h := strings.ToLower(stripHostPort(host))
|
||||||
|
if h == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range forceMITM {
|
||||||
|
if h == m || strings.HasSuffix(h, "."+m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No SNI → only have a destination IP; MITM cert/SNI would be wrong.
|
||||||
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// MITM open.live (mediaselector) — stream + Widevine licence URLs live here.
|
||||||
|
// Upstream MUST dial SO_ORIGINAL_DST (VPN fake-IP) or BBC returns geolocation 403.
|
||||||
|
if h == "open.live.bbc.co.uk" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Other BBC APIs/CDNs: MITM breaks play; passthrough.
|
||||||
|
if strings.Contains(h, "bbc.co.uk") || strings.Contains(h, "bbci.co.uk") ||
|
||||||
|
strings.Contains(h, "bbc.com") || strings.Contains(h, "bbci.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
needles := []string{
|
||||||
|
"akamai", "akamaized", "cloudfront.net", "fastly",
|
||||||
|
"edgesuite", "cmaf", "2cnt.net", "springstreams",
|
||||||
|
"fingerprint", "optimizely", "appsflyer", "urbanairship",
|
||||||
|
"googleusercontent", "gvt1.com", "googleapis.com",
|
||||||
|
"firebaselogging", "crashlytics", "app-measurement",
|
||||||
|
}
|
||||||
|
for _, n := range needles {
|
||||||
|
if strings.Contains(h, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if passthroughDefault {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePassthrough(client net.Conn, host string, port int, dial func(network, addr string) (net.Conn, error)) {
|
||||||
|
defer client.Close()
|
||||||
|
target := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
up, err := dial("tcp", target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[PASS] dial %s: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer up.Close()
|
||||||
|
log.Printf("[PASS] %s (no MITM)", target)
|
||||||
|
errc := make(chan error, 2)
|
||||||
|
var once sync.Once
|
||||||
|
closeWrite := func(c net.Conn) {
|
||||||
|
once.Do(func() {})
|
||||||
|
if tc, ok := c.(*net.TCPConn); ok {
|
||||||
|
_ = tc.CloseWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(up, client)
|
||||||
|
errc <- err
|
||||||
|
closeWrite(up)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(client, up)
|
||||||
|
errc <- err
|
||||||
|
closeWrite(client)
|
||||||
|
}()
|
||||||
|
<-errc
|
||||||
|
}
|
||||||
41
apps/proxy/device/push.sh
Normal file
41
apps/proxy/device/push.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Push rteproxy to the phone and start it in the background.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BIN="${1:-rteproxy-android-arm64}"
|
||||||
|
REMOTE="${REMOTE:-/data/local/tmp/rteproxy}"
|
||||||
|
OUT="${OUT:-/data/local/tmp/rte_cap.json}"
|
||||||
|
LISTEN="${LISTEN:-:8080}"
|
||||||
|
DNS="${DNS:-1.1.1.1,1.0.0.1,8.8.8.8}"
|
||||||
|
ADB="${ADB:-adb}"
|
||||||
|
|
||||||
|
if [[ ! -f "$BIN" ]]; then
|
||||||
|
echo "missing $BIN — run ./build.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ADB" get-state >/dev/null
|
||||||
|
"$ADB" push "$BIN" "$REMOTE"
|
||||||
|
"$ADB" shell chmod 755 "$REMOTE"
|
||||||
|
|
||||||
|
# Stop a previous instance (ignore if none)
|
||||||
|
"$ADB" shell "pkill -f /data/local/tmp/rteproxy" >/dev/null 2>&1 || true
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
"$ADB" shell "sh -c '$REMOTE -listen $LISTEN -out $OUT -ca-dir /data/local/tmp -dns $DNS >/data/local/tmp/rteproxy.log 2>&1 &'"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "--- process ---"
|
||||||
|
"$ADB" shell "ps -A | grep rteproxy || true"
|
||||||
|
echo "--- log ---"
|
||||||
|
"$ADB" shell "cat /data/local/tmp/rteproxy.log || true"
|
||||||
|
echo
|
||||||
|
echo "CA cert on device: /data/local/tmp/rteproxy-ca.crt"
|
||||||
|
echo " adb pull /data/local/tmp/rteproxy-ca.crt ."
|
||||||
|
echo " → Settings → Security → Install a certificate → CA certificate"
|
||||||
|
echo
|
||||||
|
echo "Set Wi‑Fi HTTP proxy to 127.0.0.1${LISTEN}"
|
||||||
|
echo "Play the stream, then:"
|
||||||
|
echo " adb pull $OUT /tmp/rte_cap.json"
|
||||||
|
echo " python getrtelive.py"
|
||||||
81
apps/proxy/device/tproxy_iptables.sh
Normal file
81
apps/proxy/device/tproxy_iptables.sh
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/system/bin/sh
|
||||||
|
# Transparent redirect: package UID TCP/443 → local appproxy (no Wi‑Fi http_proxy).
|
||||||
|
# Usage:
|
||||||
|
# tproxy_iptables.sh start <package> [port]
|
||||||
|
# tproxy_iptables.sh stop
|
||||||
|
# tproxy_iptables.sh status
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
CHAIN=APPROXY_TPROXY
|
||||||
|
ACTION="${1:-}"
|
||||||
|
|
||||||
|
uid_for_package() {
|
||||||
|
pkg="$1"
|
||||||
|
# dumpsys package <pkg> | grep userId= OR stat on data dir
|
||||||
|
uid=$(dumpsys package "$pkg" 2>/dev/null | grep -m1 -oE 'userId=[0-9]+' | head -1 | cut -d= -f2 || true)
|
||||||
|
if [ -z "$uid" ]; then
|
||||||
|
uid=$(stat -c %u "/data/user/0/$pkg" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
echo "$uid"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_rules() {
|
||||||
|
iptables -t nat -D OUTPUT -j "$CHAIN" 2>/dev/null || true
|
||||||
|
iptables -t nat -F "$CHAIN" 2>/dev/null || true
|
||||||
|
iptables -t nat -X "$CHAIN" 2>/dev/null || true
|
||||||
|
echo "STOPPED"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_rules() {
|
||||||
|
pkg="$1"
|
||||||
|
port="$2"
|
||||||
|
uid=$(uid_for_package "$pkg")
|
||||||
|
if [ -z "$uid" ] || [ "$uid" = "0" ]; then
|
||||||
|
echo "ERR: cannot resolve uid for package $pkg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
proxy_uid=$(stat -c %u /data/local/tmp/appproxy 2>/dev/null || echo "")
|
||||||
|
# Prefer the running process uid if available
|
||||||
|
if pidof appproxy >/dev/null 2>&1; then
|
||||||
|
proxy_uid=$(stat -c %u /proc/$(pidof appproxy | awk '{print $1}') 2>/dev/null || echo "$proxy_uid")
|
||||||
|
fi
|
||||||
|
|
||||||
|
stop_rules >/dev/null
|
||||||
|
iptables -t nat -N "$CHAIN"
|
||||||
|
# Never redirect the mitm itself (loop).
|
||||||
|
if [ -n "$proxy_uid" ]; then
|
||||||
|
iptables -t nat -A "$CHAIN" -m owner --uid-owner "$proxy_uid" -j RETURN
|
||||||
|
fi
|
||||||
|
# Skip localhost / link-local.
|
||||||
|
iptables -t nat -A "$CHAIN" -d 127.0.0.0/8 -j RETURN
|
||||||
|
iptables -t nat -A "$CHAIN" -d 10.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||||
|
# Redirect only the target app's HTTPS.
|
||||||
|
iptables -t nat -A "$CHAIN" -p tcp -m owner --uid-owner "$uid" --dport 443 -j REDIRECT --to-ports "$port"
|
||||||
|
iptables -t nat -A OUTPUT -j "$CHAIN"
|
||||||
|
echo "STARTED pkg=$pkg uid=$uid port=$port proxy_uid=${proxy_uid:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_rules() {
|
||||||
|
echo "=== $CHAIN ==="
|
||||||
|
iptables -t nat -L "$CHAIN" -n -v 2>/dev/null || echo "(no chain)"
|
||||||
|
echo "=== OUTPUT head ==="
|
||||||
|
iptables -t nat -L OUTPUT -n -v 2>/dev/null | head -20
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
start)
|
||||||
|
pkg="${2:?package required}"
|
||||||
|
port="${3:-8080}"
|
||||||
|
start_rules "$pkg" "$port"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop_rules
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status_rules
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 start <package> [port] | stop | status" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
382
apps/proxy/device/transparent.go
Normal file
382
apps/proxy/device/transparent.go
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialFunc is the DNS-aware upstream dialer (same as MITM transport DialContext).
|
||||||
|
type dialFunc func(network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
|
// serveTransparentAccept accepts iptables-REDIRECTED TCP connections, recovers
|
||||||
|
// the original destination / SNI, then feeds them into the local HTTP MITM via
|
||||||
|
// a synthetic CONNECT so UK-VPN routing stays intact (no Wi‑Fi http_proxy).
|
||||||
|
func serveTransparentAccept(ln net.Listener, mitmAddr string, dial dialFunc) {
|
||||||
|
log.Printf("transparent accept on %s → MITM %s", ln.Addr(), mitmAddr)
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("transparent accept: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handleTransparent(c, mitmAddr, dial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTransparent(client net.Conn, mitmAddr string, dial dialFunc) {
|
||||||
|
defer client.Close()
|
||||||
|
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
|
bc := newBufConn(client)
|
||||||
|
sni, helloErr := peekSNI(bc)
|
||||||
|
dstHost, dstPort, dstErr := originalDst(client)
|
||||||
|
|
||||||
|
host := strings.TrimSpace(sni)
|
||||||
|
port := 443
|
||||||
|
if dstErr == nil && dstPort > 0 {
|
||||||
|
port = dstPort
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
if dstErr != nil {
|
||||||
|
log.Printf("transparent: no SNI (%v) and no original dst (%v)", helloErr, dstErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host = dstHost
|
||||||
|
}
|
||||||
|
target := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
|
||||||
|
// CDN / media / mediaselector: splice without MITM so playback works.
|
||||||
|
if shouldPassthrough(host) {
|
||||||
|
_ = client.SetDeadline(time.Time{})
|
||||||
|
// Re-feed ClientHello: peekSNI left bytes in bc; rebuild a reader.
|
||||||
|
left := bc.buffered()
|
||||||
|
var clientR net.Conn = client
|
||||||
|
if len(left) > 0 {
|
||||||
|
clientR = &prefixConn{Conn: client, prefix: left}
|
||||||
|
}
|
||||||
|
upDial := dial
|
||||||
|
if upDial == nil {
|
||||||
|
upDial = func(network, addr string) (net.Conn, error) {
|
||||||
|
return net.DialTimeout(network, addr, 15*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefer the app's original destination IP (same CDN edge / geo) over re-resolve.
|
||||||
|
passHost := host
|
||||||
|
if dstErr == nil && net.ParseIP(dstHost) != nil && !hostIsLoopback(dstHost) {
|
||||||
|
passHost = dstHost
|
||||||
|
log.Printf("[PASS] %s → %s:%d (orig-dst)", host, dstHost, port)
|
||||||
|
}
|
||||||
|
handlePassthrough(clientR, passHost, port, upDial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[TPROXY] %s → CONNECT %s", client.RemoteAddr(), target)
|
||||||
|
|
||||||
|
mitm, err := net.DialTimeout("tcp", mitmAddr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("transparent dial mitm: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer mitm.Close()
|
||||||
|
|
||||||
|
// Pass SO_ORIGINAL_DST so MITM upstream dials the app's IP (NordVPN fake-IP
|
||||||
|
// / same CDN edge). Re-resolving via public DNS breaks BBC geolocation.
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", target, target)
|
||||||
|
if dstErr == nil && net.ParseIP(dstHost) != nil && !hostIsLoopback(dstHost) {
|
||||||
|
fmt.Fprintf(&b, "X-Appproxy-Orig-Dst: %s\r\n", net.JoinHostPort(dstHost, strconv.Itoa(port)))
|
||||||
|
}
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
if _, err := io.WriteString(mitm, b.String()); err != nil {
|
||||||
|
log.Printf("transparent CONNECT write: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
br := bufio.NewReader(mitm)
|
||||||
|
status, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("transparent CONNECT read: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(status, "200") {
|
||||||
|
rest, _ := io.ReadAll(io.LimitReader(br, 512))
|
||||||
|
log.Printf("transparent CONNECT rejected: %s%s", status, rest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Drain remaining response headers.
|
||||||
|
for {
|
||||||
|
line, err := br.ReadString('\n')
|
||||||
|
if err != nil || line == "\r\n" || line == "\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = client.SetDeadline(time.Time{})
|
||||||
|
_ = mitm.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Any buffered ClientHello bytes must go to the MITM first.
|
||||||
|
var once sync.Once
|
||||||
|
left := bc.buffered()
|
||||||
|
if len(left) > 0 {
|
||||||
|
if _, err := mitm.Write(left); err != nil {
|
||||||
|
log.Printf("transparent hello write: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errc := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(mitm, bc)
|
||||||
|
errc <- err
|
||||||
|
once.Do(func() {
|
||||||
|
if tc, ok := mitm.(*net.TCPConn); ok {
|
||||||
|
_ = tc.CloseWrite()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(client, br)
|
||||||
|
errc <- err
|
||||||
|
if tc, ok := client.(*net.TCPConn); ok {
|
||||||
|
_ = tc.CloseWrite()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-errc
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixConn emits prefix once, then reads from Conn.
|
||||||
|
type prefixConn struct {
|
||||||
|
net.Conn
|
||||||
|
prefix []byte
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prefixConn) Read(b []byte) (int, error) {
|
||||||
|
if p.i < len(p.prefix) {
|
||||||
|
n := copy(b, p.prefix[p.i:])
|
||||||
|
p.i += n
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
return p.Conn.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type bufConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufConn(c net.Conn) *bufConn {
|
||||||
|
return &bufConn{Conn: c, r: bufio.NewReaderSize(c, 4096)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bufConn) Read(p []byte) (int, error) { return b.r.Read(p) }
|
||||||
|
|
||||||
|
func (b *bufConn) buffered() []byte {
|
||||||
|
n := b.r.Buffered()
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
buf, _ := b.r.Peek(n)
|
||||||
|
out := make([]byte, len(buf))
|
||||||
|
copy(out, buf)
|
||||||
|
// Consume so later Read does not duplicate.
|
||||||
|
_, _ = b.r.Discard(n)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekSNI reads a TLS ClientHello (via Peek) and returns the SNI hostname.
|
||||||
|
func peekSNI(bc *bufConn) (string, error) {
|
||||||
|
hdr, err := bc.r.Peek(5)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if hdr[0] != 0x16 { // handshake
|
||||||
|
return "", fmt.Errorf("not TLS handshake (type=%d)", hdr[0])
|
||||||
|
}
|
||||||
|
recLen := int(hdr[3])<<8 | int(hdr[4])
|
||||||
|
need := 5 + recLen
|
||||||
|
if need > 16*1024 {
|
||||||
|
return "", fmt.Errorf("ClientHello too large (%d)", need)
|
||||||
|
}
|
||||||
|
// Wait until full record is buffered.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for bc.r.Buffered() < need && time.Now().Before(deadline) {
|
||||||
|
_ = bc.Conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||||
|
_, _ = bc.r.Peek(need)
|
||||||
|
}
|
||||||
|
_ = bc.Conn.SetReadDeadline(time.Time{})
|
||||||
|
if bc.r.Buffered() < need {
|
||||||
|
need = bc.r.Buffered()
|
||||||
|
}
|
||||||
|
data, err := bc.r.Peek(need)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseClientHelloSNI(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClientHelloSNI(rec []byte) (string, error) {
|
||||||
|
if len(rec) < 5+4 {
|
||||||
|
return "", fmt.Errorf("short record")
|
||||||
|
}
|
||||||
|
if rec[0] != 0x16 || rec[5] != 0x01 { // handshake + client_hello
|
||||||
|
return "", fmt.Errorf("not ClientHello")
|
||||||
|
}
|
||||||
|
// Skip: record hdr(5) + hs type(1) + hs len(3) + client version(2) + random(32)
|
||||||
|
i := 5 + 1 + 3 + 2 + 32
|
||||||
|
if i >= len(rec) {
|
||||||
|
return "", fmt.Errorf("truncated ClientHello")
|
||||||
|
}
|
||||||
|
// session id
|
||||||
|
sidLen := int(rec[i])
|
||||||
|
i += 1 + sidLen
|
||||||
|
if i+2 > len(rec) {
|
||||||
|
return "", fmt.Errorf("truncate cipher suites")
|
||||||
|
}
|
||||||
|
csLen := int(rec[i])<<8 | int(rec[i+1])
|
||||||
|
i += 2 + csLen
|
||||||
|
if i+1 > len(rec) {
|
||||||
|
return "", fmt.Errorf("truncate compression")
|
||||||
|
}
|
||||||
|
compLen := int(rec[i])
|
||||||
|
i += 1 + compLen
|
||||||
|
if i+2 > len(rec) {
|
||||||
|
return "", nil // no extensions
|
||||||
|
}
|
||||||
|
extLen := int(rec[i])<<8 | int(rec[i+1])
|
||||||
|
i += 2
|
||||||
|
end := i + extLen
|
||||||
|
if end > len(rec) {
|
||||||
|
end = len(rec)
|
||||||
|
}
|
||||||
|
for i+4 <= end {
|
||||||
|
typ := int(rec[i])<<8 | int(rec[i+1])
|
||||||
|
l := int(rec[i+2])<<8 | int(rec[i+3])
|
||||||
|
i += 4
|
||||||
|
if i+l > end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if typ == 0 { // server_name
|
||||||
|
return parseSNIExtension(rec[i : i+l])
|
||||||
|
}
|
||||||
|
i += l
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSNIExtension(b []byte) (string, error) {
|
||||||
|
if len(b) < 2 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
listLen := int(b[0])<<8 | int(b[1])
|
||||||
|
i := 2
|
||||||
|
end := 2 + listLen
|
||||||
|
if end > len(b) {
|
||||||
|
end = len(b)
|
||||||
|
}
|
||||||
|
for i+3 <= end {
|
||||||
|
nameType := b[i]
|
||||||
|
nameLen := int(b[i+1])<<8 | int(b[i+2])
|
||||||
|
i += 3
|
||||||
|
if i+nameLen > end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if nameType == 0 {
|
||||||
|
return string(b[i : i+nameLen]), nil
|
||||||
|
}
|
||||||
|
i += nameLen
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func originalDst(conn net.Conn) (host string, port int, err error) {
|
||||||
|
tcp, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
return "", 0, fmt.Errorf("not TCP")
|
||||||
|
}
|
||||||
|
rc, err := tcp.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
ip net.IP
|
||||||
|
prt int
|
||||||
|
err2 error
|
||||||
|
)
|
||||||
|
cerr := rc.Control(func(fd uintptr) {
|
||||||
|
ip, prt, err2 = getOrigDstFD(int(fd))
|
||||||
|
})
|
||||||
|
if cerr != nil {
|
||||||
|
return "", 0, cerr
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
return "", 0, err2
|
||||||
|
}
|
||||||
|
return ip.String(), prt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrigDstFD(fd int) (net.IP, int, error) {
|
||||||
|
// Try IPv6 structure first (works for IPv4-mapped too on many kernels).
|
||||||
|
if ip, port, err := getOrigDstIPv6(fd); err == nil {
|
||||||
|
return ip, port, nil
|
||||||
|
}
|
||||||
|
return getOrigDstIPv4(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrigDstIPv4(fd int) (net.IP, int, error) {
|
||||||
|
const soOriginalDst = 80
|
||||||
|
var addr unix.RawSockaddrInet4
|
||||||
|
sz := uint32(unsafe.Sizeof(addr))
|
||||||
|
_, _, errno := unix.Syscall6(
|
||||||
|
unix.SYS_GETSOCKOPT,
|
||||||
|
uintptr(fd),
|
||||||
|
uintptr(unix.IPPROTO_IP),
|
||||||
|
uintptr(soOriginalDst),
|
||||||
|
uintptr(unsafe.Pointer(&addr)),
|
||||||
|
uintptr(unsafe.Pointer(&sz)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
return nil, 0, errno
|
||||||
|
}
|
||||||
|
ip := net.IPv4(addr.Addr[0], addr.Addr[1], addr.Addr[2], addr.Addr[3])
|
||||||
|
port := int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&addr.Port))[:]))
|
||||||
|
return ip, port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrigDstIPv6(fd int) (net.IP, int, error) {
|
||||||
|
const soOriginalDst = 80
|
||||||
|
var addr unix.RawSockaddrInet6
|
||||||
|
sz := uint32(unsafe.Sizeof(addr))
|
||||||
|
_, _, errno := unix.Syscall6(
|
||||||
|
unix.SYS_GETSOCKOPT,
|
||||||
|
uintptr(fd),
|
||||||
|
uintptr(unix.IPPROTO_IPV6),
|
||||||
|
uintptr(soOriginalDst),
|
||||||
|
uintptr(unsafe.Pointer(&addr)),
|
||||||
|
uintptr(unsafe.Pointer(&sz)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
return nil, 0, errno
|
||||||
|
}
|
||||||
|
ip := make(net.IP, 16)
|
||||||
|
copy(ip, addr.Addr[:])
|
||||||
|
port := int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&addr.Port))[:]))
|
||||||
|
return ip, port, nil
|
||||||
|
}
|
||||||
|
|
||||||
77
apps/proxy/device/utls_dial.go
Normal file
77
apps/proxy/device/utls_dial.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
utls "github.com/refraction-networking/utls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialTLSChrome dials addr with a Chrome-like ClientHello so CDNs (Fastly)
|
||||||
|
// are less likely to HTTP 403 Go's default TLS fingerprint.
|
||||||
|
// Handshakes as HTTP/1.1 only so net/http can use the returned conn.
|
||||||
|
func dialTLSChrome(ctx context.Context, dialCtx func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||||
|
host := serverNameFromAddr(addr)
|
||||||
|
raw, err := dialCtx(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := &utls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
NextProtos: []string{"http/1.1"},
|
||||||
|
}
|
||||||
|
spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120)
|
||||||
|
if err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return dialTLSStd(ctx, dialCtx, network, addr)
|
||||||
|
}
|
||||||
|
for i := range spec.Extensions {
|
||||||
|
if alpn, ok := spec.Extensions[i].(*utls.ALPNExtension); ok {
|
||||||
|
alpn.AlpnProtocols = []string{"http/1.1"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uConn := utls.UClient(raw, cfg, utls.HelloCustom)
|
||||||
|
if err := uConn.ApplyPreset(&spec); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return nil, fmt.Errorf("utls preset %s: %w", host, err)
|
||||||
|
}
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(15 * time.Second)
|
||||||
|
}
|
||||||
|
_ = raw.SetDeadline(deadline)
|
||||||
|
if err := uConn.Handshake(); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return nil, fmt.Errorf("utls handshake %s: %w", host, err)
|
||||||
|
}
|
||||||
|
_ = raw.SetDeadline(time.Time{})
|
||||||
|
return uConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialTLSStd(ctx context.Context, dialCtx func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||||
|
host := serverNameFromAddr(addr)
|
||||||
|
raw, err := dialCtx(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := &tls.Config{ServerName: host, InsecureSkipVerify: true, NextProtos: []string{"http/1.1"}}
|
||||||
|
c := tls.Client(raw, cfg)
|
||||||
|
if err := c.HandshakeContext(ctx); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverNameFromAddr(addr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(addr)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
7
apps/proxy/go.mod
Normal file
7
apps/proxy/go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module drmdecryption/apps/proxy
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require drmdecryption v0.0.0
|
||||||
|
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
468
apps/proxy/proxyctlcmd/proxyctlcmd.go
Normal file
468
apps/proxy/proxyctlcmd/proxyctlcmd.go
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
// proxyctl — host-side CLI for the on-device HTTPS MITM (appproxy).
|
||||||
|
//
|
||||||
|
// Build the android binary, push it, install/reinject the CA, start/stop the
|
||||||
|
// proxy, run discover mode, and pull captures into outputs/discover/<stamp>.
|
||||||
|
// Package proxyctlcmd is the on-device MITM host CLI, exposed as a library so the
|
||||||
|
// single drm binary can host it as a subcommand.
|
||||||
|
package proxyctlcmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/proxy"
|
||||||
|
"drmdecryption/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run dispatches a proxy subcommand. args[0] is the subcommand name.
|
||||||
|
func Run(args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
Usage()
|
||||||
|
return fmt.Errorf("proxy: subcommand required")
|
||||||
|
}
|
||||||
|
sub, rest := args[0], args[1:]
|
||||||
|
switch sub {
|
||||||
|
case "build":
|
||||||
|
buildCmd(rest)
|
||||||
|
case "push":
|
||||||
|
pushCmd(rest)
|
||||||
|
case "install-ca":
|
||||||
|
installCACmd(rest)
|
||||||
|
case "reinject-ca":
|
||||||
|
reinjectCACmd(rest)
|
||||||
|
case "start":
|
||||||
|
startCmd(rest)
|
||||||
|
case "stop":
|
||||||
|
stopCmd(rest)
|
||||||
|
case "discover":
|
||||||
|
discoverCmd(rest)
|
||||||
|
case "pull":
|
||||||
|
pullCmd(rest)
|
||||||
|
case "clear-proxy":
|
||||||
|
clearProxyCmd(rest)
|
||||||
|
case "transparent", "tproxy":
|
||||||
|
transparentCmd(rest)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
Usage()
|
||||||
|
default:
|
||||||
|
Usage()
|
||||||
|
return fmt.Errorf("proxy: unknown subcommand %q", sub)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints the proxy subcommand help.
|
||||||
|
func Usage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `proxyctl — on-device MITM (appproxy) host control
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
proxyctl build cross-compile linux/arm64 → bin/ + apps/proxy/
|
||||||
|
proxyctl push [--serial S] push binary to /data/local/tmp/appproxy
|
||||||
|
proxyctl install-ca [--serial S] [--ca PATH] [--reinject]
|
||||||
|
push CA + HASH.0; optional Magisk reinject
|
||||||
|
proxyctl reinject-ca [--serial S] [--hash HASH]
|
||||||
|
Magisk conscrypt bind only (CA already on device)
|
||||||
|
proxyctl start [--serial S] [--bin PATH] [--install-ca] [--reinject]
|
||||||
|
stop old → push → start → set http_proxy
|
||||||
|
proxyctl stop [--serial S] kill the on-device proxy + clear http_proxy
|
||||||
|
proxyctl discover [--serial S] [--out DIR] [--skip-build] [--install-ca] [--reinject]
|
||||||
|
-log-all session; Ctrl+C → outputs/discover/<stamp>
|
||||||
|
proxyctl pull [--serial S] [--out DIR]
|
||||||
|
pull traffic/cap/log into outputs/discover/<stamp>
|
||||||
|
proxyctl clear-proxy [--serial S] clear global http_proxy (+ stop mitm)
|
||||||
|
proxyctl transparent --package PKG [--serial S] [--out DIR] [--reinject]
|
||||||
|
iptables REDIRECT capture (UK VPN OK; no Wi‑Fi proxy)
|
||||||
|
writes /data/local/tmp/capture/<pkg>/ ; adb-pulled to --out
|
||||||
|
|
||||||
|
Artifacts land under outputs/discover/<timestamp>/ by default.
|
||||||
|
Transparent captures pull into outputs/transparent/<stamp>/ by default.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientFrom(fs *flag.FlagSet, args []string) *adb.Client {
|
||||||
|
serial := fs.String("serial", "", "adb device serial")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
c := adb.New()
|
||||||
|
if *serial != "" {
|
||||||
|
c = c.WithSerial(*serial)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
root := repo.Root()
|
||||||
|
deviceDir := filepath.Join(root, "apps", "proxy", "device")
|
||||||
|
outLocal := filepath.Join(root, "apps", "proxy", "proxy-android-arm64")
|
||||||
|
outBin := filepath.Join(root, "bin", "proxy-android-arm64")
|
||||||
|
|
||||||
|
fmt.Println("[*] Building linux/arm64 appproxy...")
|
||||||
|
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", outLocal, ".")
|
||||||
|
cmd.Dir = deviceDir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GOOS=linux",
|
||||||
|
"GOARCH=arm64",
|
||||||
|
"CGO_ENABLED=0",
|
||||||
|
)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "build failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Join(root, "bin"), 0o755)
|
||||||
|
data, err := os.ReadFile(outLocal)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "read binary: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outBin, data, 0o755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "copy to bin/: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Compat name next to the canonical one.
|
||||||
|
// Legacy copy so hosts that still look for the old name keep working.
|
||||||
|
_ = os.WriteFile(filepath.Join(root, "apps", "proxy", "rteproxy-android-arm64"), data, 0o755)
|
||||||
|
fmt.Println("[+] apps/proxy/proxy-android-arm64")
|
||||||
|
fmt.Println("[+] bin/proxy-android-arm64")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("push", flag.ExitOnError)
|
||||||
|
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
local := *bin
|
||||||
|
if local == "" {
|
||||||
|
local = proxy.FindLocalBin("")
|
||||||
|
}
|
||||||
|
if local == "" {
|
||||||
|
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||||
|
}
|
||||||
|
fmt.Printf("[*] Pushing %s → %s\n", local, proxy.RemoteBin)
|
||||||
|
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||||||
|
fmt.Println("[+] pushed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCACmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("install-ca", flag.ExitOnError)
|
||||||
|
ca := fs.String("ca", "", "local CA PEM (default: the CA pulled from the device)")
|
||||||
|
reinject := fs.Bool("reinject", false, "Magisk-reinject into conscrypt after push")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := proxy.InstallCA(c, *ca, *reinject)
|
||||||
|
if err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] CA hash %s installed on device\n", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reinjectCACmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("reinject-ca", flag.ExitOnError)
|
||||||
|
hash := fs.String("hash", proxy.DefaultCAHash, "Android CA subject_hash_old")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
proxy.ReinjectCA(c, *hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("start", flag.ExitOnError)
|
||||||
|
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||||
|
installCA := fs.Bool("install-ca", false, "push CA + HASH.0 before start")
|
||||||
|
reinject := fs.Bool("reinject", false, "Magisk-reinject CA (implies -install-ca)")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
if *reinject {
|
||||||
|
*installCA = true
|
||||||
|
}
|
||||||
|
if *installCA {
|
||||||
|
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local := *bin
|
||||||
|
if local == "" {
|
||||||
|
local = proxy.FindLocalBin("")
|
||||||
|
}
|
||||||
|
if local == "" {
|
||||||
|
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||||
|
}
|
||||||
|
if err := proxy.PushAndStart(c, local); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("stop", flag.ExitOnError)
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
proxy.Stop(c)
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearProxyCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("clear-proxy", flag.ExitOnError)
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transparentCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("transparent", flag.ExitOnError)
|
||||||
|
pkg := fs.String("package", "", "app package to redirect (e.g. bbc.iplayer.android)")
|
||||||
|
out := fs.String("out", "", "host pull dir (default: outputs/transparent/<stamp>)")
|
||||||
|
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||||||
|
reinject := fs.Bool("reinject", false, "Magisk-reinject CA before start (slow; CA usually already mounted)")
|
||||||
|
port := fs.String("port", "8080", "transparent listen port on device")
|
||||||
|
noTail := fs.Bool("no-tail", false, "start only; do not wait / pull on Ctrl+C")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*pkg) == "" {
|
||||||
|
fatal(fmt.Errorf("--package is required (e.g. --package bbc.iplayer.android)"))
|
||||||
|
}
|
||||||
|
local := *bin
|
||||||
|
if local == "" {
|
||||||
|
local = proxy.FindLocalBin("")
|
||||||
|
}
|
||||||
|
if local == "" {
|
||||||
|
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never leave a stale Wi‑Fi proxy when using transparent mode.
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
|
||||||
|
remoteDir := "/data/local/tmp/capture/" + *pkg
|
||||||
|
fmt.Printf("[*] Transparent capture for %s → %s\n", *pkg, remoteDir)
|
||||||
|
if err := proxy.StartTransparent(c, local, *pkg, *port, remoteDir, *reinject); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("[+] running. Leave UK VPN ON. Open the app and play.")
|
||||||
|
fmt.Println(" Ctrl+C → stop iptables + pull captures")
|
||||||
|
if *noTail {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := *out
|
||||||
|
if dest == "" {
|
||||||
|
dest = filepath.Join(repo.Root(), "outputs", "transparent", time.Now().Format("20060102-150405"))
|
||||||
|
}
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt)
|
||||||
|
<-sig
|
||||||
|
fmt.Println("\n[*] Stopping transparent capture...")
|
||||||
|
proxy.StopTransparent(c)
|
||||||
|
_ = os.MkdirAll(dest, 0o755)
|
||||||
|
if err := proxy.PullDir(c, remoteDir, dest); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "pull: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[+] pulled into", dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("pull", flag.ExitOnError)
|
||||||
|
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
dest := *out
|
||||||
|
if dest == "" {
|
||||||
|
dest = proxy.DiscoverOutDir("", "")
|
||||||
|
}
|
||||||
|
if err := proxy.PullCaptures(c, dest); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("[+] pulled into", dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("discover", flag.ExitOnError)
|
||||||
|
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||||||
|
skipBuild := fs.Bool("skip-build", false, "do not rebuild the android binary")
|
||||||
|
installCA := fs.Bool("install-ca", true, "push CA + HASH.0 before discover")
|
||||||
|
reinject := fs.Bool("reinject", true, "Magisk-reinject CA (default on for discover)")
|
||||||
|
noTail := fs.Bool("no-tail", false, "start only; do not tail / wait for Ctrl+C")
|
||||||
|
listen := fs.String("listen", ":8080", "proxy listen address on device")
|
||||||
|
pkgForce := fs.String("force-stop", "", "package to force-stop after CA reinject (so it inherits the new mount)")
|
||||||
|
c := clientFrom(fs, args)
|
||||||
|
|
||||||
|
root := repo.Root()
|
||||||
|
dest := *out
|
||||||
|
if dest == "" {
|
||||||
|
dest = proxy.DiscoverOutDir(root, "")
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(dest, 0o755)
|
||||||
|
|
||||||
|
if !*skipBuild {
|
||||||
|
buildCmd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.EnsureDevice(); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("[*] Device:", c.Out("get-serialno"))
|
||||||
|
|
||||||
|
local := proxy.FindLocalBin(root)
|
||||||
|
if local == "" {
|
||||||
|
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *installCA || *reinject {
|
||||||
|
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] install-ca: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *pkgForce != "" {
|
||||||
|
c.ForceStop(*pkgForce)
|
||||||
|
fmt.Printf("[*] Force-stopped %s\n", *pkgForce)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.Stop(c)
|
||||||
|
fmt.Printf("[*] Pushing %s → %s (discover / -log-all)\n", local, proxy.RemoteBin)
|
||||||
|
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||||||
|
_, _ = c.Shell("rm", "-f", proxy.RemoteLog, proxy.RemoteCap, proxy.RemoteTraffic)
|
||||||
|
|
||||||
|
starter := "#!/system/bin/sh\n" +
|
||||||
|
"exec " + proxy.RemoteBin +
|
||||||
|
" -listen " + *listen +
|
||||||
|
" -out " + proxy.RemoteCap +
|
||||||
|
" -ca-dir /data/local/tmp" +
|
||||||
|
" -dns 1.1.1.1,1.0.0.1,8.8.8.8,192.168.1.1" +
|
||||||
|
" -log-all -traffic " + proxy.RemoteTraffic +
|
||||||
|
" -v >>" + proxy.RemoteLog + " 2>&1\n"
|
||||||
|
tmp := filepath.Join(os.TempDir(), "start_appproxy_discover.sh")
|
||||||
|
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
if err := c.Push(tmp, "/data/local/tmp/start_appproxy.sh"); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy.sh")
|
||||||
|
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy.sh </dev/null >/dev/null 2>&1 &")
|
||||||
|
|
||||||
|
ok := false
|
||||||
|
var pid, head string
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
pid = c.Out("shell", "pidof", "appproxy")
|
||||||
|
if pid == "" {
|
||||||
|
// Legacy process name.
|
||||||
|
pid = c.Out("shell", "pidof", "rteproxy")
|
||||||
|
}
|
||||||
|
head = c.Out("shell", "head", "-20", proxy.RemoteLog)
|
||||||
|
if pid != "" && containsListening(head) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if head != "" {
|
||||||
|
fmt.Println(head)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintln(os.Stderr, c.Out("shell", "cat", proxy.RemoteLog))
|
||||||
|
fatal(fmt.Errorf("appproxy failed to start"))
|
||||||
|
}
|
||||||
|
fmt.Printf("[+] appproxy pid=%s\n", pid)
|
||||||
|
|
||||||
|
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=== Discover mode ready ===")
|
||||||
|
fmt.Println("1. Unlock the phone and open the target app.")
|
||||||
|
fmt.Println("2. Start playback so DRM + manifest traffic flows.")
|
||||||
|
fmt.Println("3. Ctrl+C stops the tail and pulls captures.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Local folder:", dest)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if *noTail {
|
||||||
|
fmt.Println("Started without tail (-no-tail). Pull later with: proxyctl pull")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
tailDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(tailDone)
|
||||||
|
cmd := exec.Command(c.Bin, append(serialArgs(c), "shell", "tail", "-f", proxy.RemoteLog)...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
_ = cmd.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sig:
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("[*] Stopping tail...")
|
||||||
|
case <-tailDone:
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[*] Pulling captures into", dest)
|
||||||
|
_ = proxy.PullCaptures(c, dest)
|
||||||
|
proxy.ClearHTTPProxy(c)
|
||||||
|
fmt.Println("[+] Done. Inspect:")
|
||||||
|
entries, _ := os.ReadDir(dest)
|
||||||
|
for _, e := range entries {
|
||||||
|
info, _ := e.Info()
|
||||||
|
size := int64(0)
|
||||||
|
if info != nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s (%d bytes)\n", e.Name(), size)
|
||||||
|
}
|
||||||
|
tip := filepath.Join(dest, "appproxy_traffic.jsonl")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
fmt.Printf("Tip: Select-String -Path '%s' -Pattern 'mpd|license|widevine|manifest'\n", tip)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Tip: grep -E 'mpd|license|widevine|manifest' %s\n", tip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serialArgs(c *adb.Client) []string {
|
||||||
|
if c.Serial == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"-s", c.Serial}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsListening(s string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), "listening")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(err error) {
|
||||||
|
fmt.Fprintf(os.Stderr, "proxyctl: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
20
apps/proxy/rteproxy-ca.crt
Normal file
20
apps/proxy/rteproxy-ca.crt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDSjCCAjKgAwIBAgIQYdaH1QnfE8PmjHy1H12hXDANBgkqhkiG9w0BAQsFADAu
|
||||||
|
MRkwFwYDVQQKExBydGVwcm94eSBNSVRNIENBMREwDwYDVQQDEwhydGVwcm94eTAe
|
||||||
|
Fw0yNjEwMDExNTU1MDhaFw0zNjA5MjgxNjU1MDhaMC4xGTAXBgNVBAoTEHJ0ZXBy
|
||||||
|
b3h5IE1JVE0gQ0ExETAPBgNVBAMTCHJ0ZXByb3h5MIIBIjANBgkqhkiG9w0BAQEF
|
||||||
|
AAOCAQ8AMIIBCgKCAQEA2WS523uzgge/trRY6YXvtSa8JfTizzdKF0OLps8Dhnq8
|
||||||
|
9mQMVuCTdgiQyxRk75bJ6k0e/3NePEE8V2/GAeYtFtiqAC5p82d42Bt6wuXADgsH
|
||||||
|
+tzBuuIr04w5KkUlWV6sI7BVBNE4lD8He56xkfRHlZMYb2anbAC51AQEmTR0Mu3u
|
||||||
|
ep37UUo0xcrDI+oBh+mLWF5uVgt2XnxmIQE2r95dwKqomWVgzGsc1EkZoPQyhcJX
|
||||||
|
xE0f71Dtb2zM7GcWaHzDjVZTasqtsY6ISy2v6m063GO+QDpW3GGWqlPOxTyawWox
|
||||||
|
j4NS4DyBFnpRtFTTMJKq731dQHh5nyVqD1hBtLyViQIDAQABo2QwYjAOBgNVHQ8B
|
||||||
|
Af8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB
|
||||||
|
/wQIMAYBAf8CAQEwHQYDVR0OBBYEFPqzqtGYBgxFzTT4O6roQjvof7xnMA0GCSqG
|
||||||
|
SIb3DQEBCwUAA4IBAQCuoLG8GRdCIc4HeM6MyEMQU+P2PiMi6K1jJOZo4ArT4bAB
|
||||||
|
5OlOphvduzRHMsszl+V+XliwfGkK20L50ykcmj9KR3TseMMelJWOqHKzV7H5yyKj
|
||||||
|
Hsz2jXGHKQIMs4p0thSxsbwcewIoIjwoiFFs5Ji0l3U9kc1CzEtuixgxGovJHTN9
|
||||||
|
W9LoU4mjFHUWC08+n7jtAazXvzhOOII37P4y2v6AFhVC0yiNUu407p3AjRP2Eyeq
|
||||||
|
p8YOTnncsnpdpjv11s4mZRbF4Jpp6jvNhePgdNqMZAcov+c+L1KIWui0VBzF4XQI
|
||||||
|
AaENO1ApSPIXlM19FCVNPNufMEPAfbIq/eMZyg7J
|
||||||
|
-----END CERTIFICATE-----
|
||||||
65
apps/streamd/README.md
Normal file
65
apps/streamd/README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# streamd
|
||||||
|
|
||||||
|
Control plane: REST API, SQLite, dashboard and media supervisor. Hosted by the
|
||||||
|
single binary as `drm serve`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/streamd/
|
||||||
|
servecmd/ the command body, imported by apps/cli
|
||||||
|
internal/api HTTP handlers
|
||||||
|
internal/db SQLite store
|
||||||
|
internal/ui dashboard (index.html, app.js, app.css)
|
||||||
|
internal/supervisor downloader + ffmpeg supervision
|
||||||
|
internal/ws agent heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
Port 8083 by default, to stay clear of anything already on 8080/8081. Media tools
|
||||||
|
resolve from flags, then env (`NRE_PATH` / `FFMPEG_PATH` / `MP4DECRYPT_PATH`), then
|
||||||
|
`bin/`, then `PATH` — no machine-specific paths are baked in.
|
||||||
|
|
||||||
|
Open `http://HOST:8083/` and put the token in the header box for mutating actions.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/health` | liveness + uptime |
|
||||||
|
| GET | `/api/apps` | compiled-in app modules, channels, registered rewriters |
|
||||||
|
| GET | `/api/streams` | list + runtime (the agent polls this) |
|
||||||
|
| POST | `/api/streams` | create |
|
||||||
|
| GET/PATCH/DELETE | `/api/streams/:id` | detail / edit / delete |
|
||||||
|
| POST | `/api/streams/:id/start\|stop\|restart` | desired state |
|
||||||
|
| POST | `/api/streams/:id/credentials` | `{mpd,key,pssh?,auth?,pid?}`, restarts if enabled |
|
||||||
|
| POST | `/api/streams/:id/claim` | agent lease (`agent_id`, TTL ~3m) |
|
||||||
|
| POST | `/api/streams/:id/claim/release` | release lease |
|
||||||
|
|
||||||
|
Mutating routes need `Authorization: Bearer TOKEN` (or `X-Streamd-Token`, or
|
||||||
|
`?token=`). HLS output is served from `--data/www` at `/hls/<name>/index.m3u8`.
|
||||||
|
|
||||||
|
## No provider knowledge
|
||||||
|
|
||||||
|
The schema has no provider defaults, and the dashboard's app/channel pickers come
|
||||||
|
from `/api/apps`. Per-stream downloader settings resolve at start time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stored row -> the stream's app module (app.StreamDefaults) -> neutral defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest rewriter is a registry lookup on the stream's `rewriter` column, and
|
||||||
|
HLS is detected from the manifest shape — not from an app or stream name.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Area | State |
|
||||||
|
|---|---|
|
||||||
|
| serve + SQLite + CRUD UI + claims + credentials | done |
|
||||||
|
| `/api/apps` + module-driven defaults | done |
|
||||||
|
| Real downloader/ffmpeg workers + playlist health | partial — least exercised path |
|
||||||
|
|
||||||
|
**Full guide: [docs/streamd.md](../../docs/streamd.md)**
|
||||||
23
apps/streamd/go.mod
Normal file
23
apps/streamd/go.mod
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
module drmdecryption/apps/streamd
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
drmdecryption v0.0.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
replace drmdecryption => ../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
45
apps/streamd/go.sum
Normal file
45
apps/streamd/go.sum
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
326
apps/streamd/internal/api/api.go
Normal file
326
apps/streamd/internal/api/api.go
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/apps/streamd/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Supervisor interface {
|
||||||
|
Start(id int64) error
|
||||||
|
Stop(id int64) error
|
||||||
|
Restart(id int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
Store *db.Store
|
||||||
|
Token string
|
||||||
|
Supervisor Supervisor
|
||||||
|
StartedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Mount(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/health", h.health)
|
||||||
|
mux.HandleFunc("/api/streams", h.streams)
|
||||||
|
mux.HandleFunc("/api/streams/", h.streamAction)
|
||||||
|
mux.HandleFunc("/api/apps", h.apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) auth(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if h.Token == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if tok == "" {
|
||||||
|
tok = r.Header.Get("X-Streamd-Token")
|
||||||
|
}
|
||||||
|
if tok == "" {
|
||||||
|
tok = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
if tok != h.Token {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"service": "streamd",
|
||||||
|
"uptime_s": time.Since(h.StartedAt).Seconds(),
|
||||||
|
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) streams(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
list, err := h.Store.ListStreams()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []db.Stream{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"streams": list})
|
||||||
|
case http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in db.CreateStream
|
||||||
|
if err := readJSON(r, &in); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.CreateStream(in)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, st)
|
||||||
|
default:
|
||||||
|
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) streamAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/streams/")
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
h.streams(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
action = parts[1]
|
||||||
|
}
|
||||||
|
sub := ""
|
||||||
|
if len(parts) > 2 {
|
||||||
|
sub = parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case action == "" && r.Method == http.MethodGet:
|
||||||
|
st, err := h.Store.GetStream(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "" && (r.Method == http.MethodPatch || r.Method == http.MethodPut):
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in db.PatchStream
|
||||||
|
if err := readJSON(r, &in); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before, _ := h.Store.GetStream(id)
|
||||||
|
st, err := h.Store.PatchStream(id, in)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Editing mpd/key/headers must tear down the live media worker and
|
||||||
|
// bring up a fresh instance with the new credentials.
|
||||||
|
if h.Supervisor != nil && credentialsChanged(before, st, in) {
|
||||||
|
if st.Enabled {
|
||||||
|
_ = h.Supervisor.Restart(id)
|
||||||
|
} else {
|
||||||
|
_ = h.Supervisor.Stop(id)
|
||||||
|
}
|
||||||
|
st, _ = h.Store.GetStream(id)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "" && r.Method == http.MethodDelete:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Supervisor != nil {
|
||||||
|
_ = h.Supervisor.Stop(id)
|
||||||
|
}
|
||||||
|
if err := h.Store.DeleteStream(id); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
|
||||||
|
case action == "start" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.SetEnabled(id, true)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Supervisor != nil {
|
||||||
|
if err := h.Supervisor.Start(id); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, _ = h.Store.GetStream(id)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "stop" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Supervisor != nil {
|
||||||
|
_ = h.Supervisor.Stop(id)
|
||||||
|
}
|
||||||
|
st, err := h.Store.SetEnabled(id, false)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "restart" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.SetEnabled(id, true)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Supervisor != nil {
|
||||||
|
if err := h.Supervisor.Restart(id); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, _ = h.Store.GetStream(id)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "credentials" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cred db.Credentials
|
||||||
|
if err := readJSON(r, &cred); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.SetCredentials(id, cred)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Store.ReleaseClaim(id, "")
|
||||||
|
// Always kill the old media worker; start a fresh one when enabled.
|
||||||
|
if h.Supervisor != nil {
|
||||||
|
if st.Enabled {
|
||||||
|
_ = h.Supervisor.Restart(id)
|
||||||
|
} else {
|
||||||
|
_ = h.Supervisor.Stop(id)
|
||||||
|
}
|
||||||
|
st, _ = h.Store.GetStream(id)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "claim" && sub == "" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
TTLSec int `json:"ttl_sec"`
|
||||||
|
}
|
||||||
|
_ = readJSON(r, &body)
|
||||||
|
ttl := time.Duration(body.TTLSec) * time.Second
|
||||||
|
st, err := h.Store.Claim(id, body.AgentID, ttl)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "claimed" {
|
||||||
|
writeErr(w, http.StatusConflict, "already claimed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
|
||||||
|
case action == "claim" && sub == "release" && r.Method == http.MethodPost:
|
||||||
|
if !h.auth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
}
|
||||||
|
_ = readJSON(r, &body)
|
||||||
|
if err := h.Store.ReleaseClaim(id, body.AgentID); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeErr(w, http.StatusNotFound, "not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(r *http.Request, dst any) error {
|
||||||
|
defer r.Body.Close()
|
||||||
|
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return errors.New("empty body")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]any{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialsChanged is true when the patch touched mpd, key, or headers that
|
||||||
|
// the live NRE/ffmpeg worker is already using.
|
||||||
|
func credentialsChanged(before, after db.Stream, in db.PatchStream) bool {
|
||||||
|
if in.MPD != nil && strings.TrimSpace(before.MPD) != strings.TrimSpace(after.MPD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if in.Key != nil && strings.TrimSpace(before.Key) != strings.TrimSpace(after.Key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if in.HeadersJSON != nil && strings.TrimSpace(before.HeadersJSON) != strings.TrimSpace(after.HeadersJSON) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
47
apps/streamd/internal/api/apps.go
Normal file
47
apps/streamd/internal/api/apps.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appInfo describes one compiled-in app module to the dashboard, so the UI never
|
||||||
|
// has to hardcode a provider or channel name.
|
||||||
|
type appInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Channels []channelInfo `json:"channels"`
|
||||||
|
Rewriter string `json:"rewriter"`
|
||||||
|
HasConfig bool `json:"has_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apps answers GET /api/apps: which providers this binary was built with.
|
||||||
|
func (h *Handler) apps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := []appInfo{}
|
||||||
|
for _, a := range app.All() {
|
||||||
|
info := appInfo{Name: a.Name(), Channels: []channelInfo{}, Rewriter: "none"}
|
||||||
|
for _, ch := range a.Channels() {
|
||||||
|
info.Channels = append(info.Channels, channelInfo{ID: ch.ID, Label: ch.Label})
|
||||||
|
}
|
||||||
|
// A module with no local values file has no channels to offer yet.
|
||||||
|
info.HasConfig = len(info.Channels) > 0
|
||||||
|
if sd, ok := a.(app.StreamDefaults); ok {
|
||||||
|
info.Rewriter = sd.RewriterName()
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"apps": out,
|
||||||
|
"rewriters": mpd.Names(),
|
||||||
|
})
|
||||||
|
}
|
||||||
504
apps/streamd/internal/db/db.go
Normal file
504
apps/streamd/internal/db/db.go
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
HeadersJSON string `json:"headers_json"`
|
||||||
|
VideoSelect string `json:"video_select"`
|
||||||
|
AudioSelect string `json:"audio_select"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rewriter string `json:"rewriter"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
PID int64 `json:"pid,omitempty"`
|
||||||
|
PlayPath string `json:"play_path,omitempty"`
|
||||||
|
UptimeS float64 `json:"uptime_s,omitempty"`
|
||||||
|
BitrateMbps float64 `json:"bitrate_mbps,omitempty"`
|
||||||
|
PlaylistAgeS float64 `json:"playlist_age_s,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
RuntimeAt string `json:"runtime_updated_at,omitempty"`
|
||||||
|
|
||||||
|
ClaimedBy string `json:"claimed_by,omitempty"`
|
||||||
|
ClaimExp string `json:"claim_expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStream struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
HeadersJSON string `json:"headers_json"`
|
||||||
|
VideoSelect string `json:"video_select"`
|
||||||
|
AudioSelect string `json:"audio_select"`
|
||||||
|
Rewriter string `json:"rewriter"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatchStream struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
App *string `json:"app"`
|
||||||
|
Channel *string `json:"channel"`
|
||||||
|
MPD *string `json:"mpd"`
|
||||||
|
Key *string `json:"key"`
|
||||||
|
HeadersJSON *string `json:"headers_json"`
|
||||||
|
VideoSelect *string `json:"video_select"`
|
||||||
|
AudioSelect *string `json:"audio_select"`
|
||||||
|
Rewriter *string `json:"rewriter"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
MPD string `json:"mpd"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
PSSH string `json:"pssh,omitempty"`
|
||||||
|
Auth string `json:"auth,omitempty"`
|
||||||
|
PID string `json:"pid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dataDir string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, "streamd.db")
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", filepath.ToSlash(path))
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
s := &Store{DB: sqlDB}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.DB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.DB.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
app TEXT NOT NULL DEFAULT '',
|
||||||
|
channel TEXT NOT NULL DEFAULT '',
|
||||||
|
mpd TEXT NOT NULL DEFAULT '',
|
||||||
|
key_kid_key TEXT NOT NULL DEFAULT '',
|
||||||
|
headers_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
video_select TEXT NOT NULL DEFAULT '',
|
||||||
|
audio_select TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rewriter TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stream_runtime (
|
||||||
|
stream_id INTEGER PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
|
||||||
|
health TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
pid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
play_path TEXT NOT NULL DEFAULT '',
|
||||||
|
uptime_s REAL NOT NULL DEFAULT 0,
|
||||||
|
bitrate_mbps REAL NOT NULL DEFAULT 0,
|
||||||
|
playlist_age_s REAL NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_claims (
|
||||||
|
stream_id INTEGER PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
claimed_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func slug(name string) string {
|
||||||
|
name = strings.TrimSpace(strings.ToLower(name))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-' || r == '_' || r == ' ':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
for strings.Contains(out, "--") {
|
||||||
|
out = strings.ReplaceAll(out, "--", "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListStreams() ([]Stream, error) {
|
||||||
|
rows, err := s.DB.Query(`
|
||||||
|
SELECT s.id, s.name, s.title, s.app, s.channel, s.mpd, s.key_kid_key, s.headers_json,
|
||||||
|
s.video_select, s.audio_select, s.enabled, s.rewriter, s.created_at, s.updated_at,
|
||||||
|
COALESCE(r.health,''), COALESCE(r.pid,0), COALESCE(r.play_path,''),
|
||||||
|
COALESCE(r.uptime_s,0), COALESCE(r.bitrate_mbps,0), COALESCE(r.playlist_age_s,0),
|
||||||
|
COALESCE(r.last_error,''), COALESCE(r.updated_at,''),
|
||||||
|
COALESCE(c.agent_id,''), COALESCE(c.expires_at,'')
|
||||||
|
FROM streams s
|
||||||
|
LEFT JOIN stream_runtime r ON r.stream_id = s.id
|
||||||
|
LEFT JOIN agent_claims c ON c.stream_id = s.id
|
||||||
|
ORDER BY s.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Stream
|
||||||
|
for rows.Next() {
|
||||||
|
st, err := scanStream(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetStream(id int64) (Stream, error) {
|
||||||
|
row := s.DB.QueryRow(`
|
||||||
|
SELECT s.id, s.name, s.title, s.app, s.channel, s.mpd, s.key_kid_key, s.headers_json,
|
||||||
|
s.video_select, s.audio_select, s.enabled, s.rewriter, s.created_at, s.updated_at,
|
||||||
|
COALESCE(r.health,''), COALESCE(r.pid,0), COALESCE(r.play_path,''),
|
||||||
|
COALESCE(r.uptime_s,0), COALESCE(r.bitrate_mbps,0), COALESCE(r.playlist_age_s,0),
|
||||||
|
COALESCE(r.last_error,''), COALESCE(r.updated_at,''),
|
||||||
|
COALESCE(c.agent_id,''), COALESCE(c.expires_at,'')
|
||||||
|
FROM streams s
|
||||||
|
LEFT JOIN stream_runtime r ON r.stream_id = s.id
|
||||||
|
LEFT JOIN agent_claims c ON c.stream_id = s.id
|
||||||
|
WHERE s.id = ?`, id)
|
||||||
|
return scanStream(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanStream(row rowScanner) (Stream, error) {
|
||||||
|
var st Stream
|
||||||
|
var enabled int
|
||||||
|
err := row.Scan(
|
||||||
|
&st.ID, &st.Name, &st.Title, &st.App, &st.Channel, &st.MPD, &st.Key, &st.HeadersJSON,
|
||||||
|
&st.VideoSelect, &st.AudioSelect, &enabled, &st.Rewriter, &st.CreatedAt, &st.UpdatedAt,
|
||||||
|
&st.Health, &st.PID, &st.PlayPath, &st.UptimeS, &st.BitrateMbps, &st.PlaylistAgeS,
|
||||||
|
&st.LastError, &st.RuntimeAt, &st.ClaimedBy, &st.ClaimExp,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
st.Enabled = enabled != 0
|
||||||
|
if st.Health == "" {
|
||||||
|
st.Health = "stopped"
|
||||||
|
}
|
||||||
|
if st.PlayPath == "" {
|
||||||
|
st.PlayPath = "/hls/" + st.Name + "/index.m3u8"
|
||||||
|
}
|
||||||
|
// Hide expired claims in API responses (ExpireClaims cleans them shortly after).
|
||||||
|
if st.ClaimExp != "" {
|
||||||
|
if exp, e := time.Parse(time.RFC3339, st.ClaimExp); e == nil && !exp.After(time.Now().UTC()) {
|
||||||
|
st.ClaimedBy = ""
|
||||||
|
st.ClaimExp = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateStream(in CreateStream) (Stream, error) {
|
||||||
|
name := slug(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
return Stream{}, fmt.Errorf("name required")
|
||||||
|
}
|
||||||
|
if in.HeadersJSON == "" {
|
||||||
|
in.HeadersJSON = "{}"
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(in.HeadersJSON)) {
|
||||||
|
return Stream{}, fmt.Errorf("headers_json must be valid JSON")
|
||||||
|
}
|
||||||
|
// video_select / audio_select / rewriter are left empty on purpose: the
|
||||||
|
// supervisor resolves them from the stream's app module at start time, so no
|
||||||
|
// provider's preferences are baked into the schema.
|
||||||
|
if in.Title == "" {
|
||||||
|
in.Title = name
|
||||||
|
}
|
||||||
|
enabled := 0
|
||||||
|
if in.Enabled != nil && *in.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
ts := now()
|
||||||
|
res, err := s.DB.Exec(`
|
||||||
|
INSERT INTO streams(name,title,app,channel,mpd,key_kid_key,headers_json,video_select,audio_select,enabled,rewriter,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
name, in.Title, in.App, in.Channel, in.MPD, in.Key, in.HeadersJSON,
|
||||||
|
in.VideoSelect, in.AudioSelect, enabled, in.Rewriter, ts, ts)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
_, _ = s.DB.Exec(`INSERT OR IGNORE INTO stream_runtime(stream_id,health,play_path,updated_at) VALUES(?,?,?,?)`,
|
||||||
|
id, "stopped", "/hls/"+name+"/index.m3u8", ts)
|
||||||
|
return s.GetStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PatchStream(id int64, in PatchStream) (Stream, error) {
|
||||||
|
cur, err := s.GetStream(id)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
if in.Title != nil {
|
||||||
|
cur.Title = *in.Title
|
||||||
|
}
|
||||||
|
if in.App != nil {
|
||||||
|
cur.App = *in.App
|
||||||
|
}
|
||||||
|
if in.Channel != nil {
|
||||||
|
cur.Channel = *in.Channel
|
||||||
|
}
|
||||||
|
if in.MPD != nil {
|
||||||
|
cur.MPD = *in.MPD
|
||||||
|
}
|
||||||
|
if in.Key != nil {
|
||||||
|
cur.Key = *in.Key
|
||||||
|
}
|
||||||
|
if in.HeadersJSON != nil {
|
||||||
|
if !json.Valid([]byte(*in.HeadersJSON)) {
|
||||||
|
return Stream{}, fmt.Errorf("headers_json must be valid JSON")
|
||||||
|
}
|
||||||
|
cur.HeadersJSON = *in.HeadersJSON
|
||||||
|
}
|
||||||
|
if in.VideoSelect != nil {
|
||||||
|
cur.VideoSelect = *in.VideoSelect
|
||||||
|
}
|
||||||
|
if in.AudioSelect != nil {
|
||||||
|
cur.AudioSelect = *in.AudioSelect
|
||||||
|
}
|
||||||
|
if in.Rewriter != nil {
|
||||||
|
cur.Rewriter = *in.Rewriter
|
||||||
|
}
|
||||||
|
enabled := 0
|
||||||
|
if cur.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
if in.Enabled != nil {
|
||||||
|
if *in.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
} else {
|
||||||
|
enabled = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts := now()
|
||||||
|
_, err = s.DB.Exec(`
|
||||||
|
UPDATE streams SET title=?, app=?, channel=?, mpd=?, key_kid_key=?, headers_json=?,
|
||||||
|
video_select=?, audio_select=?, enabled=?, rewriter=?, updated_at=? WHERE id=?`,
|
||||||
|
cur.Title, cur.App, cur.Channel, cur.MPD, cur.Key, cur.HeadersJSON,
|
||||||
|
cur.VideoSelect, cur.AudioSelect, enabled, cur.Rewriter, ts, id)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
return s.GetStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteStream(id int64) error {
|
||||||
|
_, err := s.DB.Exec(`DELETE FROM streams WHERE id=?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetEnabled(id int64, enabled bool) (Stream, error) {
|
||||||
|
v := 0
|
||||||
|
if enabled {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
_, err := s.DB.Exec(`UPDATE streams SET enabled=?, updated_at=? WHERE id=?`, v, now(), id)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
health := "stopped"
|
||||||
|
if enabled {
|
||||||
|
health = "starting"
|
||||||
|
}
|
||||||
|
_, _ = s.DB.Exec(`
|
||||||
|
INSERT INTO stream_runtime(stream_id,health,play_path,updated_at)
|
||||||
|
VALUES(?,?, (SELECT '/hls/'||name||'/index.m3u8' FROM streams WHERE id=?), ?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET health=excluded.health, play_path=excluded.play_path, updated_at=excluded.updated_at`,
|
||||||
|
id, health, id, now())
|
||||||
|
return s.GetStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetCredentials(id int64, cred Credentials) (Stream, error) {
|
||||||
|
if strings.TrimSpace(cred.MPD) == "" || strings.TrimSpace(cred.Key) == "" {
|
||||||
|
return Stream{}, fmt.Errorf("mpd and key required")
|
||||||
|
}
|
||||||
|
_, err := s.DB.Exec(`UPDATE streams SET mpd=?, key_kid_key=?, updated_at=? WHERE id=?`,
|
||||||
|
strings.TrimSpace(cred.MPD), strings.TrimSpace(cred.Key), now(), id)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
return s.GetStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Claim(id int64, agentID string, ttl time.Duration) (Stream, error) {
|
||||||
|
if agentID == "" {
|
||||||
|
agentID = "agent"
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 90 * time.Second
|
||||||
|
}
|
||||||
|
// Drop expired rows first so a dead agent cannot block forever.
|
||||||
|
_, _ = s.ExpireClaims()
|
||||||
|
st, err := s.GetStream(id)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
nowT := time.Now().UTC()
|
||||||
|
if st.ClaimedBy != "" && st.ClaimExp != "" {
|
||||||
|
if exp, e := time.Parse(time.RFC3339, st.ClaimExp); e == nil && exp.After(nowT) && st.ClaimedBy != agentID {
|
||||||
|
return Stream{}, fmt.Errorf("claimed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claimedAt := nowT.Format(time.RFC3339)
|
||||||
|
expires := nowT.Add(ttl).Format(time.RFC3339)
|
||||||
|
_, err = s.DB.Exec(`
|
||||||
|
INSERT INTO agent_claims(stream_id,agent_id,claimed_at,expires_at) VALUES(?,?,?,?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET agent_id=excluded.agent_id, claimed_at=excluded.claimed_at, expires_at=excluded.expires_at`,
|
||||||
|
id, agentID, claimedAt, expires)
|
||||||
|
if err != nil {
|
||||||
|
return Stream{}, err
|
||||||
|
}
|
||||||
|
return s.GetStream(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReleaseClaim(id int64, agentID string) error {
|
||||||
|
if agentID == "" {
|
||||||
|
_, err := s.DB.Exec(`DELETE FROM agent_claims WHERE stream_id=?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.DB.Exec(`DELETE FROM agent_claims WHERE stream_id=? AND agent_id=?`, id, agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpireClaims deletes claims whose expires_at is in the past. Returns rows removed.
|
||||||
|
func (s *Store) ExpireClaims() (int64, error) {
|
||||||
|
res, err := s.DB.Exec(`DELETE FROM agent_claims WHERE expires_at <> '' AND expires_at < ?`, now())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewAgentClaims extends expires_at for every claim held by agentID.
|
||||||
|
func (s *Store) RenewAgentClaims(agentID string, ttl time.Duration) (int64, error) {
|
||||||
|
if agentID == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 90 * time.Second
|
||||||
|
}
|
||||||
|
exp := time.Now().UTC().Add(ttl).Format(time.RFC3339)
|
||||||
|
res, err := s.DB.Exec(`UPDATE agent_claims SET expires_at=? WHERE agent_id=?`, exp, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAgentClaims drops every claim for agentID (used on WS disconnect).
|
||||||
|
func (s *Store) ReleaseAgentClaims(agentID string) (int64, error) {
|
||||||
|
if agentID == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
res, err := s.DB.Exec(`DELETE FROM agent_claims WHERE agent_id=?`, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimePatch updates live worker fields for a stream.
|
||||||
|
type RuntimePatch struct {
|
||||||
|
Health string
|
||||||
|
PID int64
|
||||||
|
PlayPath string
|
||||||
|
UptimeS float64
|
||||||
|
BitrateMbps float64
|
||||||
|
PlaylistAgeS float64
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetRuntime(id int64, p RuntimePatch) error {
|
||||||
|
play := p.PlayPath
|
||||||
|
if play == "" {
|
||||||
|
st, err := s.GetStream(id)
|
||||||
|
if err == nil {
|
||||||
|
play = "/hls/" + st.Name + "/index.m3u8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := s.DB.Exec(`
|
||||||
|
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,bitrate_mbps,playlist_age_s,last_error,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET
|
||||||
|
health=excluded.health,
|
||||||
|
pid=excluded.pid,
|
||||||
|
play_path=excluded.play_path,
|
||||||
|
uptime_s=excluded.uptime_s,
|
||||||
|
bitrate_mbps=excluded.bitrate_mbps,
|
||||||
|
playlist_age_s=excluded.playlist_age_s,
|
||||||
|
last_error=excluded.last_error,
|
||||||
|
updated_at=excluded.updated_at`,
|
||||||
|
id, p.Health, p.PID, play, p.UptimeS, p.BitrateMbps, p.PlaylistAgeS, p.LastError, now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsCapture reports whether an enabled stream looks down / missing creds.
|
||||||
|
func (st Stream) NeedsCapture() bool {
|
||||||
|
if !st.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch st.Health {
|
||||||
|
case "down", "stopped", "starting", "":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if st.PlaylistAgeS > 30 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
87
apps/streamd/internal/supervisor/appdefaults.go
Normal file
87
apps/streamd/internal/supervisor/appdefaults.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/apps/streamd/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// streamSettings are the downloader knobs for one stream. They come from the
|
||||||
|
// stream's app module when it declares them, so the supervisor never has to
|
||||||
|
// guess from an app name or a stream name.
|
||||||
|
type streamSettings struct {
|
||||||
|
VideoSelect string
|
||||||
|
AudioSelect string
|
||||||
|
Rewriter string
|
||||||
|
LiveWait int
|
||||||
|
TSReadyBytes int64
|
||||||
|
// IsHLS drives packaging choices that depend on the manifest format.
|
||||||
|
IsHLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLiveWait = 2
|
||||||
|
defaultTSReadyBytes = 256 * 1024
|
||||||
|
// hlsTSReadyBytes buffers more before probing: an HLS pipe-mux often starts
|
||||||
|
// with incomplete audio config.
|
||||||
|
hlsTSReadyBytes = 2 * 1024 * 1024
|
||||||
|
hlsLiveWait = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsFor resolves a stream's downloader settings: the app module's
|
||||||
|
// declarations first, then the stream row, then neutral defaults.
|
||||||
|
func settingsFor(st db.Stream) streamSettings {
|
||||||
|
s := streamSettings{
|
||||||
|
VideoSelect: strings.TrimSpace(st.VideoSelect),
|
||||||
|
AudioSelect: strings.TrimSpace(st.AudioSelect),
|
||||||
|
Rewriter: strings.TrimSpace(st.Rewriter),
|
||||||
|
LiveWait: defaultLiveWait,
|
||||||
|
TSReadyBytes: defaultTSReadyBytes,
|
||||||
|
IsHLS: isHLS(st),
|
||||||
|
}
|
||||||
|
if a, err := app.Get(st.App); err == nil {
|
||||||
|
if sd, ok := a.(app.StreamDefaults); ok {
|
||||||
|
if s.VideoSelect == "" {
|
||||||
|
s.VideoSelect = sd.VideoSelect()
|
||||||
|
}
|
||||||
|
if s.AudioSelect == "" {
|
||||||
|
s.AudioSelect = sd.AudioSelect()
|
||||||
|
}
|
||||||
|
if s.Rewriter == "" {
|
||||||
|
s.Rewriter = sd.RewriterName()
|
||||||
|
}
|
||||||
|
if n := sd.LiveWaitSeconds(); n > 0 {
|
||||||
|
s.LiveWait = n
|
||||||
|
}
|
||||||
|
if n := sd.TSReadyBytes(); n > 0 {
|
||||||
|
s.TSReadyBytes = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.IsHLS {
|
||||||
|
if s.LiveWait == defaultLiveWait {
|
||||||
|
s.LiveWait = hlsLiveWait
|
||||||
|
}
|
||||||
|
if s.TSReadyBytes == defaultTSReadyBytes {
|
||||||
|
s.TSReadyBytes = hlsTSReadyBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.VideoSelect == "" {
|
||||||
|
s.VideoSelect = "for=best"
|
||||||
|
}
|
||||||
|
if s.AudioSelect == "" {
|
||||||
|
s.AudioSelect = "for=best"
|
||||||
|
}
|
||||||
|
if s.Rewriter == "" {
|
||||||
|
s.Rewriter = "none"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHLS reports whether a stream's manifest is an HLS playlist, by URL shape.
|
||||||
|
// No provider host is consulted: a module declares its own needs through
|
||||||
|
// app.StreamDefaults.
|
||||||
|
func isHLS(st db.Stream) bool {
|
||||||
|
return strings.Contains(strings.ToLower(strings.TrimSpace(st.MPD)), ".m3u8")
|
||||||
|
}
|
||||||
129
apps/streamd/internal/supervisor/appdefaults_test.go
Normal file
129
apps/streamd/internal/supervisor/appdefaults_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"drmdecryption/adb"
|
||||||
|
"drmdecryption/app"
|
||||||
|
"drmdecryption/apps/streamd/internal/db"
|
||||||
|
"drmdecryption/capture"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeApp stands in for a compiled-in app module: the supervisor must take its
|
||||||
|
// declared settings without knowing anything about the provider.
|
||||||
|
type fakeApp struct {
|
||||||
|
name string
|
||||||
|
rewriter string
|
||||||
|
liveWait int
|
||||||
|
tsBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeApp) Name() string { return f.name }
|
||||||
|
func (f *fakeApp) Package() string { return "com.example." + f.name }
|
||||||
|
func (f *fakeApp) LicenseURL() string { return "" }
|
||||||
|
func (f *fakeApp) Launch(*adb.Client) error { return nil }
|
||||||
|
func (f *fakeApp) AutoPlay(*adb.Client, string) error { return nil }
|
||||||
|
func (f *fakeApp) CaptureHints() capture.Hints { return capture.DefaultHints() }
|
||||||
|
func (f *fakeApp) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||||
|
func (f *fakeApp) Channels() []app.Channel { return nil }
|
||||||
|
func (f *fakeApp) HasChannel(string) bool { return false }
|
||||||
|
func (f *fakeApp) ProxyBin() string { return "proxy" }
|
||||||
|
func (f *fakeApp) CAHash() string { return "deadbeef" }
|
||||||
|
func (f *fakeApp) KeyMode() string { return "raw" }
|
||||||
|
func (f *fakeApp) VideoSelect() string { return "res=1280x720:for=best" }
|
||||||
|
func (f *fakeApp) AudioSelect() string { return "lang=en:for=best" }
|
||||||
|
func (f *fakeApp) RewriterName() string { return f.rewriter }
|
||||||
|
func (f *fakeApp) LiveWaitSeconds() int { return f.liveWait }
|
||||||
|
func (f *fakeApp) TSReadyBytes() int64 { return f.tsBytes }
|
||||||
|
|
||||||
|
func register(t *testing.T, f *fakeApp) {
|
||||||
|
t.Helper()
|
||||||
|
app.Register(f.name, func() (app.App, error) { return f, nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsComeFromTheAppModule(t *testing.T) {
|
||||||
|
register(t, &fakeApp{name: "fakedash", rewriter: "fakedash", liveWait: 3, tsBytes: 111})
|
||||||
|
got := settingsFor(db.Stream{App: "fakedash", MPD: "https://origin.test/manifest.mpd"})
|
||||||
|
if got.Rewriter != "fakedash" {
|
||||||
|
t.Errorf("rewriter = %q, want fakedash", got.Rewriter)
|
||||||
|
}
|
||||||
|
if got.VideoSelect != "res=1280x720:for=best" || got.AudioSelect != "lang=en:for=best" {
|
||||||
|
t.Errorf("selectors = %q / %q", got.VideoSelect, got.AudioSelect)
|
||||||
|
}
|
||||||
|
if got.LiveWait != 3 || got.TSReadyBytes != 111 {
|
||||||
|
t.Errorf("liveWait=%d tsReadyBytes=%d", got.LiveWait, got.TSReadyBytes)
|
||||||
|
}
|
||||||
|
if got.IsHLS {
|
||||||
|
t.Error("an .mpd manifest is not HLS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored row wins over the module, so an operator override sticks.
|
||||||
|
func TestStoredSettingsOverrideTheModule(t *testing.T) {
|
||||||
|
register(t, &fakeApp{name: "fakeoverride", rewriter: "fakeoverride"})
|
||||||
|
got := settingsFor(db.Stream{
|
||||||
|
App: "fakeoverride",
|
||||||
|
MPD: "https://origin.test/manifest.mpd",
|
||||||
|
VideoSelect: "res=1920x1080:for=best",
|
||||||
|
Rewriter: "none",
|
||||||
|
})
|
||||||
|
if got.VideoSelect != "res=1920x1080:for=best" {
|
||||||
|
t.Errorf("video select = %q, want the stored value", got.VideoSelect)
|
||||||
|
}
|
||||||
|
if got.Rewriter != "none" {
|
||||||
|
t.Errorf("rewriter = %q, want the stored none", got.Rewriter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown app must not inherit any provider's preferences.
|
||||||
|
func TestUnknownAppGetsNeutralDefaults(t *testing.T) {
|
||||||
|
got := settingsFor(db.Stream{App: "nosuchapp", MPD: "https://origin.test/manifest.mpd"})
|
||||||
|
if got.VideoSelect != "for=best" || got.AudioSelect != "for=best" {
|
||||||
|
t.Errorf("selectors = %q / %q, want for=best", got.VideoSelect, got.AudioSelect)
|
||||||
|
}
|
||||||
|
if got.Rewriter != "none" {
|
||||||
|
t.Errorf("rewriter = %q, want none", got.Rewriter)
|
||||||
|
}
|
||||||
|
if got.LiveWait != defaultLiveWait || got.TSReadyBytes != defaultTSReadyBytes {
|
||||||
|
t.Errorf("liveWait=%d tsReadyBytes=%d, want neutral defaults", got.LiveWait, got.TSReadyBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS gets more buffering and a longer live wait, by manifest shape and not by
|
||||||
|
// matching a stream name.
|
||||||
|
func TestHLSDetectionByManifestShape(t *testing.T) {
|
||||||
|
got := settingsFor(db.Stream{App: "", MPD: "https://cdn.test/x/playlist-hls-dvr.m3u8"})
|
||||||
|
if !got.IsHLS {
|
||||||
|
t.Fatal("an .m3u8 manifest should be detected as HLS")
|
||||||
|
}
|
||||||
|
if got.LiveWait != hlsLiveWait {
|
||||||
|
t.Errorf("liveWait = %d, want %d", got.LiveWait, hlsLiveWait)
|
||||||
|
}
|
||||||
|
if got.TSReadyBytes != hlsTSReadyBytes {
|
||||||
|
t.Errorf("tsReadyBytes = %d, want %d", got.TSReadyBytes, hlsTSReadyBytes)
|
||||||
|
}
|
||||||
|
// The old code keyed this off stream names like "tg4"/"cula"/"plus".
|
||||||
|
named := settingsFor(db.Stream{Name: "tg4-ioi", MPD: "https://origin.test/manifest.mpd"})
|
||||||
|
if named.TSReadyBytes != defaultTSReadyBytes {
|
||||||
|
t.Error("buffering must not be chosen by stream name any more")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module's rewriter is only used if it registered one under that name.
|
||||||
|
func TestRewriterLookupGatesTheRewrite(t *testing.T) {
|
||||||
|
register(t, &fakeApp{name: "fakeunregistered", rewriter: "fakeunregistered"})
|
||||||
|
cfg := settingsFor(db.Stream{App: "fakeunregistered", MPD: "https://origin.test/manifest.mpd"})
|
||||||
|
if _, needed := mpd.Lookup(cfg.Rewriter); needed {
|
||||||
|
t.Error("an unregistered rewriter name must not trigger a rewrite")
|
||||||
|
}
|
||||||
|
mpd.Register("fakeunregistered", func() mpd.Rewriter { return stubRewriter{} })
|
||||||
|
if _, needed := mpd.Lookup(cfg.Rewriter); !needed {
|
||||||
|
t.Error("a registered rewriter must trigger a rewrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubRewriter struct{}
|
||||||
|
|
||||||
|
func (stubRewriter) Name() string { return "fakeunregistered" }
|
||||||
|
func (stubRewriter) Rewrite(in []byte, _ string) ([]byte, error) { return in, nil }
|
||||||
779
apps/streamd/internal/supervisor/media.go
Normal file
779
apps/streamd/internal/supervisor/media.go
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/apps/streamd/internal/db"
|
||||||
|
"drmdecryption/mpd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Media runs N_m3u8DL-RE + ffmpeg HLS packager per stream into DataDir/www/<name>/.
|
||||||
|
type Media struct {
|
||||||
|
Store *db.Store
|
||||||
|
DataDir string
|
||||||
|
NRE string
|
||||||
|
FFmpeg string
|
||||||
|
MP4Decrypt string
|
||||||
|
Log *log.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
procs map[int64]*streamProc
|
||||||
|
stopMon chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamProc struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
nre *exec.Cmd
|
||||||
|
ffmpeg *exec.Cmd
|
||||||
|
started time.Time
|
||||||
|
workDir string
|
||||||
|
wwwDir string
|
||||||
|
tsPath string
|
||||||
|
hlsIndex string
|
||||||
|
nreLog *os.File
|
||||||
|
ffOutLog *os.File
|
||||||
|
ffErrLog *os.File
|
||||||
|
stopping bool
|
||||||
|
mpdProxy *mpd.LocalServer
|
||||||
|
nreDead bool
|
||||||
|
ffDead bool
|
||||||
|
// tsReadyBytes is how much muxed output to buffer before probing, from the
|
||||||
|
// stream's app module.
|
||||||
|
tsReadyBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for constructing the media supervisor.
|
||||||
|
type Options struct {
|
||||||
|
Store *db.Store
|
||||||
|
DataDir string
|
||||||
|
NRE string
|
||||||
|
FFmpeg string
|
||||||
|
MP4Decrypt string
|
||||||
|
Log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMedia(opt Options) *Media {
|
||||||
|
lg := opt.Log
|
||||||
|
if lg == nil {
|
||||||
|
lg = log.Default()
|
||||||
|
}
|
||||||
|
m := &Media{
|
||||||
|
Store: opt.Store,
|
||||||
|
DataDir: opt.DataDir,
|
||||||
|
NRE: opt.NRE,
|
||||||
|
FFmpeg: opt.FFmpeg,
|
||||||
|
MP4Decrypt: opt.MP4Decrypt,
|
||||||
|
Log: lg,
|
||||||
|
procs: map[int64]*streamProc{},
|
||||||
|
stopMon: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go m.monitorLoop()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) Close() {
|
||||||
|
close(m.stopMon)
|
||||||
|
m.mu.Lock()
|
||||||
|
ids := make([]int64, 0, len(m.procs))
|
||||||
|
for id := range m.procs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
_ = m.Stop(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) Start(id int64) error {
|
||||||
|
st, err := m.Store.GetStream(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "down",
|
||||||
|
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||||
|
LastError: "missing mpd/key - capture required",
|
||||||
|
})
|
||||||
|
return fmt.Errorf("missing mpd/key")
|
||||||
|
}
|
||||||
|
mpdURL := strings.TrimSpace(st.MPD)
|
||||||
|
if !strings.HasPrefix(strings.ToLower(mpdURL), "http://") && !strings.HasPrefix(strings.ToLower(mpdURL), "https://") {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "down",
|
||||||
|
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||||
|
LastError: "mpd must be an http(s) URL",
|
||||||
|
})
|
||||||
|
return fmt.Errorf("invalid mpd url")
|
||||||
|
}
|
||||||
|
if m.NRE == "" || m.FFmpeg == "" {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "down",
|
||||||
|
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||||
|
LastError: "NRE or ffmpeg binary not configured",
|
||||||
|
})
|
||||||
|
return fmt.Errorf("NRE or ffmpeg not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.procs[id]; exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return m.Restart(id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "starting",
|
||||||
|
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||||
|
})
|
||||||
|
|
||||||
|
workDir := filepath.Join(m.DataDir, "work", st.Name)
|
||||||
|
wwwDir := filepath.Join(m.DataDir, "www", st.Name)
|
||||||
|
// Fresh dirs each start — leftover decrypted segments make NRE File.Move fail.
|
||||||
|
_ = os.RemoveAll(wwwDir)
|
||||||
|
_ = os.RemoveAll(workDir)
|
||||||
|
for _, d := range []string{workDir, wwwDir, filepath.Join(m.DataDir, "logs")} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysFile := filepath.Join(workDir, "keys.txt")
|
||||||
|
keysBody := normalizeKeysFile(st.Key)
|
||||||
|
if err := os.WriteFile(keysFile, []byte(keysBody), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
saveName := st.Name
|
||||||
|
tsPath := filepath.Join(workDir, saveName+".ts")
|
||||||
|
_ = os.Remove(tsPath)
|
||||||
|
|
||||||
|
headers := headerMap(st.HeadersJSON)
|
||||||
|
manifestURL := st.MPD
|
||||||
|
cfg := settingsFor(st)
|
||||||
|
var mpdProxy *mpd.LocalServer
|
||||||
|
// The rewriter is named by the stream row or its app module and looked up in
|
||||||
|
// the mpd registry, so streamd needs no knowledge of any provider.
|
||||||
|
if rw, needed := mpd.Lookup(cfg.Rewriter); needed && strings.TrimSpace(st.MPD) != "" && !cfg.IsHLS {
|
||||||
|
srv, err := mpd.StartLocal(st.MPD, st.Key, headers, rw)
|
||||||
|
if err != nil {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "down", PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||||
|
LastError: "mpd rewrite server: " + err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mpdProxy = srv
|
||||||
|
manifestURL = srv.URL
|
||||||
|
m.logf("stream %s: rewritten MPD %s", st.Name, manifestURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ad-stitched manifest rewrite + live pipe mux.
|
||||||
|
nreTmp := filepath.Join(workDir, "nre")
|
||||||
|
_ = os.MkdirAll(nreTmp, 0o755)
|
||||||
|
tsPath = filepath.Join(nreTmp, saveName+".ts")
|
||||||
|
_ = os.Remove(tsPath)
|
||||||
|
|
||||||
|
liveWait := strconv.Itoa(cfg.LiveWait)
|
||||||
|
nreArgs := []string{
|
||||||
|
manifestURL,
|
||||||
|
// Multi-KID SAMPLE-AES streams rainbow-decrypt with a single --key, so
|
||||||
|
// always feed every KID:KEY via --key-text-file.
|
||||||
|
"--key-text-file", keysFile,
|
||||||
|
"--live-real-time-merge",
|
||||||
|
"--live-pipe-mux",
|
||||||
|
"--mp4-real-time-decryption",
|
||||||
|
"--live-wait-time", liveWait,
|
||||||
|
"--ffmpeg-binary-path", m.FFmpeg,
|
||||||
|
"--save-name", saveName,
|
||||||
|
"--save-dir", nreTmp,
|
||||||
|
"--tmp-dir", nreTmp,
|
||||||
|
"--no-ansi-color",
|
||||||
|
"--log-level", "ERROR",
|
||||||
|
"-ss", "0",
|
||||||
|
}
|
||||||
|
// A synthetic manifest publishes exactly one video + audio pair, and an HLS
|
||||||
|
// master's variants rarely match a resolution/language filter, so in both
|
||||||
|
// cases take the best rendition instead of the configured selectors.
|
||||||
|
if mpdProxy != nil || cfg.IsHLS {
|
||||||
|
nreArgs = append(nreArgs, "-sv", "for=best", "-sa", "for=best")
|
||||||
|
} else {
|
||||||
|
nreArgs = append(nreArgs, "-sv", cfg.VideoSelect, "-sa", cfg.AudioSelect)
|
||||||
|
}
|
||||||
|
if m.MP4Decrypt != "" {
|
||||||
|
nreArgs = append(nreArgs,
|
||||||
|
"--decryption-engine", "MP4DECRYPT",
|
||||||
|
"--decryption-binary-path", m.MP4Decrypt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Headers only needed when hitting upstream DAI directly (non-rewritten).
|
||||||
|
if mpdProxy == nil {
|
||||||
|
for _, h := range headerFlags(st.HeadersJSON) {
|
||||||
|
nreArgs = append(nreArgs, "-H", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = keysFile // kept on disk for debugging
|
||||||
|
|
||||||
|
nreLogPath := filepath.Join(m.DataDir, "logs", st.Name+"-nre.log")
|
||||||
|
nreLog, err := os.OpenFile(nreLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nreCmd := exec.Command(m.NRE, nreArgs...)
|
||||||
|
nreCmd.Stdout = nreLog
|
||||||
|
nreCmd.Stderr = nreLog
|
||||||
|
hideWindow(nreCmd)
|
||||||
|
// NRE (v0.6) resolves mp4decrypt via PATH / sibling of its .exe, and often
|
||||||
|
// ignores --decryption-binary-path for MP4DECRYPT. Match legacy: prepend
|
||||||
|
// dirs and best-effort copy beside NRE.
|
||||||
|
nreEnv := os.Environ()
|
||||||
|
pathPrepend := []string{}
|
||||||
|
if m.MP4Decrypt != "" {
|
||||||
|
pathPrepend = append(pathPrepend, filepath.Dir(m.MP4Decrypt))
|
||||||
|
sibling := filepath.Join(filepath.Dir(m.NRE), filepath.Base(m.MP4Decrypt))
|
||||||
|
if _, err := os.Stat(sibling); err != nil {
|
||||||
|
_ = copyFile(m.MP4Decrypt, sibling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.NRE != "" {
|
||||||
|
pathPrepend = append(pathPrepend, filepath.Dir(m.NRE))
|
||||||
|
}
|
||||||
|
if len(pathPrepend) > 0 {
|
||||||
|
sep := string(os.PathListSeparator)
|
||||||
|
nreEnv = prependPathEnv(nreEnv, strings.Join(pathPrepend, sep))
|
||||||
|
}
|
||||||
|
nreCmd.Env = nreEnv
|
||||||
|
if err := nreCmd.Start(); err != nil {
|
||||||
|
_ = nreLog.Close()
|
||||||
|
if mpdProxy != nil {
|
||||||
|
mpdProxy.Close()
|
||||||
|
}
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{Health: "down", LastError: err.Error(), PlayPath: "/hls/" + st.Name + "/index.m3u8"})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.logf("stream %s: NRE pid=%d", st.Name, nreCmd.Process.Pid)
|
||||||
|
|
||||||
|
sp := &streamProc{
|
||||||
|
id: id,
|
||||||
|
name: st.Name,
|
||||||
|
nre: nreCmd,
|
||||||
|
started: time.Now(),
|
||||||
|
workDir: workDir,
|
||||||
|
wwwDir: wwwDir,
|
||||||
|
tsPath: tsPath,
|
||||||
|
hlsIndex: filepath.Join(wwwDir, "index.m3u8"),
|
||||||
|
nreLog: nreLog,
|
||||||
|
mpdProxy: mpdProxy,
|
||||||
|
|
||||||
|
tsReadyBytes: cfg.TSReadyBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.procs[id] = sp
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = nreCmd.Wait()
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[id]; ok && cur.nre == nreCmd {
|
||||||
|
cur.nreDead = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go m.bootstrapHLS(sp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) bootstrapHLS(sp *streamProc) {
|
||||||
|
deadline := time.Now().Add(3 * time.Minute)
|
||||||
|
nreDir := filepath.Dir(sp.tsPath)
|
||||||
|
minTS := sp.tsReadyBytes
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
m.mu.Lock()
|
||||||
|
cur := m.procs[sp.id]
|
||||||
|
stopping := cur == nil || cur.stopping
|
||||||
|
nreDead := sp.nreDead
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stopping {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if found := findGrowingTSMin(nreDir, sp.name, minTS); found != "" {
|
||||||
|
sp.tsPath = found
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[sp.id]; ok {
|
||||||
|
cur.tsPath = found
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if nreDead || (sp.nre.ProcessState != nil && sp.nre.ProcessState.Exited()) {
|
||||||
|
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||||
|
Health: "down",
|
||||||
|
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||||
|
LastError: "NRE exited before TS was ready — see logs/" + sp.name + "-nre.log",
|
||||||
|
})
|
||||||
|
m.cleanupProc(sp.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(sp.tsPath); err != nil || st.Size() < minTS {
|
||||||
|
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||||
|
Health: "down",
|
||||||
|
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||||
|
LastError: "timed out waiting for growing TS",
|
||||||
|
})
|
||||||
|
_ = m.Stop(sp.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.startFFmpegHLS(sp); err != nil {
|
||||||
|
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||||
|
Health: "down", LastError: err.Error(), PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pid := int64(0)
|
||||||
|
if sp.nre != nil && sp.nre.Process != nil {
|
||||||
|
pid = int64(sp.nre.Process.Pid)
|
||||||
|
}
|
||||||
|
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||||
|
Health: "starting",
|
||||||
|
PID: pid,
|
||||||
|
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) startFFmpegHLS(sp *streamProc) error {
|
||||||
|
ffOut := filepath.Join(m.DataDir, "logs", sp.name+"-ffmpeg.out.log")
|
||||||
|
ffErr := filepath.Join(m.DataDir, "logs", sp.name+"-ffmpeg.err.log")
|
||||||
|
outF, err := os.OpenFile(ffOut, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
errF, err := os.OpenFile(ffErr, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
_ = outF.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ffArgs := []string{
|
||||||
|
"-hide_banner", "-loglevel", "warning", "-y",
|
||||||
|
"-follow", "1",
|
||||||
|
// Large probe: early pipe-mux bytes often lack clean audio config.
|
||||||
|
"-analyzeduration", "20M",
|
||||||
|
"-probesize", "10M",
|
||||||
|
"-fflags", "+genpts",
|
||||||
|
"-i", sp.tsPath,
|
||||||
|
"-map", "0",
|
||||||
|
"-c", "copy",
|
||||||
|
"-f", "hls",
|
||||||
|
"-hls_time", "4",
|
||||||
|
"-hls_list_size", "15",
|
||||||
|
"-hls_flags", "delete_segments+append_list+omit_endlist+independent_segments",
|
||||||
|
"-hls_segment_type", "mpegts",
|
||||||
|
"-hls_segment_filename", filepath.Join(sp.wwwDir, "seg_%05d.ts"),
|
||||||
|
sp.hlsIndex,
|
||||||
|
}
|
||||||
|
ffCmd := exec.Command(m.FFmpeg, ffArgs...)
|
||||||
|
ffCmd.Stdout = outF
|
||||||
|
ffCmd.Stderr = errF
|
||||||
|
hideWindow(ffCmd)
|
||||||
|
if err := ffCmd.Start(); err != nil {
|
||||||
|
_ = outF.Close()
|
||||||
|
_ = errF.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.logf("stream %s: ffmpeg HLS pid=%d → %s", sp.name, ffCmd.Process.Pid, sp.hlsIndex)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[sp.id]; ok && !cur.stopping {
|
||||||
|
if cur.ffOutLog != nil {
|
||||||
|
_ = cur.ffOutLog.Close()
|
||||||
|
}
|
||||||
|
if cur.ffErrLog != nil {
|
||||||
|
_ = cur.ffErrLog.Close()
|
||||||
|
}
|
||||||
|
cur.ffmpeg = ffCmd
|
||||||
|
cur.ffOutLog = outF
|
||||||
|
cur.ffErrLog = errF
|
||||||
|
cur.ffDead = false
|
||||||
|
} else {
|
||||||
|
m.mu.Unlock()
|
||||||
|
_ = killProcess(ffCmd)
|
||||||
|
_ = outF.Close()
|
||||||
|
_ = errF.Close()
|
||||||
|
return fmt.Errorf("stream stopped before ffmpeg attach")
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = ffCmd.Wait()
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[sp.id]; ok && cur.ffmpeg == ffCmd {
|
||||||
|
cur.ffDead = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) Stop(id int64) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
sp, ok := m.procs[id]
|
||||||
|
if ok {
|
||||||
|
sp.stopping = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
st, _ := m.Store.GetStream(id)
|
||||||
|
name := fmt.Sprintf("%d", id)
|
||||||
|
if st.Name != "" {
|
||||||
|
name = st.Name
|
||||||
|
}
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{Health: "stopped", PlayPath: "/hls/" + name + "/index.m3u8"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nrePID, ffPID := 0, 0
|
||||||
|
if sp.ffmpeg != nil && sp.ffmpeg.Process != nil {
|
||||||
|
ffPID = sp.ffmpeg.Process.Pid
|
||||||
|
_ = killProcess(sp.ffmpeg)
|
||||||
|
}
|
||||||
|
if sp.nre != nil && sp.nre.Process != nil {
|
||||||
|
nrePID = sp.nre.Process.Pid
|
||||||
|
_ = killProcess(sp.nre)
|
||||||
|
}
|
||||||
|
if sp.mpdProxy != nil {
|
||||||
|
sp.mpdProxy.Close()
|
||||||
|
sp.mpdProxy = nil
|
||||||
|
}
|
||||||
|
waitPIDsGone([]int{nrePID, ffPID}, 5*time.Second)
|
||||||
|
m.cleanupProc(id)
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "stopped",
|
||||||
|
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||||
|
})
|
||||||
|
m.logf("stream %s: stopped", sp.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) Restart(id int64) error {
|
||||||
|
m.logf("stream id=%d: hard restart (kill worker, fresh start)", id)
|
||||||
|
_ = m.Stop(id)
|
||||||
|
// Give Windows time to release file locks on work/www before Start wipes them.
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
return m.Start(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) cleanupProc(id int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
sp, ok := m.procs[id]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sp.nreLog != nil {
|
||||||
|
_ = sp.nreLog.Close()
|
||||||
|
}
|
||||||
|
if sp.ffOutLog != nil {
|
||||||
|
_ = sp.ffOutLog.Close()
|
||||||
|
}
|
||||||
|
if sp.ffErrLog != nil {
|
||||||
|
_ = sp.ffErrLog.Close()
|
||||||
|
}
|
||||||
|
delete(m.procs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) monitorLoop() {
|
||||||
|
t := time.NewTicker(5 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopMon:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
m.mu.Lock()
|
||||||
|
ids := make([]int64, 0, len(m.procs))
|
||||||
|
for id := range m.procs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
m.refreshHealth(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) refreshHealth(id int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
sp, ok := m.procs[id]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok || sp.stopping {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
nreDead := sp.nreDead
|
||||||
|
ffDead := sp.ffDead
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
playPath := "/hls/" + sp.name + "/index.m3u8"
|
||||||
|
uptime := time.Since(sp.started).Seconds()
|
||||||
|
pid := int64(0)
|
||||||
|
if sp.nre != nil && sp.nre.Process != nil {
|
||||||
|
pid = int64(sp.nre.Process.Pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nreDead {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "down", PID: 0, PlayPath: playPath, UptimeS: uptime,
|
||||||
|
LastError: "NRE process exited — see logs/" + sp.name + "-nre.log",
|
||||||
|
})
|
||||||
|
m.cleanupProc(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(sp.hlsIndex)
|
||||||
|
if err != nil {
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: "starting", PID: pid, PlayPath: playPath, UptimeS: uptime,
|
||||||
|
LastError: "waiting for HLS playlist",
|
||||||
|
})
|
||||||
|
if ffDead {
|
||||||
|
// restart ffmpeg packager if TS still growing
|
||||||
|
go m.restartFFmpeg(sp)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
age := time.Since(fi.ModTime()).Seconds()
|
||||||
|
health := "ok"
|
||||||
|
lastErr := ""
|
||||||
|
if age > 45 {
|
||||||
|
health = "down"
|
||||||
|
lastErr = fmt.Sprintf("playlist stale (%.0fs)", age)
|
||||||
|
}
|
||||||
|
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||||
|
Health: health, PID: pid, PlayPath: playPath, UptimeS: uptime,
|
||||||
|
PlaylistAgeS: age, LastError: lastErr,
|
||||||
|
})
|
||||||
|
if ffDead && health != "down" {
|
||||||
|
go m.restartFFmpeg(sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) restartFFmpeg(sp *streamProc) {
|
||||||
|
m.mu.Lock()
|
||||||
|
cur, ok := m.procs[sp.id]
|
||||||
|
if !ok || cur.stopping || !cur.ffDead {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Claim the restart slot so monitorLoop does not spawn duplicates.
|
||||||
|
cur.ffDead = false
|
||||||
|
if cur.ffmpeg != nil {
|
||||||
|
_ = killProcess(cur.ffmpeg)
|
||||||
|
}
|
||||||
|
tsPath := cur.tsPath
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
minTS := sp.tsReadyBytes
|
||||||
|
if st, err := os.Stat(tsPath); err != nil || st.Size() < minTS {
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[sp.id]; ok {
|
||||||
|
cur.ffDead = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.logf("stream %s: restarting ffmpeg HLS packager", sp.name)
|
||||||
|
if err := m.startFFmpegHLS(sp); err != nil {
|
||||||
|
m.logf("stream %s: ffmpeg restart failed: %v", sp.name, err)
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[sp.id]; ok {
|
||||||
|
cur.ffDead = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Media) logf(format string, args ...any) {
|
||||||
|
m.Log.Printf("supervisor: "+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerMap(headersJSON string) map[string]string {
|
||||||
|
headersJSON = strings.TrimSpace(headersJSON)
|
||||||
|
if headersJSON == "" || headersJSON == "{}" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(headersJSON), &m); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range m {
|
||||||
|
k = strings.TrimSpace(k)
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if k != "" && v != "" {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerFlags(headersJSON string) []string {
|
||||||
|
m := headerMap(headersJSON)
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out = append(out, k+": "+v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeKeysFile turns a DB key field (one or many KID:KEY lines, or
|
||||||
|
// comma/semicolon separated) into an NRE --key-text-file body.
|
||||||
|
func normalizeKeysFile(raw string) string {
|
||||||
|
raw = strings.ReplaceAll(raw, ",", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, ";", "\n")
|
||||||
|
var lines []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.Count(line, ":") != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[line] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[line] = true
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return strings.TrimSpace(raw) + "\n"
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func killProcess(cmd *exec.Cmd) error {
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func prependPathEnv(env []string, prefix string) []string {
|
||||||
|
if prefix == "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(env)+1)
|
||||||
|
found := false
|
||||||
|
for _, e := range env {
|
||||||
|
if len(e) >= 5 && strings.EqualFold(e[:5], "PATH=") {
|
||||||
|
out = append(out, "PATH="+prefix+string(os.PathListSeparator)+e[5:])
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
out = append(out, "PATH="+prefix)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findGrowingTS locates NRE's live-pipe-mux output. Depending on version/flags
|
||||||
|
// it may be saveName.ts or saveName.<lang>.ts (e.g. test.en.ts).
|
||||||
|
func waitPIDsGone(pids []int, timeout time.Duration) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
alive := false
|
||||||
|
for _, pid := range pids {
|
||||||
|
if pid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !processDead(pid) {
|
||||||
|
alive = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGrowingTS(dir, saveName string) string {
|
||||||
|
return findGrowingTSMin(dir, saveName, 256*1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGrowingTSMin(dir, saveName string, minSize int64) string {
|
||||||
|
if minSize <= 0 {
|
||||||
|
minSize = 256 * 1024
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(dir, saveName+".ts"),
|
||||||
|
}
|
||||||
|
if ents, err := os.ReadDir(dir); err == nil {
|
||||||
|
prefix := saveName + "."
|
||||||
|
for _, e := range ents {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".ts") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == saveName+".ts" || strings.HasPrefix(name, prefix) {
|
||||||
|
candidates = append(candidates, filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var best string
|
||||||
|
var bestSize int64
|
||||||
|
for _, p := range candidates {
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
if err != nil || st.Size() < minSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st.Size() > bestSize {
|
||||||
|
bestSize = st.Size()
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
20
apps/streamd/internal/supervisor/proc_unix.go
Normal file
20
apps/streamd/internal/supervisor/proc_unix.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hideWindow(cmd *exec.Cmd) {}
|
||||||
|
|
||||||
|
func processDead(pid int) bool {
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
err = p.Signal(syscall.Signal(0))
|
||||||
|
return err != nil
|
||||||
|
}
|
||||||
35
apps/streamd/internal/supervisor/proc_windows.go
Normal file
35
apps/streamd/internal/supervisor/proc_windows.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hideWindow(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDead(pid int) bool {
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// On Windows, FindProcess always succeeds; OpenProcess is needed.
|
||||||
|
// tasklist-style: try duplicate handle via Signal is unsupported.
|
||||||
|
// Use Windows API indirectly: if Wait with timeout 0 isn't available,
|
||||||
|
// check via tasklist is heavy — use syscall OpenProcess.
|
||||||
|
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
h, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = p
|
||||||
|
syscall.CloseHandle(h)
|
||||||
|
return false
|
||||||
|
}
|
||||||
71
apps/streamd/internal/supervisor/stub.go
Normal file
71
apps/streamd/internal/supervisor/stub.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"drmdecryption/apps/streamd/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stub marks streams as starting/stopped without spawning media workers.
|
||||||
|
// Replaced by a real NRE/ffmpeg supervisor in Phase 2.
|
||||||
|
type Stub struct {
|
||||||
|
Store *db.Store
|
||||||
|
mu sync.Mutex
|
||||||
|
pids map[int64]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStub(store *db.Store) *Stub {
|
||||||
|
return &Stub{Store: store, pids: map[int64]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stub) Start(id int64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, err := s.Store.GetStream(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st.MPD == "" || st.Key == "" {
|
||||||
|
_, err = s.Store.DB.Exec(`
|
||||||
|
INSERT INTO stream_runtime(stream_id,health,play_path,last_error,updated_at)
|
||||||
|
VALUES(?,?,?,?,?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET health=excluded.health, last_error=excluded.last_error, updated_at=excluded.updated_at`,
|
||||||
|
id, "down", "/hls/"+st.Name+"/index.m3u8", "missing mpd/key - capture required", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pid := 10000 + int(id)
|
||||||
|
s.pids[id] = pid
|
||||||
|
_, err = s.Store.DB.Exec(`
|
||||||
|
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,playlist_age_s,last_error,updated_at)
|
||||||
|
VALUES(?,?,?,?,0,1,'',?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET
|
||||||
|
health=excluded.health, pid=excluded.pid, play_path=excluded.play_path,
|
||||||
|
playlist_age_s=excluded.playlist_age_s, last_error='', updated_at=excluded.updated_at`,
|
||||||
|
id, "ok", pid, "/hls/"+st.Name+"/index.m3u8", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stub) Stop(id int64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.pids, id)
|
||||||
|
st, err := s.Store.GetStream(id)
|
||||||
|
name := fmt.Sprintf("%d", id)
|
||||||
|
if err == nil {
|
||||||
|
name = st.Name
|
||||||
|
}
|
||||||
|
_, err = s.Store.DB.Exec(`
|
||||||
|
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,bitrate_mbps,playlist_age_s,last_error,updated_at)
|
||||||
|
VALUES(?,?,0,?,0,0,0,'',?)
|
||||||
|
ON CONFLICT(stream_id) DO UPDATE SET
|
||||||
|
health='stopped', pid=0, uptime_s=0, bitrate_mbps=0, playlist_age_s=0, last_error='', updated_at=excluded.updated_at`,
|
||||||
|
id, "stopped", "/hls/"+name+"/index.m3u8", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stub) Restart(id int64) error {
|
||||||
|
_ = s.Stop(id)
|
||||||
|
return s.Start(id)
|
||||||
|
}
|
||||||
106
apps/streamd/internal/ui/app.css
Normal file
106
apps/streamd/internal/ui/app.css
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--card: #171a21;
|
||||||
|
--border: #2a303c;
|
||||||
|
--text: #e8eaed;
|
||||||
|
--muted: #9aa0a6;
|
||||||
|
--accent: #7aa2ff;
|
||||||
|
--ok: #3dd68c;
|
||||||
|
--degraded: #f5a524;
|
||||||
|
--down: #f07178;
|
||||||
|
--stopped: #6b7280;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.45 system-ui, Segoe UI, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
|
||||||
|
.header-right { display: flex; gap: 16px; align-items: center; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.card {
|
||||||
|
margin: 16px 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
.span2 { grid-column: span 2; }
|
||||||
|
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
|
input, select, button, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: #0c0e12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5em;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #243044;
|
||||||
|
border-color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button.ghost { background: transparent; }
|
||||||
|
button.danger { border-color: #7f1d1d; color: #fecaca; }
|
||||||
|
.actions { display: flex; gap: 8px; align-items: end; }
|
||||||
|
.token-box input { width: 180px; }
|
||||||
|
.row-between { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.pill.ok { color: var(--ok); }
|
||||||
|
.pill.degraded { color: var(--degraded); }
|
||||||
|
.pill.down, .pill.starting { color: var(--down); }
|
||||||
|
.pill.stopped { color: var(--stopped); }
|
||||||
|
.pill.claim { color: #c4b5fd; background: #1f1635; }
|
||||||
|
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.row-actions button { padding: 4px 8px; font-size: 12px; }
|
||||||
|
.msg { min-height: 1.2em; margin: 8px 0 0; }
|
||||||
|
.msg.err { color: var(--down); }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
min-width: min(560px, 92vw);
|
||||||
|
}
|
||||||
|
dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.span2 { grid-column: span 1; }
|
||||||
|
header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
}
|
||||||
286
apps/streamd/internal/ui/app.js
Normal file
286
apps/streamd/internal/ui/app.js
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
(() => {
|
||||||
|
const tokenEl = document.getElementById("token");
|
||||||
|
const rowsEl = document.getElementById("rows");
|
||||||
|
const listMsg = document.getElementById("list-msg");
|
||||||
|
const formMsg = document.getElementById("form-msg");
|
||||||
|
const uptimeEl = document.getElementById("uptime");
|
||||||
|
const agentsEl = document.getElementById("agents-status");
|
||||||
|
const dlg = document.getElementById("edit-dlg");
|
||||||
|
const editForm = document.getElementById("edit-form");
|
||||||
|
|
||||||
|
tokenEl.value = localStorage.getItem("streamd_token") || "";
|
||||||
|
tokenEl.addEventListener("change", () => {
|
||||||
|
localStorage.setItem("streamd_token", tokenEl.value.trim());
|
||||||
|
connectUIWS();
|
||||||
|
});
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return tokenEl.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(json) {
|
||||||
|
const h = {};
|
||||||
|
if (json) h["Content-Type"] = "application/json";
|
||||||
|
const t = token();
|
||||||
|
if (t) h["Authorization"] = "Bearer " + t;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && data.error) || res.statusText || "request failed";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(s) {
|
||||||
|
if (s == null || s === 0) return "—";
|
||||||
|
return Number(s).toFixed(1) + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimLabel(s) {
|
||||||
|
if (!s.claimed_by) return '<span class="muted">—</span>';
|
||||||
|
let exp = "";
|
||||||
|
if (s.claim_expires_at) {
|
||||||
|
const ms = Date.parse(s.claim_expires_at) - Date.now();
|
||||||
|
if (!Number.isNaN(ms)) {
|
||||||
|
exp = ms > 0 ? ` (${Math.ceil(ms / 1000)}s)` : " (expired)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<span class="pill claim">${esc(s.claimed_by)}${esc(exp)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(streams) {
|
||||||
|
if (!streams.length) {
|
||||||
|
rowsEl.innerHTML = "";
|
||||||
|
listMsg.textContent = "No streams yet. Add one above.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listMsg.textContent = streams.length + " stream(s)";
|
||||||
|
rowsEl.innerHTML = streams.map((s) => {
|
||||||
|
const need = !s.mpd || !s.key ? " needs capture" : "";
|
||||||
|
return `<tr>
|
||||||
|
<td><strong>${esc(s.name)}</strong><div class="muted">${esc(s.title || "")}${need}</div></td>
|
||||||
|
<td>${esc(s.app)} / ${esc(s.channel)}</td>
|
||||||
|
<td><span class="pill ${esc(s.health || "stopped")}">${esc(s.health || "stopped")}</span></td>
|
||||||
|
<td>${claimLabel(s)}</td>
|
||||||
|
<td>${age(s.playlist_age_s)}</td>
|
||||||
|
<td><a href="${esc(s.play_path)}" target="_blank" rel="noopener">hls</a></td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<button data-act="start" data-id="${s.id}">Start</button>
|
||||||
|
<button data-act="stop" data-id="${s.id}" class="ghost">Stop</button>
|
||||||
|
<button data-act="restart" data-id="${s.id}" class="ghost">Restart</button>
|
||||||
|
<button data-act="edit" data-id="${s.id}" class="ghost">Edit</button>
|
||||||
|
${s.claimed_by ? `<button data-act="unclaim" data-id="${s.id}" class="ghost">Release</button>` : ""}
|
||||||
|
<button data-act="delete" data-id="${s.id}" class="danger">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(v) {
|
||||||
|
return String(v ?? "").replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgents(list) {
|
||||||
|
if (!agentsEl) return;
|
||||||
|
if (!list || !list.length) {
|
||||||
|
agentsEl.textContent = "agents: none connected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
agentsEl.textContent = "agents: " + list.map((a) => a.agent_id).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const [health, list, agents] = await Promise.all([
|
||||||
|
api("/api/health"),
|
||||||
|
api("/api/streams"),
|
||||||
|
api("/api/agents").catch(() => ({ agents: [] })),
|
||||||
|
]);
|
||||||
|
uptimeEl.textContent = "up " + Math.floor(health.uptime_s || 0) + "s";
|
||||||
|
render(list.streams || []);
|
||||||
|
renderAgents(agents.agents || []);
|
||||||
|
listMsg.classList.remove("err");
|
||||||
|
} catch (e) {
|
||||||
|
listMsg.textContent = e.message;
|
||||||
|
listMsg.classList.add("err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App/channel pickers come from the binary's compiled-in modules, so the UI
|
||||||
|
// never hardcodes a provider.
|
||||||
|
const appSel = document.getElementById("app-select");
|
||||||
|
const chanSel = document.getElementById("channel-select");
|
||||||
|
let apps = [];
|
||||||
|
|
||||||
|
function fillChannels() {
|
||||||
|
const a = apps.find((x) => x.name === appSel.value);
|
||||||
|
chanSel.innerHTML = "";
|
||||||
|
if (!a || !a.channels.length) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = "";
|
||||||
|
o.textContent = a ? "(no module.yaml — no channels)" : "(no apps)";
|
||||||
|
chanSel.appendChild(o);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const ch of a.channels) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = ch.id;
|
||||||
|
o.textContent = ch.label && ch.label !== ch.id ? ch.id + " — " + ch.label : ch.id;
|
||||||
|
chanSel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/apps");
|
||||||
|
apps = data.apps || [];
|
||||||
|
} catch (e) {
|
||||||
|
apps = [];
|
||||||
|
}
|
||||||
|
appSel.innerHTML = "";
|
||||||
|
if (!apps.length) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = "";
|
||||||
|
o.textContent = "(no app modules compiled in)";
|
||||||
|
appSel.appendChild(o);
|
||||||
|
}
|
||||||
|
for (const a of apps) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = a.name;
|
||||||
|
o.textContent = a.name;
|
||||||
|
appSel.appendChild(o);
|
||||||
|
}
|
||||||
|
fillChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
appSel.addEventListener("change", fillChannels);
|
||||||
|
loadApps();
|
||||||
|
|
||||||
|
document.getElementById("refresh").addEventListener("click", refresh);
|
||||||
|
|
||||||
|
document.getElementById("create-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
formMsg.textContent = "";
|
||||||
|
formMsg.classList.remove("err");
|
||||||
|
const fd = new FormData(ev.target);
|
||||||
|
const body = Object.fromEntries(fd.entries());
|
||||||
|
try {
|
||||||
|
await api("/api/streams", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(true),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
ev.target.reset();
|
||||||
|
ev.target.headers_json.value = "{}";
|
||||||
|
fillChannels();
|
||||||
|
formMsg.textContent = "Created.";
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
formMsg.textContent = e.message;
|
||||||
|
formMsg.classList.add("err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rowsEl.addEventListener("click", async (ev) => {
|
||||||
|
const btn = ev.target.closest("button[data-act]");
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
try {
|
||||||
|
if (act === "delete") {
|
||||||
|
if (!confirm("Delete stream #" + id + "?")) return;
|
||||||
|
await api("/api/streams/" + id, { method: "DELETE", headers: headers(false) });
|
||||||
|
} else if (act === "unclaim") {
|
||||||
|
await api("/api/streams/" + id + "/claim/release", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(true),
|
||||||
|
body: JSON.stringify({ agent_id: "" }),
|
||||||
|
});
|
||||||
|
} else if (act === "edit") {
|
||||||
|
const st = await api("/api/streams/" + id);
|
||||||
|
editForm.id.value = st.id;
|
||||||
|
editForm.title.value = st.title || "";
|
||||||
|
editForm.channel.value = st.channel || "";
|
||||||
|
editForm.mpd.value = st.mpd || "";
|
||||||
|
editForm.key.value = st.key || "";
|
||||||
|
editForm.headers_json.value = st.headers_json || "{}";
|
||||||
|
dlg.showModal();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await api("/api/streams/" + id + "/" + act, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
listMsg.textContent = e.message;
|
||||||
|
listMsg.classList.add("err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("edit-cancel").addEventListener("click", () => dlg.close());
|
||||||
|
|
||||||
|
editForm.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const id = editForm.id.value;
|
||||||
|
const body = {
|
||||||
|
title: editForm.title.value,
|
||||||
|
channel: editForm.channel.value,
|
||||||
|
mpd: editForm.mpd.value,
|
||||||
|
key: editForm.key.value,
|
||||||
|
headers_json: editForm.headers_json.value,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api("/api/streams/" + id, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: headers(true),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
dlg.close();
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let uiWS = null;
|
||||||
|
let uiWSTimer = null;
|
||||||
|
function connectUIWS() {
|
||||||
|
if (uiWS) {
|
||||||
|
try { uiWS.close(); } catch {}
|
||||||
|
uiWS = null;
|
||||||
|
}
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(proto + "//" + location.host + "/ws/ui");
|
||||||
|
uiWS = ws;
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === "agents" || msg.type === "agent_online" || msg.type === "agent_offline") {
|
||||||
|
renderAgents(msg.agents || []);
|
||||||
|
}
|
||||||
|
if (msg.type === "claims_changed" || msg.type === "agent_offline") {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(uiWSTimer);
|
||||||
|
uiWSTimer = setTimeout(connectUIWS, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
connectUIWS();
|
||||||
|
setInterval(refresh, 5000);
|
||||||
|
})();
|
||||||
84
apps/streamd/internal/ui/index.html
Normal file
84
apps/streamd/internal/ui/index.html
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>streamd</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>streamd</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="uptime" class="muted"></span>
|
||||||
|
<label class="token-box">token <input id="token" type="password" placeholder="STREAMD_TOKEN" autocomplete="off" /></label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Add stream</h2>
|
||||||
|
<form id="create-form" class="grid">
|
||||||
|
<label>name <input name="name" required placeholder="my-channel" /></label>
|
||||||
|
<label>title <input name="title" placeholder="My Channel" /></label>
|
||||||
|
<label>app
|
||||||
|
<select name="app" id="app-select"></select>
|
||||||
|
</label>
|
||||||
|
<label>channel
|
||||||
|
<select name="channel" id="channel-select"></select>
|
||||||
|
</label>
|
||||||
|
<label class="span2">mpd <input name="mpd" placeholder="https://…/manifest.mpd (optional until capture)" /></label>
|
||||||
|
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line; multi-key streams need every key)" spellcheck="false"></textarea></label>
|
||||||
|
<label class="span2">headers JSON <input name="headers_json" value="{}" /></label>
|
||||||
|
<div class="span2 actions">
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p id="form-msg" class="msg"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="row-between">
|
||||||
|
<h2>Streams</h2>
|
||||||
|
<div class="row-actions">
|
||||||
|
<span id="agents-status" class="muted">agents: —</span>
|
||||||
|
<button type="button" id="refresh" class="ghost">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>name</th>
|
||||||
|
<th>app / channel</th>
|
||||||
|
<th>health</th>
|
||||||
|
<th>claim</th>
|
||||||
|
<th>age</th>
|
||||||
|
<th>play</th>
|
||||||
|
<th>actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="list-msg" class="msg muted">Loading…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dialog id="edit-dlg">
|
||||||
|
<form method="dialog" id="edit-form" class="grid">
|
||||||
|
<h2 class="span2">Edit stream</h2>
|
||||||
|
<input type="hidden" name="id" />
|
||||||
|
<label>title <input name="title" /></label>
|
||||||
|
<label>channel <input name="channel" /></label>
|
||||||
|
<label class="span2">mpd <input name="mpd" /></label>
|
||||||
|
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line)" spellcheck="false"></textarea></label>
|
||||||
|
<label class="span2">headers JSON <input name="headers_json" /></label>
|
||||||
|
<div class="span2 actions">
|
||||||
|
<button type="submit" value="save">Save</button>
|
||||||
|
<button type="button" id="edit-cancel" class="ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue