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