// appproxy — on-device HTTPS MITM proxy that logs DASH/HLS manifests + DRM // fields to a JSON file (same shape as watchhttp.py / from_har.py). // // Cross-compile (host): // // GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o appproxy-android-arm64 . // // On device (example): // // adb push appproxy-android-arm64 /data/local/tmp/appproxy // adb shell chmod 755 /data/local/tmp/appproxy // adb shell /data/local/tmp/appproxy -listen :8080 -out /data/local/tmp/appproxy_cap.json // adb pull /data/local/tmp/appproxy-ca.crt # install as CA on the phone package main import ( "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "flag" "fmt" "io" "log" "math/big" "net" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/elazarl/goproxy" ) type capture struct { MPD string `json:"mpd,omitempty"` LicenseURL string `json:"license_url,omitempty"` Auth string `json:"auth,omitempty"` PID string `json:"pid,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` PSSH string `json:"pssh,omitempty"` // first short Widevine PSSH seen in an MPD body } type licenseBody struct { GetWidevineLicense struct { ReleasePID string `json:"releasePid"` WidevineChallenge string `json:"widevineChallenge"` } `json:"getWidevineLicense"` } func main() { listen := flag.String("listen", ":8080", "proxy listen address (transparent public port when -transparent)") outPath := flag.String("out", defaultOutPath(), "JSON capture output path (structured mpd/license)") outDir := flag.String("out-dir", "", "if set, write cap.json + traffic.jsonl (+ log) into this directory") caDir := flag.String("ca-dir", defaultCADir(), "directory for CA cert/key (created if missing)") dnsServers := flag.String("dns", "", "comma-separated DNS servers (default: device DNS or 1.1.1.1)") verbose := flag.Bool("v", true, "log matched exchanges to stderr") logAll := flag.Bool("log-all", false, "log every HTTP(S) request (discover mode for unknown apps)") trafficPath := flag.String("traffic", defaultTrafficPath(), "JSONL traffic log path (used with -log-all)") transparent := flag.Bool("transparent", false, "iptables-REDIRECT mode: no Wi‑Fi http_proxy; works with UK VPN") mitmInternal := flag.String("mitm-internal", "127.0.0.1:18080", "internal MITM listen addr used by -transparent") passAll := flag.Bool("passthrough-all", false, "splice every host without MITM (diagnose redirect/VPN vs MITM breakage)") // Provider-specific needles are flags, not constants: this binary runs on the // phone and cannot read an app module's config. proxyctl passes them through. flag.Var(&matchNeedles, "match", "extra host/URL substring that marks an exchange interesting (repeatable)") flag.Var(&scoreHosts, "score-host", "host that scores as a known manifest origin (repeatable)") flag.Var(&scoreDeny, "score-deny", "host/URL substring that is never a manifest (repeatable)") flag.Var(&mitmHosts, "mitm-host", "force MITM for this host (overrides BBC/CDN passthrough; repeatable)") flag.Parse() passthroughAll = *passAll passthroughDefault = *transparent // transparent: MITM only open.live / -mitm-host forceMITM = forceMITM[:0] for _, h := range mitmHosts { forceMITM = append(forceMITM, strings.ToLower(strings.TrimSpace(h))) } if dir := strings.TrimSpace(*outDir); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { log.Fatalf("out-dir: %v", err) } *outPath = filepath.Join(dir, "cap.json") *trafficPath = filepath.Join(dir, "traffic.jsonl") logFile := filepath.Join(dir, "appproxy.log") f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err == nil { log.SetOutput(io.MultiWriter(os.Stderr, f)) } log.Printf("out-dir → %s", dir) } caCertPath := filepath.Join(*caDir, caCertName) caKeyPath := filepath.Join(*caDir, caKeyName) // Older devices were provisioned with the previous CA file names; keep using // those if they are already on the phone so a reinject is not required. if _, err := os.Stat(caCertPath); err != nil { if _, e := os.Stat(filepath.Join(*caDir, legacyCACertName)); e == nil { caCertPath = filepath.Join(*caDir, legacyCACertName) caKeyPath = filepath.Join(*caDir, legacyCAKeyName) } } if err := os.MkdirAll(*caDir, 0o755); err != nil { log.Fatalf("ca-dir: %v", err) } tlsCert, err := loadOrCreateCA(caCertPath, caKeyPath) if err != nil { log.Fatalf("CA: %v", err) } log.Printf("CA cert: %s (install this on the device as a trusted CA)", caCertPath) log.Printf("capture → %s", *outPath) if *logAll { log.Printf("discover → %s (-log-all: every request)", *trafficPath) _ = os.Remove(*trafficPath) // fresh session } resolver := newResolver(*dnsServers) log.Printf("DNS → %v", resolver.addrs) baseDialer := &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Resolver: resolver.std, } // Dial upstream by hostname, but never follow AdAway/hosts sinkholes // (127.0.0.1) — re-resolve via plain DNS when /etc/hosts poisoned the name. dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf("dial addr %s: %w", addr, err) } if ip := net.ParseIP(host); ip != nil { if ip.IsLoopback() || ip.IsUnspecified() { return nil, fmt.Errorf("refusing loopback upstream dial %s (Wi‑Fi proxy must be device LAN IP; also disable VPN during capture)", addr) } return baseDialer.DialContext(ctx, network, addr) } ips, err := resolver.lookupIPs(ctx, host) if err != nil { return nil, fmt.Errorf("resolve %s: %w", host, err) } var last error for _, ip := range ips { if ip.IsLoopback() || ip.IsUnspecified() { continue } target := net.JoinHostPort(ip.String(), port) c, err := baseDialer.DialContext(ctx, network, target) if err == nil { return c, nil } last = err } if last == nil { last = fmt.Errorf("all resolved IPs for %s were loopback/unusable (AdAway hosts or VPN fake-ip?)", host) } return nil, last } // dialPreferOrig uses NordVPN/app SO_ORIGINAL_DST when recorded for this host // so BBC geolocation sees the same path as the app (fake-IP → UK tunnel). dialPreferOrig := func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err == nil { if v, ok := origDstByHost.Load(strings.ToLower(host)); ok { od := v.(string) if od != "" && od != addr { log.Printf("[DIAL] %s → orig-dst %s", addr, od) return dialContext(ctx, network, od) } } _ = port } return dialContext(ctx, network, addr) } transport := &http.Transport{ Proxy: nil, // never chain through another proxy / env DialContext: dialPreferOrig, // Chrome-like upstream TLS — BBC Fastly often 403s Go's default JA3. DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { c, err := dialTLSChrome(ctx, dialPreferOrig, network, addr) if err != nil { log.Printf("[TLS] utls failed for %s: %v — std fallback", addr, err) return dialTLSStd(ctx, dialPreferOrig, network, addr) } return c, nil }, ForceAttemptHTTP2: false, // utls conn is HTTP/1.1-only MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 15 * time.Second, } cap := &captureStore{path: *outPath} traffic := &trafficLog{path: *trafficPath, enabled: *logAll} proxy := goproxy.NewProxyHttpServer() proxy.Verbose = false proxy.Tr = transport // goproxy also dials CONNECT targets via its own transport; keep them aligned. proxy.ConnectDial = func(network, addr string) (net.Conn, error) { return dialPreferOrig(context.Background(), network, addr) } proxy.ConnectDialWithReq = func(req *http.Request, network, addr string) (net.Conn, error) { // Prefer the CONNECT / request host when addr collapsed to loopback. if host, port, err := net.SplitHostPort(addr); err == nil && hostIsLoopback(host) { if rh := requestHost(req); rh != "" && !hostIsLoopback(rh) { addr = net.JoinHostPort(stripHostPort(rh), port) log.Printf("[FIX] CONNECT dial %s → %s", host, addr) } } if req != nil { if od := req.Header.Get("X-Appproxy-Orig-Dst"); od != "" { rememberOrigDst(addr, od) } } return dialPreferOrig(context.Background(), network, addr) } // MITM all CONNECT tunnels so we can read HTTPS bodies. customCA := &goproxy.ConnectAction{ Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(tlsCert), } proxy.OnRequest().HandleConnect(goproxy.FuncHttpsHandler(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { ud := map[string]string{"connect_host": host} if ctx.Req != nil { if od := ctx.Req.Header.Get("X-Appproxy-Orig-Dst"); od != "" { ud["orig_dst"] = od rememberOrigDst(host, od) log.Printf("[CONNECT] %s (orig-dst %s)", host, od) } } ctx.UserData = ud if ud["orig_dst"] == "" && (*logAll || interesting(host, host)) { log.Printf("[CONNECT] %s", host) } if hostIsLoopback(stripHostPort(host)) { log.Printf("[WARN] CONNECT to loopback %s — client likely using 127.0.0.1 as Wi‑Fi proxy", host) } return customCA, host })) proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { // After MITM, some Android stacks put the proxy address (127.0.0.1) in // Host / URL. Restore the CONNECT target so upstream dials the real site. if ch := connectHostFromCtx(ctx); ch != "" { fixLoopbackHost(req, ch) } url := req.URL.String() if req.URL.Scheme == "" { // After MITM, URL may be relative; rebuild. scheme := "https" if req.TLS == nil && ctx.Req != nil && ctx.Req.URL != nil && ctx.Req.URL.Scheme != "" { scheme = ctx.Req.URL.Scheme } host := req.Host if host == "" && req.URL.Host != "" { host = req.URL.Host } url = scheme + "://" + host + req.URL.RequestURI() } host := req.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } kind := classifyURL(req.Method, host, url) if (*logAll || interesting(host, url) || kind != "other") && *verbose { tag := "REQ" switch kind { case "mpd": tag = "MPD" case "license": tag = "LIC" case "manifest": tag = "MAN" } log.Printf("[%s] %s %s", tag, req.Method, trim(url, 220)) } if *logAll { rec := map[string]any{ "ts": time.Now().Format(time.RFC3339), "phase": "req", "kind": kind, "method": req.Method, "host": host, "url": url, } if auth := req.Header.Get("Authorization"); auth != "" && (kind == "license" || kind == "other") { rec["authorization"] = trim(auth, 120) } if ct := req.Header.Get("Content-Type"); ct != "" { rec["req_content_type"] = ct } traffic.write(rec) } // Live / DASH MPD (any host — RTE sometimes redirects via dai.google.com etc.) if looksLikeMPD(url) { cap.set(func(c *capture) { c.MPD = preferMPD(c.MPD, url) }) if *verbose { log.Printf("[MPD] %s", url) } } // ModularDrm license (RTE) + generic license-looking POSTs for discover isLicHost := strings.Contains(host, "widevine.entitlement") || looksLikeLicense(req.Method, host, url) if isLicHost && req.Method == http.MethodPost { auth := req.Header.Get("Authorization") body, err := io.ReadAll(req.Body) if err == nil { req.Body.Close() req.Body = io.NopCloser(strings.NewReader(string(body))) } pid := "" var lb licenseBody if json.Unmarshal(body, &lb) == nil { pid = lb.GetWidevineLicense.ReleasePID } cap.set(func(c *capture) { c.LicenseURL = url if auth != "" { c.Auth = auth } if pid != "" { c.PID = pid } }) if *verbose { log.Printf("[LIC] pid=%s auth=%s", pid, trim(auth, 40)) log.Printf(" %s", trim(url, 120)) } if *logAll { traffic.write(map[string]any{ "ts": time.Now().Format(time.RFC3339), "phase": "license", "kind": "license", "method": req.Method, "host": host, "url": url, "authorization": trim(auth, 200), "pid": pid, "body_len": len(body), "body_head": trim(string(body), 240), }) } } return req, nil }) proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { if resp == nil || resp.Request == nil { return resp } url := resp.Request.URL.String() host := resp.Request.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } ct := resp.Header.Get("Content-Type") isMPD := looksLikeMPD(url) || strings.Contains(ct, "dash+xml") || strings.Contains(ct, "application/mpd") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") || hostIsManifestOrigin(host) if *logAll { kind := classifyURL(resp.Request.Method, host, url) if isMPD { kind = "mpd" } rec := map[string]any{ "ts": time.Now().Format(time.RFC3339), "phase": "resp", "kind": kind, "method": resp.Request.Method, "host": host, "url": url, "status": resp.StatusCode, "resp_content_type": ct, } if kind != "other" || isMPD { traffic.write(rec) } else if *logAll { // still record non-other; for pure other skip body-heavy spam unless status interesting traffic.write(rec) } } // Surface mediaselector / license / non-2xx so play failures are obvious in appproxy.log. if resp.StatusCode >= 400 || strings.Contains(url, "mediaselector") || interesting(host, url) { log.Printf("[RSP] %d %s %s", resp.StatusCode, resp.Request.Method, trim(url, 180)) } isMediaSelector := strings.Contains(url, "mediaselector") if (isMediaSelector || isMPD) && resp.Body != nil { body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { resp.Body = io.NopCloser(strings.NewReader("")) return resp } resp.Body = io.NopCloser(strings.NewReader(string(body))) if isMediaSelector { captureMediaSelector(body, url, *outPath, cap, traffic, *logAll, *verbose) } if isMPD { if pssh := firstShortPSSH(string(body)); pssh != "" { reqURL := url if reqURL == "" || !strings.Contains(reqURL, "://") { reqURL = "https://" + host + resp.Request.URL.RequestURI() } cap.set(func(c *capture) { c.PSSH = pssh if looksLikeMPD(reqURL) { c.MPD = preferMPD(c.MPD, reqURL) } }) if *verbose { log.Printf("[PSSH] %s", trim(pssh, 80)) if looksLikeMPD(reqURL) { log.Printf("[MPD] %s", reqURL) } } if *logAll { traffic.write(map[string]any{ "ts": time.Now().Format(time.RFC3339), "phase": "pssh", "kind": "mpd", "host": host, "url": reqURL, "pssh": trim(pssh, 200), "body_len": len(body), }) } } } return resp } return resp }) if *transparent { mitmLn, err := net.Listen("tcp", *mitmInternal) if err != nil { log.Fatalf("mitm-internal listen %s: %v", *mitmInternal, err) } go func() { log.Printf("MITM (internal) on %s", mitmLn.Addr()) log.Fatal(http.Serve(mitmLn, proxy)) }() pubLn, err := net.Listen("tcp", *listen) if err != nil { log.Fatalf("transparent listen %s: %v", *listen, err) } log.Printf("transparent on %s — use iptables REDIRECT (no Wi‑Fi http_proxy; VPN OK)", pubLn.Addr()) serveTransparentAccept(pubLn, *mitmInternal, func(network, addr string) (net.Conn, error) { return dialContext(context.Background(), network, addr) }) return } ln, err := net.Listen("tcp", *listen) if err != nil { log.Fatalf("listen %s: %v", *listen, err) } log.Printf("listening on %s (set Wi‑Fi HTTP proxy to this device IP%s)", ln.Addr(), portSuffix(*listen)) log.Fatal(http.Serve(ln, proxy)) } type dnsResolver struct { addrs []string std *net.Resolver } func newResolver(flagDNS string) *dnsResolver { var addrs []string if flagDNS != "" { for _, p := range strings.Split(flagDNS, ",") { p = strings.TrimSpace(p) if p != "" { addrs = append(addrs, p) } } } if len(addrs) == 0 { addrs = detectDeviceDNS() } // Drop VPN/fake DNS (CGNAT 100.64/10, etc.) — prefer public resolvers for MITM upstream. cleaned := make([]string, 0, len(addrs)) for _, a := range addrs { host := a if h, _, err := net.SplitHostPort(a); err == nil { host = h } if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || isCGNAT(ip)) { continue } cleaned = append(cleaned, a) } addrs = cleaned if len(addrs) == 0 { addrs = []string{"1.1.1.1", "1.0.0.1", "8.8.8.8"} } // Ensure host:port form for Dial for i, a := range addrs { if !strings.Contains(a, ":") { addrs[i] = net.JoinHostPort(a, "53") } } r := &dnsResolver{addrs: addrs} r.std = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { var last error d := net.Dialer{Timeout: 3 * time.Second} for _, server := range r.addrs { conn, err := d.DialContext(ctx, "udp", server) if err == nil { return conn, nil } last = err } return nil, last }, } return r } func isCGNAT(ip net.IP) bool { ip4 := ip.To4() if ip4 == nil { return false } // 100.64.0.0/10 used by NordVPN / carrier-grade NAT DNS return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 } // lookupIPs resolves host for upstream dials. Go's resolver honors /etc/hosts // (AdAway -> 127.0.0.1); when that happens we fall back to a raw DNS A query. func (r *dnsResolver) lookupIPs(ctx context.Context, host string) ([]net.IP, error) { addrs, err := r.std.LookupIPAddr(ctx, host) out := make([]net.IP, 0, len(addrs)) for _, a := range addrs { if a.IP.IsLoopback() || a.IP.IsUnspecified() { continue } out = append(out, a.IP) } if len(out) > 0 { return out, nil } raw, rawErr := r.lookupIPRaw(ctx, host) if len(raw) > 0 { log.Printf("[DNS] %s: ignored hosts sinkhole, using %v", host, raw) return raw, nil } if err != nil { return nil, err } if rawErr != nil { return nil, rawErr } return nil, fmt.Errorf("no usable IPs for %s", host) } func (r *dnsResolver) lookupIPRaw(ctx context.Context, host string) ([]net.IP, error) { var last error d := &net.Dialer{Timeout: 3 * time.Second} for _, server := range r.addrs { ips, err := dnsQueryA(ctx, d, server, host) if err != nil { last = err continue } found := make([]net.IP, 0, len(ips)) for _, ip := range ips { if !ip.IsLoopback() && !ip.IsUnspecified() { found = append(found, ip) } } if len(found) > 0 { return found, nil } } return nil, last } // dnsQueryA is a minimal DNS A lookup that never reads /etc/hosts. func dnsQueryA(ctx context.Context, d *net.Dialer, server, host string) ([]net.IP, error) { conn, err := d.DialContext(ctx, "udp", server) if err != nil { return nil, err } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) id := uint16(time.Now().UnixNano()) msg := buildDNSQuery(id, host) if _, err := conn.Write(msg); err != nil { return nil, err } buf := make([]byte, 512) n, err := conn.Read(buf) if err != nil { return nil, err } return parseDNSAnswersA(buf[:n], id) } func buildDNSQuery(id uint16, host string) []byte { b := make([]byte, 0, 64) b = append(b, byte(id>>8), byte(id)) b = append(b, 0x01, 0x00) // RD b = append(b, 0x00, 0x01) // QDCOUNT b = append(b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") { if label == "" { continue } b = append(b, byte(len(label))) b = append(b, label...) } b = append(b, 0x00) b = append(b, 0x00, 0x01) // TYPE A b = append(b, 0x00, 0x01) // CLASS IN return b } func parseDNSAnswersA(msg []byte, wantID uint16) ([]net.IP, error) { if len(msg) < 12 { return nil, fmt.Errorf("short DNS response") } id := uint16(msg[0])<<8 | uint16(msg[1]) if id != wantID { return nil, fmt.Errorf("DNS id mismatch") } qd := int(msg[4])<<8 | int(msg[5]) an := int(msg[6])<<8 | int(msg[7]) i := 12 for q := 0; q < qd && i < len(msg); q++ { for i < len(msg) && msg[i] != 0 { if msg[i]&0xC0 == 0xC0 { i += 2 break } i += 1 + int(msg[i]) } if i < len(msg) && msg[i] == 0 { i++ } i += 4 } var ips []net.IP for a := 0; a < an && i+10 <= len(msg); a++ { if msg[i]&0xC0 == 0xC0 { i += 2 } else { for i < len(msg) && msg[i] != 0 { i += 1 + int(msg[i]) } i++ } if i+10 > len(msg) { break } typ := int(msg[i])<<8 | int(msg[i+1]) rdlen := int(msg[i+8])<<8 | int(msg[i+9]) i += 10 if i+rdlen > len(msg) { break } if typ == 1 && rdlen == 4 { ips = append(ips, net.IPv4(msg[i], msg[i+1], msg[i+2], msg[i+3])) } i += rdlen } if len(ips) == 0 { return nil, fmt.Errorf("no A records") } return ips, nil } func detectDeviceDNS() []string { // Prefer getprop / dumpsys-style values when available. out, err := exec.Command("getprop").CombinedOutput() if err == nil { var found []string for _, line := range strings.Split(string(out), "\n") { // [net.dns1]: [1.1.1.1] or dhcp.*.dns* if !strings.Contains(line, "dns") { continue } start := strings.Index(line, "[") end := strings.LastIndex(line, "]") if start < 0 || end <= start { continue } // value is last [...] rest := line[start+1 : end] if i := strings.LastIndex(rest, "["); i >= 0 { rest = rest[i+1:] } rest = strings.TrimSpace(rest) if net.ParseIP(rest) != nil { found = append(found, rest) } } if len(found) > 0 { return unique(found) } } return nil } func unique(in []string) []string { seen := map[string]bool{} var out []string for _, s := range in { if !seen[s] { seen[s] = true out = append(out, s) } } return out } func portSuffix(listen string) string { _, port, err := net.SplitHostPort(listen) if err != nil { if strings.HasPrefix(listen, ":") { return listen } return "" } return ":" + port } // origDstByHost maps lowercase hostname → "ip:port" from transparent SO_ORIGINAL_DST // so upstream dials keep NordVPN fake-IP / geo routing. var origDstByHost sync.Map func rememberOrigDst(hostOrAddr, origDst string) { h := strings.ToLower(stripHostPort(hostOrAddr)) if h == "" || origDst == "" { return } origDstByHost.Store(h, origDst) } // captureMediaSelector saves BBC mediaselector JSON and extracts Widevine licence / // media hosts for -mitm-host follow-up. func captureMediaSelector(body []byte, url, outPath string, cap *captureStore, traffic *trafficLog, logAll, verbose bool) { dir := filepath.Dir(outPath) if dir == "" || dir == "." { dir = "/data/local/tmp" } msPath := filepath.Join(dir, "mediaselector.json") if err := os.WriteFile(msPath, body, 0o644); err != nil { log.Printf("[MS] write %s: %v", msPath, err) } else { log.Printf("[MS] saved %s (%d bytes)", msPath, len(body)) } licURLs, mediaURLs, hosts := extractBBCMediaSelector(body) for _, u := range licURLs { log.Printf("[LIC-URL] %s", trim(u, 200)) if cap != nil { cap.set(func(c *capture) { if c.LicenseURL == "" { c.LicenseURL = u } }) } } for _, u := range mediaURLs { if verbose || strings.Contains(strings.ToLower(u), "mpd") || strings.Contains(u, ".mpd") { log.Printf("[MEDIA] %s", trim(u, 200)) } if cap != nil && (strings.Contains(u, ".mpd") || strings.Contains(strings.ToLower(u), "manifest")) { cap.set(func(c *capture) { c.MPD = preferMPD(c.MPD, u) }) } } for _, h := range hosts { log.Printf("[MS-HOST] %s", h) } if logAll && traffic != nil { traffic.write(map[string]any{ "ts": time.Now().Format(time.RFC3339), "phase": "mediaselector", "kind": "mediaselector", "url": url, "body_len": len(body), "license_urls": licURLs, "media_urls": mediaURLs, "hosts": hosts, }) } } func extractBBCMediaSelector(body []byte) (licURLs, mediaURLs, hosts []string) { s := string(body) // Licence / DRM endpoints (Widevine laurl, authorisationService, etc.) licNeedles := []string{ `"https://`, `"http://`, } seenU := map[string]bool{} seenH := map[string]bool{} addURL := func(u string, license bool) { u = strings.TrimSpace(u) u = strings.Trim(u, `"'`) if u == "" || seenU[u] { return } seenU[u] = true if license { licURLs = append(licURLs, u) } else { mediaURLs = append(mediaURLs, u) } if hp, err := parseURLHost(u); err == nil && hp != "" && !seenH[hp] { seenH[hp] = true hosts = append(hosts, hp) } } // Walk JSON-ish strings for http(s) URLs. for i := 0; i < len(s); { j := strings.Index(s[i:], "https://") k := strings.Index(s[i:], "http://") off := -1 if j >= 0 && (k < 0 || j < k) { off = i + j } else if k >= 0 { off = i + k } if off < 0 { break } end := off for end < len(s) { c := s[end] if c == '"' || c == '\'' || c == ' ' || c == '\n' || c == '\r' || c == '<' || c == '>' { break } end++ } u := s[off:end] u = strings.TrimRight(u, `\,)]}`) low := strings.ToLower(u) isLic := strings.Contains(low, "license") || strings.Contains(low, "licence") || strings.Contains(low, "widevine") || strings.Contains(low, "drm") || strings.Contains(low, "authorisation") || strings.Contains(low, "authorization") || strings.Contains(low, "/la/") || strings.Contains(low, "laurl") addURL(u, isLic) i = end + 1 } _ = licNeedles return licURLs, mediaURLs, hosts } func parseURLHost(raw string) (string, error) { if !strings.Contains(raw, "://") { raw = "https://" + raw } // tiny parse without importing net/url cycle issues — use net/url u, err := httpParseHost(raw) return u, err } func httpParseHost(raw string) (string, error) { // local helper using strings to avoid extra deps in hot path rest := raw if i := strings.Index(rest, "://"); i >= 0 { rest = rest[i+3:] } if i := strings.IndexAny(rest, "/?#"); i >= 0 { rest = rest[:i] } rest = stripHostPort(rest) if rest == "" { return "", fmt.Errorf("no host") } return rest, nil } func hostIsLoopback(host string) bool { host = strings.TrimSpace(strings.Trim(host, "[]")) if host == "" { return false } if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "0.0.0.0" { return true } if ip := net.ParseIP(host); ip != nil { return ip.IsLoopback() || ip.IsUnspecified() } return false } func stripHostPort(hostport string) string { hostport = strings.TrimSpace(hostport) if h, _, err := net.SplitHostPort(hostport); err == nil { return h } return strings.Trim(hostport, "[]") } func requestHost(req *http.Request) string { if req == nil { return "" } if req.Host != "" { return req.Host } if req.URL != nil && req.URL.Host != "" { return req.URL.Host } return "" } func connectHostFromCtx(ctx *goproxy.ProxyCtx) string { if ctx == nil { return "" } if m, ok := ctx.UserData.(map[string]string); ok { return m["connect_host"] } if s, ok := ctx.UserData.(string); ok { return s } return "" } // fixLoopbackHost rewrites req Host/URL when they collapsed to the proxy // loopback address, using the original CONNECT target instead. func fixLoopbackHost(req *http.Request, connectHost string) { if req == nil || connectHost == "" { return } realHost := stripHostPort(connectHost) if realHost == "" || hostIsLoopback(realHost) { return } port := "443" if _, p, err := net.SplitHostPort(connectHost); err == nil && p != "" { port = p } need := false if hostIsLoopback(stripHostPort(req.Host)) || req.Host == "" { need = true } if req.URL != nil && (hostIsLoopback(stripHostPort(req.URL.Host)) || req.URL.Host == "") { need = true } if !need { return } log.Printf("[FIX] loopback Host %q → %s", req.Host, realHost) req.Host = realHost if req.URL != nil { req.URL.Host = net.JoinHostPort(realHost, port) if req.URL.Scheme == "" { req.URL.Scheme = "https" } } } func defaultOutPath() string { // Prefer /data/local/tmp — always writable by the shell user that runs the proxy. // /sdcard/Download is often blocked by scoped storage for the shell uid. for _, p := range []string{ "/data/local/tmp/appproxy_cap.json", // Legacy names, still read by hosts provisioned before the rename. "/data/local/tmp/rte_cap.json", "/sdcard/Download/rte_cap.json", "/storage/emulated/0/Download/rte_cap.json", } { dir := filepath.Dir(p) if st, err := os.Stat(dir); err == nil && st.IsDir() { return p } } return "appproxy_cap.json" } func defaultTrafficPath() string { return "/data/local/tmp/appproxy_traffic.jsonl" } // formatNeedles mark DRM/manifest traffic for any provider. var formatNeedles = []string{ "widevine", ".mpd", ".isml", ".m3u8", "manifest", "license", "licence", "drm", "cenc", "getlicense", "acquirelicense", } func interesting(host, url string) bool { h := strings.ToLower(host + " " + url) for _, needle := range formatNeedles { if strings.Contains(h, needle) { return true } } for _, needle := range matchNeedles { if needle != "" && strings.Contains(h, strings.ToLower(needle)) { return true } } for _, needle := range scoreHosts { if needle != "" && strings.Contains(h, strings.ToLower(needle)) { return true } } return false } // hostIsManifestOrigin reports whether a host was named with --score-host. func hostIsManifestOrigin(host string) bool { h := strings.ToLower(host) for _, needle := range scoreHosts { if needle != "" && strings.Contains(h, strings.ToLower(needle)) { return true } } return false } func looksLikeMPD(url string) bool { u := strings.ToLower(url) path := u if i := strings.Index(path, "?"); i >= 0 { path = path[:i] } // Match on path only — metrics trackers embed ".m3u8" in query strings. return strings.HasSuffix(path, ".mpd") || strings.Contains(path, "manifest.mpd") || strings.HasSuffix(path, ".m3u8") || strings.Contains(path, "playlist-hls") } func looksLikeLicense(method, host, url string) bool { if !strings.EqualFold(method, http.MethodPost) { return false } h := strings.ToLower(host + " " + url) for _, needle := range []string{ "license", "licence", "widevine", "/wv/", "getlicense", "acquirelicense", "drm", "cenc", "ceneckey", "modular drm", "modulardrm", } { if strings.Contains(h, needle) { return true } } return false } func classifyURL(method, host, url string) string { if looksLikeMPD(url) { return "mpd" } u := strings.ToLower(url) if strings.Contains(u, ".isml") || strings.Contains(u, "manifest") || strings.Contains(u, ".m3u8") { return "manifest" } if looksLikeLicense(method, host, url) || strings.Contains(strings.ToLower(host), "widevine") { return "license" } return "other" } type trafficLog struct { mu sync.Mutex path string enabled bool } func (t *trafficLog) write(rec map[string]any) { if t == nil || !t.enabled || t.path == "" { return } t.mu.Lock() defer t.mu.Unlock() raw, err := json.Marshal(rec) if err != nil { return } f, err := os.OpenFile(t.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { log.Printf("traffic log: %v", err) return } _, _ = f.Write(append(raw, '\n')) _ = f.Close() } func preferMPD(current, next string) string { if next == "" { return current } // Reject EPG/schedule/analytics URLs (score <= 0). if manifestScore(next) <= 0 { return current } if current == "" { return next } if manifestScore(next) >= manifestScore(current) { return next } return current } func manifestScore(u string) int { lu := strings.ToLower(u) path := lu if i := strings.Index(path, "?"); i >= 0 { path = path[:i] } for _, bad := range denyNeedles() { if strings.Contains(lu, bad) { return -100 } } s := 0 if hostIsManifestOrigin(lu) { s += 50 } if strings.Contains(path, "playlist-hls") || strings.Contains(path, "playlist.m3u8") { s += 40 } if strings.Contains(path, "chunklist") { s += 10 } if strings.HasSuffix(path, ".m3u8") { s += 5 } if strings.HasSuffix(path, ".mpd") || strings.Contains(path, "manifest.mpd") { s += 30 } if strings.Contains(path, ".isml") || strings.Contains(path, "/manifest") { s += 20 } return s } func defaultCADir() string { if exe, err := os.Executable(); err == nil { return filepath.Dir(exe) } return "." } type captureStore struct { mu sync.Mutex path string } func (s *captureStore) set(mut func(*capture)) { s.mu.Lock() defer s.mu.Unlock() c := capture{} if raw, err := os.ReadFile(s.path); err == nil { _ = json.Unmarshal(raw, &c) } mut(&c) c.UpdatedAt = time.Now().Format(time.RFC3339) raw, err := json.MarshalIndent(c, "", " ") if err != nil { log.Printf("marshal: %v", err) return } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { // fallback: write direct if err2 := os.WriteFile(s.path, append(raw, '\n'), 0o644); err2 != nil { log.Printf("write %s: %v", s.path, err2) } return } if err := os.Rename(tmp, s.path); err != nil { _ = os.WriteFile(s.path, append(raw, '\n'), 0o644) } } func trim(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } // Prefer the short Widevine PSSH (AAAASnBzc2g…) over the giant PlayReady blob. func firstShortPSSH(mpd string) string { const marker = "AAAASnBzc2g" idx := strings.Index(mpd, marker) if idx < 0 { // any cenc:pssh content const open = "") k := strings.Index(mpd[i:], "") if j < 0 || k < 0 || k <= j { return "" } return strings.TrimSpace(mpd[i+j+1 : i+k]) } end := idx for end < len(mpd) { c := mpd[end] if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' { end++ continue } break } return mpd[idx:end] } func loadOrCreateCA(certPath, keyPath string) (*tls.Certificate, error) { if _, err := os.Stat(certPath); err == nil { if _, err := os.Stat(keyPath); err == nil { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return nil, err } if len(cert.Certificate) > 0 { leaf, err := x509.ParseCertificate(cert.Certificate[0]) if err == nil { cert.Leaf = leaf } } return &cert, nil } } key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, err } tmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ Organization: []string{caOrganization}, CommonName: caCommonName, }, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, IsCA: true, MaxPathLen: 1, } der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { return nil, err } certOut, err := os.Create(certPath) if err != nil { return nil, err } if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { certOut.Close() return nil, err } certOut.Close() keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return nil, err } if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil { keyOut.Close() return nil, err } keyOut.Close() leaf, err := x509.ParseCertificate(der) if err != nil { return nil, err } return &tls.Certificate{ Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf, }, nil }