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.
1310 lines
36 KiB
Go
1310 lines
36 KiB
Go
// 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
|
||
}
|