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