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
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue