Null-DRM-Official/apps/proxy/device/main.go
404errordeveloper 2fa8f2435f 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.
2026-10-06 00:25:35 +02:00

1310 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = "<cenc:pssh"
i := strings.Index(mpd, open)
if i < 0 {
return ""
}
j := strings.Index(mpd[i:], ">")
k := strings.Index(mpd[i:], "</cenc:pssh>")
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
}