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.
284 lines
7.4 KiB
Go
284 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|