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.
468 lines
14 KiB
Go
468 lines
14 KiB
Go
// proxyctl — host-side CLI for the on-device HTTPS MITM (appproxy).
|
||
//
|
||
// Build the android binary, push it, install/reinject the CA, start/stop the
|
||
// proxy, run discover mode, and pull captures into outputs/discover/<stamp>.
|
||
// Package proxyctlcmd is the on-device MITM host CLI, exposed as a library so the
|
||
// single drm binary can host it as a subcommand.
|
||
package proxyctlcmd
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"drmdecryption/adb"
|
||
"drmdecryption/proxy"
|
||
"drmdecryption/repo"
|
||
)
|
||
|
||
// Run dispatches a proxy subcommand. args[0] is the subcommand name.
|
||
func Run(args []string) error {
|
||
if len(args) < 1 {
|
||
Usage()
|
||
return fmt.Errorf("proxy: subcommand required")
|
||
}
|
||
sub, rest := args[0], args[1:]
|
||
switch sub {
|
||
case "build":
|
||
buildCmd(rest)
|
||
case "push":
|
||
pushCmd(rest)
|
||
case "install-ca":
|
||
installCACmd(rest)
|
||
case "reinject-ca":
|
||
reinjectCACmd(rest)
|
||
case "start":
|
||
startCmd(rest)
|
||
case "stop":
|
||
stopCmd(rest)
|
||
case "discover":
|
||
discoverCmd(rest)
|
||
case "pull":
|
||
pullCmd(rest)
|
||
case "clear-proxy":
|
||
clearProxyCmd(rest)
|
||
case "transparent", "tproxy":
|
||
transparentCmd(rest)
|
||
case "help", "-h", "--help":
|
||
Usage()
|
||
default:
|
||
Usage()
|
||
return fmt.Errorf("proxy: unknown subcommand %q", sub)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Usage prints the proxy subcommand help.
|
||
func Usage() {
|
||
fmt.Fprintf(os.Stderr, `proxyctl — on-device MITM (appproxy) host control
|
||
|
||
Usage:
|
||
proxyctl build cross-compile linux/arm64 → bin/ + apps/proxy/
|
||
proxyctl push [--serial S] push binary to /data/local/tmp/appproxy
|
||
proxyctl install-ca [--serial S] [--ca PATH] [--reinject]
|
||
push CA + HASH.0; optional Magisk reinject
|
||
proxyctl reinject-ca [--serial S] [--hash HASH]
|
||
Magisk conscrypt bind only (CA already on device)
|
||
proxyctl start [--serial S] [--bin PATH] [--install-ca] [--reinject]
|
||
stop old → push → start → set http_proxy
|
||
proxyctl stop [--serial S] kill the on-device proxy + clear http_proxy
|
||
proxyctl discover [--serial S] [--out DIR] [--skip-build] [--install-ca] [--reinject]
|
||
-log-all session; Ctrl+C → outputs/discover/<stamp>
|
||
proxyctl pull [--serial S] [--out DIR]
|
||
pull traffic/cap/log into outputs/discover/<stamp>
|
||
proxyctl clear-proxy [--serial S] clear global http_proxy (+ stop mitm)
|
||
proxyctl transparent --package PKG [--serial S] [--out DIR] [--reinject]
|
||
iptables REDIRECT capture (UK VPN OK; no Wi‑Fi proxy)
|
||
writes /data/local/tmp/capture/<pkg>/ ; adb-pulled to --out
|
||
|
||
Artifacts land under outputs/discover/<timestamp>/ by default.
|
||
Transparent captures pull into outputs/transparent/<stamp>/ by default.
|
||
`)
|
||
}
|
||
|
||
func clientFrom(fs *flag.FlagSet, args []string) *adb.Client {
|
||
serial := fs.String("serial", "", "adb device serial")
|
||
_ = fs.Parse(args)
|
||
c := adb.New()
|
||
if *serial != "" {
|
||
c = c.WithSerial(*serial)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func buildCmd(args []string) {
|
||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||
_ = fs.Parse(args)
|
||
root := repo.Root()
|
||
deviceDir := filepath.Join(root, "apps", "proxy", "device")
|
||
outLocal := filepath.Join(root, "apps", "proxy", "proxy-android-arm64")
|
||
outBin := filepath.Join(root, "bin", "proxy-android-arm64")
|
||
|
||
fmt.Println("[*] Building linux/arm64 appproxy...")
|
||
cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", outLocal, ".")
|
||
cmd.Dir = deviceDir
|
||
cmd.Env = append(os.Environ(),
|
||
"GOOS=linux",
|
||
"GOARCH=arm64",
|
||
"CGO_ENABLED=0",
|
||
)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
if err := cmd.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "build failed: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
_ = os.MkdirAll(filepath.Join(root, "bin"), 0o755)
|
||
data, err := os.ReadFile(outLocal)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "read binary: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if err := os.WriteFile(outBin, data, 0o755); err != nil {
|
||
fmt.Fprintf(os.Stderr, "copy to bin/: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
// Compat name next to the canonical one.
|
||
// Legacy copy so hosts that still look for the old name keep working.
|
||
_ = os.WriteFile(filepath.Join(root, "apps", "proxy", "rteproxy-android-arm64"), data, 0o755)
|
||
fmt.Println("[+] apps/proxy/proxy-android-arm64")
|
||
fmt.Println("[+] bin/proxy-android-arm64")
|
||
}
|
||
|
||
func pushCmd(args []string) {
|
||
fs := flag.NewFlagSet("push", flag.ExitOnError)
|
||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
local := *bin
|
||
if local == "" {
|
||
local = proxy.FindLocalBin("")
|
||
}
|
||
if local == "" {
|
||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||
}
|
||
fmt.Printf("[*] Pushing %s → %s\n", local, proxy.RemoteBin)
|
||
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||
fatal(err)
|
||
}
|
||
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||
fmt.Println("[+] pushed")
|
||
}
|
||
|
||
func installCACmd(args []string) {
|
||
fs := flag.NewFlagSet("install-ca", flag.ExitOnError)
|
||
ca := fs.String("ca", "", "local CA PEM (default: the CA pulled from the device)")
|
||
reinject := fs.Bool("reinject", false, "Magisk-reinject into conscrypt after push")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
hash, err := proxy.InstallCA(c, *ca, *reinject)
|
||
if err != nil {
|
||
fatal(err)
|
||
}
|
||
fmt.Printf("[+] CA hash %s installed on device\n", hash)
|
||
}
|
||
|
||
func reinjectCACmd(args []string) {
|
||
fs := flag.NewFlagSet("reinject-ca", flag.ExitOnError)
|
||
hash := fs.String("hash", proxy.DefaultCAHash, "Android CA subject_hash_old")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
proxy.ReinjectCA(c, *hash)
|
||
}
|
||
|
||
func startCmd(args []string) {
|
||
fs := flag.NewFlagSet("start", flag.ExitOnError)
|
||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||
installCA := fs.Bool("install-ca", false, "push CA + HASH.0 before start")
|
||
reinject := fs.Bool("reinject", false, "Magisk-reinject CA (implies -install-ca)")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
if *reinject {
|
||
*installCA = true
|
||
}
|
||
if *installCA {
|
||
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||
fatal(err)
|
||
}
|
||
}
|
||
local := *bin
|
||
if local == "" {
|
||
local = proxy.FindLocalBin("")
|
||
}
|
||
if local == "" {
|
||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||
}
|
||
if err := proxy.PushAndStart(c, local); err != nil {
|
||
fatal(err)
|
||
}
|
||
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||
fatal(err)
|
||
}
|
||
}
|
||
|
||
func stopCmd(args []string) {
|
||
fs := flag.NewFlagSet("stop", flag.ExitOnError)
|
||
c := clientFrom(fs, args)
|
||
proxy.Stop(c)
|
||
proxy.ClearHTTPProxy(c)
|
||
}
|
||
|
||
func clearProxyCmd(args []string) {
|
||
fs := flag.NewFlagSet("clear-proxy", flag.ExitOnError)
|
||
c := clientFrom(fs, args)
|
||
proxy.ClearHTTPProxy(c)
|
||
}
|
||
|
||
func transparentCmd(args []string) {
|
||
fs := flag.NewFlagSet("transparent", flag.ExitOnError)
|
||
pkg := fs.String("package", "", "app package to redirect (e.g. bbc.iplayer.android)")
|
||
out := fs.String("out", "", "host pull dir (default: outputs/transparent/<stamp>)")
|
||
bin := fs.String("bin", "", "local proxy binary (default: auto)")
|
||
reinject := fs.Bool("reinject", false, "Magisk-reinject CA before start (slow; CA usually already mounted)")
|
||
port := fs.String("port", "8080", "transparent listen port on device")
|
||
noTail := fs.Bool("no-tail", false, "start only; do not wait / pull on Ctrl+C")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
if strings.TrimSpace(*pkg) == "" {
|
||
fatal(fmt.Errorf("--package is required (e.g. --package bbc.iplayer.android)"))
|
||
}
|
||
local := *bin
|
||
if local == "" {
|
||
local = proxy.FindLocalBin("")
|
||
}
|
||
if local == "" {
|
||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||
}
|
||
|
||
// Never leave a stale Wi‑Fi proxy when using transparent mode.
|
||
proxy.ClearHTTPProxy(c)
|
||
|
||
remoteDir := "/data/local/tmp/capture/" + *pkg
|
||
fmt.Printf("[*] Transparent capture for %s → %s\n", *pkg, remoteDir)
|
||
if err := proxy.StartTransparent(c, local, *pkg, *port, remoteDir, *reinject); err != nil {
|
||
fatal(err)
|
||
}
|
||
fmt.Println("[+] running. Leave UK VPN ON. Open the app and play.")
|
||
fmt.Println(" Ctrl+C → stop iptables + pull captures")
|
||
if *noTail {
|
||
return
|
||
}
|
||
|
||
dest := *out
|
||
if dest == "" {
|
||
dest = filepath.Join(repo.Root(), "outputs", "transparent", time.Now().Format("20060102-150405"))
|
||
}
|
||
sig := make(chan os.Signal, 1)
|
||
signal.Notify(sig, os.Interrupt)
|
||
<-sig
|
||
fmt.Println("\n[*] Stopping transparent capture...")
|
||
proxy.StopTransparent(c)
|
||
_ = os.MkdirAll(dest, 0o755)
|
||
if err := proxy.PullDir(c, remoteDir, dest); err != nil {
|
||
fmt.Fprintf(os.Stderr, "pull: %v\n", err)
|
||
} else {
|
||
fmt.Println("[+] pulled into", dest)
|
||
}
|
||
}
|
||
|
||
func pullCmd(args []string) {
|
||
fs := flag.NewFlagSet("pull", flag.ExitOnError)
|
||
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||
c := clientFrom(fs, args)
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
dest := *out
|
||
if dest == "" {
|
||
dest = proxy.DiscoverOutDir("", "")
|
||
}
|
||
if err := proxy.PullCaptures(c, dest); err != nil {
|
||
fatal(err)
|
||
}
|
||
fmt.Println("[+] pulled into", dest)
|
||
}
|
||
|
||
func discoverCmd(args []string) {
|
||
fs := flag.NewFlagSet("discover", flag.ExitOnError)
|
||
out := fs.String("out", "", "destination dir (default: outputs/discover/<stamp>)")
|
||
skipBuild := fs.Bool("skip-build", false, "do not rebuild the android binary")
|
||
installCA := fs.Bool("install-ca", true, "push CA + HASH.0 before discover")
|
||
reinject := fs.Bool("reinject", true, "Magisk-reinject CA (default on for discover)")
|
||
noTail := fs.Bool("no-tail", false, "start only; do not tail / wait for Ctrl+C")
|
||
listen := fs.String("listen", ":8080", "proxy listen address on device")
|
||
pkgForce := fs.String("force-stop", "", "package to force-stop after CA reinject (so it inherits the new mount)")
|
||
c := clientFrom(fs, args)
|
||
|
||
root := repo.Root()
|
||
dest := *out
|
||
if dest == "" {
|
||
dest = proxy.DiscoverOutDir(root, "")
|
||
}
|
||
_ = os.MkdirAll(dest, 0o755)
|
||
|
||
if !*skipBuild {
|
||
buildCmd(nil)
|
||
}
|
||
|
||
if err := c.EnsureDevice(); err != nil {
|
||
fatal(err)
|
||
}
|
||
fmt.Println("[*] Device:", c.Out("get-serialno"))
|
||
|
||
local := proxy.FindLocalBin(root)
|
||
if local == "" {
|
||
fatal(fmt.Errorf("no proxy binary — run: proxyctl build"))
|
||
}
|
||
|
||
if *installCA || *reinject {
|
||
if _, err := proxy.InstallCA(c, "", *reinject); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[!] install-ca: %v\n", err)
|
||
}
|
||
}
|
||
if *pkgForce != "" {
|
||
c.ForceStop(*pkgForce)
|
||
fmt.Printf("[*] Force-stopped %s\n", *pkgForce)
|
||
}
|
||
|
||
proxy.Stop(c)
|
||
fmt.Printf("[*] Pushing %s → %s (discover / -log-all)\n", local, proxy.RemoteBin)
|
||
if err := c.Push(local, proxy.RemoteBin); err != nil {
|
||
fatal(err)
|
||
}
|
||
_, _ = c.Shell("chmod", "755", proxy.RemoteBin)
|
||
_, _ = c.Shell("rm", "-f", proxy.RemoteLog, proxy.RemoteCap, proxy.RemoteTraffic)
|
||
|
||
starter := "#!/system/bin/sh\n" +
|
||
"exec " + proxy.RemoteBin +
|
||
" -listen " + *listen +
|
||
" -out " + proxy.RemoteCap +
|
||
" -ca-dir /data/local/tmp" +
|
||
" -dns 1.1.1.1,1.0.0.1,8.8.8.8,192.168.1.1" +
|
||
" -log-all -traffic " + proxy.RemoteTraffic +
|
||
" -v >>" + proxy.RemoteLog + " 2>&1\n"
|
||
tmp := filepath.Join(os.TempDir(), "start_appproxy_discover.sh")
|
||
if err := os.WriteFile(tmp, []byte(starter), 0o755); err != nil {
|
||
fatal(err)
|
||
}
|
||
defer os.Remove(tmp)
|
||
if err := c.Push(tmp, "/data/local/tmp/start_appproxy.sh"); err != nil {
|
||
fatal(err)
|
||
}
|
||
_, _ = c.Shell("chmod", "755", "/data/local/tmp/start_appproxy.sh")
|
||
_, _, _ = c.Run("shell", "setsid /data/local/tmp/start_appproxy.sh </dev/null >/dev/null 2>&1 &")
|
||
|
||
ok := false
|
||
var pid, head string
|
||
for i := 0; i < 12; i++ {
|
||
time.Sleep(400 * time.Millisecond)
|
||
pid = c.Out("shell", "pidof", "appproxy")
|
||
if pid == "" {
|
||
// Legacy process name.
|
||
pid = c.Out("shell", "pidof", "rteproxy")
|
||
}
|
||
head = c.Out("shell", "head", "-20", proxy.RemoteLog)
|
||
if pid != "" && containsListening(head) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if head != "" {
|
||
fmt.Println(head)
|
||
}
|
||
if !ok {
|
||
fmt.Fprintln(os.Stderr, c.Out("shell", "cat", proxy.RemoteLog))
|
||
fatal(fmt.Errorf("appproxy failed to start"))
|
||
}
|
||
fmt.Printf("[+] appproxy pid=%s\n", pid)
|
||
|
||
if err := proxy.EnsureHTTPProxy(c, ""); err != nil {
|
||
fatal(err)
|
||
}
|
||
|
||
fmt.Println()
|
||
fmt.Println("=== Discover mode ready ===")
|
||
fmt.Println("1. Unlock the phone and open the target app.")
|
||
fmt.Println("2. Start playback so DRM + manifest traffic flows.")
|
||
fmt.Println("3. Ctrl+C stops the tail and pulls captures.")
|
||
fmt.Println()
|
||
fmt.Println("Local folder:", dest)
|
||
fmt.Println()
|
||
|
||
if *noTail {
|
||
fmt.Println("Started without tail (-no-tail). Pull later with: proxyctl pull")
|
||
return
|
||
}
|
||
|
||
sig := make(chan os.Signal, 1)
|
||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||
|
||
tailDone := make(chan struct{})
|
||
go func() {
|
||
defer close(tailDone)
|
||
cmd := exec.Command(c.Bin, append(serialArgs(c), "shell", "tail", "-f", proxy.RemoteLog)...)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
_ = cmd.Run()
|
||
}()
|
||
|
||
select {
|
||
case <-sig:
|
||
fmt.Println()
|
||
fmt.Println("[*] Stopping tail...")
|
||
case <-tailDone:
|
||
}
|
||
|
||
fmt.Println("[*] Pulling captures into", dest)
|
||
_ = proxy.PullCaptures(c, dest)
|
||
proxy.ClearHTTPProxy(c)
|
||
fmt.Println("[+] Done. Inspect:")
|
||
entries, _ := os.ReadDir(dest)
|
||
for _, e := range entries {
|
||
info, _ := e.Info()
|
||
size := int64(0)
|
||
if info != nil {
|
||
size = info.Size()
|
||
}
|
||
fmt.Printf(" %s (%d bytes)\n", e.Name(), size)
|
||
}
|
||
tip := filepath.Join(dest, "appproxy_traffic.jsonl")
|
||
if runtime.GOOS == "windows" {
|
||
fmt.Printf("Tip: Select-String -Path '%s' -Pattern 'mpd|license|widevine|manifest'\n", tip)
|
||
} else {
|
||
fmt.Printf("Tip: grep -E 'mpd|license|widevine|manifest' %s\n", tip)
|
||
}
|
||
}
|
||
|
||
func serialArgs(c *adb.Client) []string {
|
||
if c.Serial == "" {
|
||
return nil
|
||
}
|
||
return []string{"-s", c.Serial}
|
||
}
|
||
|
||
func containsListening(s string) bool {
|
||
return strings.Contains(strings.ToLower(s), "listening")
|
||
}
|
||
|
||
func fatal(err error) {
|
||
fmt.Fprintf(os.Stderr, "proxyctl: %v\n", err)
|
||
os.Exit(1)
|
||
}
|