Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
63
apps/agent/README.md
Normal file
63
apps/agent/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# agent
|
||||
|
||||
Always-on credentials refresher, hosted by the single binary as `drm agent`.
|
||||
|
||||
```text
|
||||
apps/agent/
|
||||
agentcmd/ the command body, imported by apps/cli
|
||||
internal/ poller, durable queue, device pool, worker, ws client
|
||||
agent.yaml config
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Polls streamd (`GET /api/streams`)
|
||||
2. When an **enabled** stream is down/errored or missing `mpd`/`key`, enqueues a job
|
||||
in a durable SQLite queue under `data_dir`
|
||||
3. Claims a free ADB device that **already has** the app installed — it never installs
|
||||
4. Runs a phone capture through the stream's app module
|
||||
5. `POST /api/streams/:id/credentials`
|
||||
6. Force-stops the app and clears the device proxy so the phone is free
|
||||
|
||||
It never runs a downloader. Claims are kept alive by a websocket heartbeat, so they
|
||||
expire by themselves if the agent dies.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# terminal 1 — control plane
|
||||
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||
|
||||
# terminal 2 — agent
|
||||
export STREAMD_TOKEN=SECRET
|
||||
./bin/drm agent run --config apps/agent/agent.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
./bin/drm agent status
|
||||
./bin/drm agent devices
|
||||
./bin/drm agent enqueue --stream tg4-ioi --reason manual
|
||||
./bin/drm agent cancel --job 3
|
||||
```
|
||||
|
||||
App modules are compiled into the binary, so there is no `modules_dir` to set. The
|
||||
agent drives whichever module a stream names and contains no provider logic.
|
||||
|
||||
## Config
|
||||
|
||||
`agent.yaml` — `streamd_url`, `token` (or `STREAMD_TOKEN`), `poll_interval_sec`,
|
||||
`workers`, `max_attempts`, `capture_wait_sec`, `devices` (empty = any authorised
|
||||
device), `wvd`, `user_agent`, `agent_id`, `data_dir`.
|
||||
|
||||
`wvd` must be set or key fetches fail.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go -C apps/cli build -o ../../bin/drm .
|
||||
|
||||
# cross-compile
|
||||
GOOS=darwin GOARCH=arm64 go -C apps/cli build -o ../../bin/drm .
|
||||
```
|
||||
|
||||
**Full guide: [docs/streamd.md](../../docs/streamd.md)**
|
||||
18
apps/agent/agent.yaml
Normal file
18
apps/agent/agent.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Always-on credentials agent (apps/agent → bin/agent.exe)
|
||||
# Default path: apps/agent/agent.yaml
|
||||
streamd_url: http://127.0.0.1:8083
|
||||
# Must match streamd --token / STREAMD_TOKEN (mutating API: claim, credentials).
|
||||
token: "SECRET"
|
||||
# Stable id across restarts (default = hostname). Required for reclaim after crash.
|
||||
# agent_id: my-agent
|
||||
poll_interval_sec: 30
|
||||
data_dir: .cache/agent
|
||||
workers: 1 # concurrent ADB jobs
|
||||
max_attempts: 5
|
||||
capture_wait_sec: 180
|
||||
devices: [] # empty = all adb "device" serials; never installs apps
|
||||
# wvd: data/device.wvd # path to .wvd (required for key fetch)
|
||||
# user_agent: "" # optional license-request User-Agent
|
||||
# App modules (rte, tg4, …) are compiled into the binary — there is no
|
||||
# modules directory to point at. Their local values live in
|
||||
# apps/modules/<name>/module.yaml; see apps/modules/README.md.
|
||||
259
apps/agent/agentcmd/agentcmd.go
Normal file
259
apps/agent/agentcmd/agentcmd.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// Package agentcmd is the agent CLI, exposed as a library so the single drm
|
||||
// binary can host it as a subcommand.
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
|
||||
"drmdecryption/apps/agent/internal/client"
|
||||
"drmdecryption/apps/agent/internal/config"
|
||||
"drmdecryption/apps/agent/internal/device"
|
||||
"drmdecryption/apps/agent/internal/poller"
|
||||
"drmdecryption/apps/agent/internal/queue"
|
||||
"drmdecryption/apps/agent/internal/worker"
|
||||
"drmdecryption/apps/agent/internal/wsclient"
|
||||
)
|
||||
|
||||
// Run dispatches an agent subcommand. args[0] is the subcommand name.
|
||||
func Run(args []string) error {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("agent: ")
|
||||
|
||||
if len(args) < 1 {
|
||||
Usage()
|
||||
return fmt.Errorf("agent: subcommand required")
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "run":
|
||||
runCmd(sub, rest)
|
||||
case "status":
|
||||
statusCmd(sub, rest)
|
||||
case "devices":
|
||||
devicesCmd(sub, rest)
|
||||
case "enqueue":
|
||||
enqueueCmd(rest)
|
||||
case "cancel":
|
||||
cancelCmd(rest)
|
||||
case "help", "-h", "--help":
|
||||
Usage()
|
||||
default:
|
||||
Usage()
|
||||
return fmt.Errorf("agent: unknown subcommand %q", sub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Usage prints the agent subcommand help.
|
||||
func Usage() {
|
||||
fmt.Fprintf(os.Stderr, `agent — always-on credentials refresher (queue + ADB)
|
||||
|
||||
Usage:
|
||||
agent run [--config apps/agent/agent.yaml]
|
||||
agent status [--config ...]
|
||||
agent devices [--config ...]
|
||||
agent enqueue --stream NAME [--reason manual] [--config ...]
|
||||
agent cancel --job ID [--config ...]
|
||||
|
||||
Polls streamd for enabled streams that are down / missing credentials,
|
||||
enqueues jobs, claims a free phone that already has the app, captures
|
||||
via MITM, POSTs credentials, then force-stops the app.
|
||||
|
||||
Does not install apps. Does not run NRE/ffmpeg.
|
||||
`)
|
||||
}
|
||||
|
||||
func loadCfg(sub string, args []string) (config.Config, *flag.FlagSet) {
|
||||
fs := flag.NewFlagSet(sub, flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||
_ = fs.Parse(args)
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return cfg, fs
|
||||
}
|
||||
|
||||
func openQueue(cfg config.Config) *queue.Store {
|
||||
q, err := queue.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("queue: %v", err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func runCmd(sub string, args []string) {
|
||||
cfg, _ := loadCfg(sub, args)
|
||||
api := client.New(cfg.StreamdURL, cfg.Token)
|
||||
if err := api.Health(); err != nil {
|
||||
log.Fatalf("streamd unreachable at %s: %v", cfg.StreamdURL, err)
|
||||
}
|
||||
q := openQueue(cfg)
|
||||
defer q.Close()
|
||||
|
||||
if n, err := q.RecoverStaleRunning(); err != nil {
|
||||
log.Fatalf("recover jobs: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("re-queued %d stuck running job(s) from previous run", n)
|
||||
}
|
||||
|
||||
pool := device.New("", cfg.Devices)
|
||||
stop := make(chan struct{})
|
||||
|
||||
p := &poller.Poller{
|
||||
API: api,
|
||||
Queue: q,
|
||||
MaxAttempts: cfg.MaxAttempts,
|
||||
Interval: cfg.PollInterval(),
|
||||
Log: log.Default(),
|
||||
}
|
||||
go p.Loop(stop)
|
||||
|
||||
ws := wsclient.New(cfg.StreamdURL, cfg.Token, cfg.AgentID, log.Default())
|
||||
go ws.Loop()
|
||||
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
w := &worker.Worker{
|
||||
ID: i + 1,
|
||||
API: api,
|
||||
Queue: q,
|
||||
Pool: pool,
|
||||
AgentID: cfg.AgentID,
|
||||
CaptureWait: cfg.CaptureWait(),
|
||||
Python: cfg.Python,
|
||||
WVD: cfg.WVD,
|
||||
UserAgent: cfg.UserAgent,
|
||||
Log: log.Default(),
|
||||
}
|
||||
go w.Loop(stop)
|
||||
}
|
||||
|
||||
log.Printf("running agent_id=%s streamd=%s data=%s workers=%d poll=%s",
|
||||
cfg.AgentID, cfg.StreamdURL, cfg.DataDir, cfg.Workers, cfg.PollInterval())
|
||||
log.Printf("ws heartbeat enabled — claims auto-expire if this agent dies")
|
||||
log.Printf("waiting for down / missing-credential streams… Ctrl+C to stop")
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
<-ch
|
||||
log.Printf("shutting down…")
|
||||
ws.Stop()
|
||||
close(stop)
|
||||
}
|
||||
|
||||
func statusCmd(sub string, args []string) {
|
||||
cfg, _ := loadCfg(sub, args)
|
||||
q := openQueue(cfg)
|
||||
defer q.Close()
|
||||
jobs, err := q.ListRecent(40)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tSTREAM\tAPP\tCHANNEL\tSTATUS\tATTEMPTS\tDEVICE\tREASON\tERROR")
|
||||
for _, j := range jobs {
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%d/%d\t%s\t%s\t%s\n",
|
||||
j.ID, j.StreamName, j.App, j.Channel, j.Status, j.Attempts, j.MaxAttempts,
|
||||
j.DeviceSerial, j.Reason, trunc(j.LastError, 60))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func devicesCmd(sub string, args []string) {
|
||||
cfg, _ := loadCfg(sub, args)
|
||||
pool := device.New("", cfg.Devices)
|
||||
list, err := pool.List()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
fmt.Println("no adb devices")
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SERIAL\tSTATE\tMODEL\tALLOWED\tBUSY\tBUSY_FOR")
|
||||
for _, d := range list {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%v\t%s\n",
|
||||
d.Serial, d.State, d.Model, d.Allowed, d.Busy, d.BusyFor)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func enqueueCmd(args []string) {
|
||||
fs := flag.NewFlagSet("enqueue", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||
name := fs.String("stream", "", "streamd stream name")
|
||||
reason := fs.String("reason", "manual", "job reason")
|
||||
_ = fs.Parse(args)
|
||||
if *name == "" {
|
||||
log.Fatal("--stream required")
|
||||
}
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
api := client.New(cfg.StreamdURL, cfg.Token)
|
||||
streams, err := api.ListStreams()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var st *client.Stream
|
||||
for i := range streams {
|
||||
if streams[i].Name == *name {
|
||||
st = &streams[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if st == nil {
|
||||
log.Fatalf("stream %q not found on streamd", *name)
|
||||
}
|
||||
q := openQueue(cfg)
|
||||
defer q.Close()
|
||||
j, added, err := q.EnqueueIfIdle(st.ID, st.Name, st.App, st.Channel, *reason, cfg.MaxAttempts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if !added {
|
||||
fmt.Printf("already has queued/running job for %s\n", st.Name)
|
||||
return
|
||||
}
|
||||
fmt.Printf("enqueued job=%d stream=%s app=%s channel=%s\n", j.ID, j.StreamName, j.App, j.Channel)
|
||||
}
|
||||
|
||||
func cancelCmd(args []string) {
|
||||
fs := flag.NewFlagSet("cancel", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "path to apps/agent/agent.yaml")
|
||||
jobID := fs.String("job", "", "job id")
|
||||
_ = fs.Parse(args)
|
||||
if *jobID == "" {
|
||||
log.Fatal("--job required")
|
||||
}
|
||||
id, err := strconv.ParseInt(*jobID, 10, 64)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
q := openQueue(cfg)
|
||||
defer q.Close()
|
||||
if err := q.Cancel(id); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("cancelled", id)
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
25
apps/agent/go.mod
Normal file
25
apps/agent/go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module drmdecryption/apps/agent
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
drmdecryption v0.0.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace drmdecryption => ../pkg
|
||||
55
apps/agent/go.sum
Normal file
55
apps/agent/go.sum
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac h1:pNA9PRXGPFURORAsI3IqfQJ4RLsRXqghCFdvypd/4GY=
|
||||
go.starlark.net v0.0.0-20260930220527-d7438c5a85ac/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
118
apps/agent/internal/client/client.go
Normal file
118
apps/agent/internal/client/client.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func New(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
UserAgent: "drm-agent/1.0",
|
||||
}
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
App string `json:"app"`
|
||||
Channel string `json:"channel"`
|
||||
MPD string `json:"mpd"`
|
||||
Key string `json:"key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Health string `json:"health"`
|
||||
LastError string `json:"last_error"`
|
||||
PlaylistAgeS float64 `json:"playlist_age_s"`
|
||||
ClaimedBy string `json:"claimed_by"`
|
||||
}
|
||||
|
||||
type Credentials struct {
|
||||
MPD string `json:"mpd"`
|
||||
Key string `json:"key"`
|
||||
PSSH string `json:"pssh,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
PID string `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Health() error {
|
||||
_, err := c.do(http.MethodGet, "/api/health", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) ListStreams() ([]Stream, error) {
|
||||
body, err := c.do(http.MethodGet, "/api/streams", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Streams []Stream `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrap.Streams, nil
|
||||
}
|
||||
|
||||
func (c *Client) Claim(id int64, agentID string, ttlSec int) error {
|
||||
payload := map[string]any{"agent_id": agentID, "ttl_sec": ttlSec}
|
||||
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/claim", id), payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseClaim(id int64, agentID string) error {
|
||||
payload := map[string]any{"agent_id": agentID}
|
||||
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/claim/release", id), payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) PostCredentials(id int64, cred Credentials) error {
|
||||
_, err := c.do(http.MethodPost, fmt.Sprintf("/api/streams/%d/credentials", id), cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, payload any) ([]byte, error) {
|
||||
var rdr io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%s %s: %s — %s", method, path, resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
104
apps/agent/internal/config/config.go
Normal file
104
apps/agent/internal/config/config.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"drmdecryption/repo"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
StreamdURL string `yaml:"streamd_url"`
|
||||
Token string `yaml:"token"`
|
||||
PollIntervalSec int `yaml:"poll_interval_sec"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
Workers int `yaml:"workers"`
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
CaptureWaitSec int `yaml:"capture_wait_sec"`
|
||||
Devices []string `yaml:"devices"`
|
||||
Python string `yaml:"python"`
|
||||
WVD string `yaml:"wvd"` // path to .wvd device file (required for key fetch)
|
||||
UserAgent string `yaml:"user_agent"`
|
||||
AgentID string `yaml:"agent_id"`
|
||||
}
|
||||
|
||||
func (c Config) PollInterval() time.Duration {
|
||||
if c.PollIntervalSec <= 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return time.Duration(c.PollIntervalSec) * time.Second
|
||||
}
|
||||
|
||||
func (c Config) CaptureWait() time.Duration {
|
||||
if c.CaptureWaitSec <= 0 {
|
||||
return 180 * time.Second
|
||||
}
|
||||
return time.Duration(c.CaptureWaitSec) * time.Second
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
root := repo.Root()
|
||||
if path == "" {
|
||||
path = filepath.Join(root, "apps", "agent", "agent.yaml")
|
||||
}
|
||||
var cfg Config
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cfg = defaults(root)
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg = applyDefaults(cfg, root)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func defaults(root string) Config {
|
||||
return applyDefaults(Config{}, root)
|
||||
}
|
||||
|
||||
func applyDefaults(cfg Config, root string) Config {
|
||||
if cfg.StreamdURL == "" {
|
||||
cfg.StreamdURL = "http://127.0.0.1:8083"
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
cfg.Token = os.Getenv("STREAMD_TOKEN")
|
||||
}
|
||||
if cfg.PollIntervalSec <= 0 {
|
||||
cfg.PollIntervalSec = 30
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(root, ".cache", "agent")
|
||||
} else if !filepath.IsAbs(cfg.DataDir) {
|
||||
cfg.DataDir = filepath.Join(root, cfg.DataDir)
|
||||
}
|
||||
if cfg.Workers <= 0 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
if cfg.MaxAttempts <= 0 {
|
||||
cfg.MaxAttempts = 5
|
||||
}
|
||||
if cfg.CaptureWaitSec <= 0 {
|
||||
cfg.CaptureWaitSec = 180
|
||||
}
|
||||
if cfg.AgentID == "" {
|
||||
// Stable across restarts so the same agent can reclaim after a crash.
|
||||
// Override in apps/agent/agent.yaml when running multiple agents.
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "agent"
|
||||
}
|
||||
cfg.AgentID = host
|
||||
}
|
||||
if cfg.WVD != "" && !filepath.IsAbs(cfg.WVD) {
|
||||
cfg.WVD = filepath.Join(root, cfg.WVD)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
118
apps/agent/internal/device/pool.go
Normal file
118
apps/agent/internal/device/pool.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package device
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"drmdecryption/adb"
|
||||
)
|
||||
|
||||
// Pool tracks free/busy ADB devices. Never installs apps.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
adbBin string
|
||||
allow map[string]bool // empty allow = all serials
|
||||
busy map[string]string // serial → job label
|
||||
}
|
||||
|
||||
func New(adbBin string, allowlist []string) *Pool {
|
||||
p := &Pool{
|
||||
adbBin: adbBin,
|
||||
allow: map[string]bool{},
|
||||
busy: map[string]string{},
|
||||
}
|
||||
for _, s := range allowlist {
|
||||
if s != "" {
|
||||
p.allow[s] = true
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Serial string `json:"serial"`
|
||||
State string `json:"state"`
|
||||
Model string `json:"model"`
|
||||
Busy bool `json:"busy"`
|
||||
BusyFor string `json:"busy_for,omitempty"`
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
func (p *Pool) List() ([]Info, error) {
|
||||
devs, err := adb.ListDevices(p.adbBin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]Info, 0, len(devs))
|
||||
for _, d := range devs {
|
||||
allowed := len(p.allow) == 0 || p.allow[d.Serial]
|
||||
busyFor, busy := p.busy[d.Serial]
|
||||
out = append(out, Info{
|
||||
Serial: d.Serial,
|
||||
State: d.State,
|
||||
Model: d.Model,
|
||||
Busy: busy,
|
||||
BusyFor: busyFor,
|
||||
Allowed: allowed,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Acquire finds a free device in state "device" that already has pkg installed.
|
||||
// Returns ErrWait if none available (do not burn job attempts).
|
||||
func (p *Pool) Acquire(pkg, jobLabel string) (*adb.Client, string, error) {
|
||||
devs, err := adb.ListDevices(p.adbBin)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
base := adb.New()
|
||||
if p.adbBin != "" {
|
||||
base.Bin = p.adbBin
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var lastMiss string
|
||||
for _, d := range devs {
|
||||
if d.State != "device" {
|
||||
continue
|
||||
}
|
||||
if len(p.allow) > 0 && !p.allow[d.Serial] {
|
||||
continue
|
||||
}
|
||||
if _, busy := p.busy[d.Serial]; busy {
|
||||
continue
|
||||
}
|
||||
c := base.WithSerial(d.Serial)
|
||||
if pkg != "" && !c.PackageInstalled(pkg) {
|
||||
lastMiss = fmt.Sprintf("%s missing package %s", d.Serial, pkg)
|
||||
continue
|
||||
}
|
||||
p.busy[d.Serial] = jobLabel
|
||||
return c, d.Serial, nil
|
||||
}
|
||||
if lastMiss != "" {
|
||||
return nil, "", &WaitError{Msg: lastMiss}
|
||||
}
|
||||
return nil, "", &WaitError{Msg: "no free adb device"}
|
||||
}
|
||||
|
||||
func (p *Pool) Release(serial string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.busy, serial)
|
||||
}
|
||||
|
||||
// WaitError means the job should stay queued without consuming an attempt.
|
||||
type WaitError struct{ Msg string }
|
||||
|
||||
func (e *WaitError) Error() string { return e.Msg }
|
||||
|
||||
func IsWait(err error) bool {
|
||||
_, ok := err.(*WaitError)
|
||||
return ok
|
||||
}
|
||||
111
apps/agent/internal/poller/poller.go
Normal file
111
apps/agent/internal/poller/poller.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package poller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drmdecryption/apps/agent/internal/client"
|
||||
"drmdecryption/apps/agent/internal/queue"
|
||||
)
|
||||
|
||||
type Poller struct {
|
||||
API *client.Client
|
||||
Queue *queue.Store
|
||||
MaxAttempts int
|
||||
Interval time.Duration
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
func (p *Poller) Once() error {
|
||||
streams, err := p.API.ListStreams()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, st := range streams {
|
||||
if !st.Enabled {
|
||||
continue
|
||||
}
|
||||
reason := needsRefresh(st)
|
||||
if reason == "" {
|
||||
continue
|
||||
}
|
||||
j, added, err := p.Queue.EnqueueIfIdle(st.ID, st.Name, st.App, st.Channel, reason, p.MaxAttempts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if added {
|
||||
p.logf("enqueued job=%d stream=%s reason=%s", j.ID, st.Name, reason)
|
||||
continue
|
||||
}
|
||||
if active, ok, err := p.Queue.ActiveJobForStream(st.ID); err == nil && ok {
|
||||
extra := ""
|
||||
if active.Status == queue.StatusQueued && !active.NotBefore.IsZero() && active.NotBefore.After(time.Now().UTC()) {
|
||||
extra = fmt.Sprintf(" backoff_until=%s", active.NotBefore.Format(time.RFC3339))
|
||||
}
|
||||
p.logf("stream %s needs %s but job=%d already %s (attempts %d/%d)%s",
|
||||
st.Name, reason, active.ID, active.Status, active.Attempts, active.MaxAttempts, extra)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Poller) Loop(stop <-chan struct{}) {
|
||||
t := time.NewTicker(p.Interval)
|
||||
defer t.Stop()
|
||||
_ = p.Once()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := p.Once(); err != nil {
|
||||
p.logf("poll error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func needsRefresh(st client.Stream) string {
|
||||
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
|
||||
return "missing_credentials"
|
||||
}
|
||||
if !validKeyPair(st.Key) {
|
||||
return "invalid_credentials"
|
||||
}
|
||||
h := strings.ToLower(strings.TrimSpace(st.Health))
|
||||
switch h {
|
||||
case "down", "error", "failed":
|
||||
return "down"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validKeyPair accepts KID:KEY as 32-hex:32-hex (colons/dashes in KID ignored).
|
||||
func validKeyPair(key string) bool {
|
||||
parts := strings.SplitN(strings.TrimSpace(key), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
hexOnly := func(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
kid, k := hexOnly(parts[0]), hexOnly(parts[1])
|
||||
return len(kid) == 32 && len(k) == 32
|
||||
}
|
||||
|
||||
func (p *Poller) logf(format string, args ...any) {
|
||||
if p.Log != nil {
|
||||
p.Log.Printf(format, args...)
|
||||
return
|
||||
}
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
334
apps/agent/internal/queue/queue.go
Normal file
334
apps/agent/internal/queue/queue.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package queue
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusRunning Status = "running"
|
||||
StatusOK Status = "ok"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancel Status = "cancelled"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID int64
|
||||
StreamID int64
|
||||
StreamName string
|
||||
App string
|
||||
Channel string
|
||||
Reason string
|
||||
Status Status
|
||||
Priority int
|
||||
Attempts int
|
||||
MaxAttempts int
|
||||
DeviceSerial string
|
||||
LastError string
|
||||
NotBefore time.Time
|
||||
CreatedAt time.Time
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(dataDir, "agent.db")
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", filepath.ToSlash(path))
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
s := &Store{DB: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.DB.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.DB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stream_id INTEGER NOT NULL,
|
||||
stream_name TEXT NOT NULL DEFAULT '',
|
||||
app TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
device_serial TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
not_before TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT '',
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, priority, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_stream ON jobs(stream_id, status);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := time.Parse(time.RFC3339, s)
|
||||
return t
|
||||
}
|
||||
|
||||
// EnqueueIfIdle inserts a job unless one is already queued/running for the stream.
|
||||
func (s *Store) EnqueueIfIdle(streamID int64, name, app, channel, reason string, maxAttempts int) (Job, bool, error) {
|
||||
var existing int
|
||||
err := s.DB.QueryRow(`
|
||||
SELECT COUNT(1) FROM jobs WHERE stream_id=? AND status IN ('queued','running')`, streamID).Scan(&existing)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
if existing > 0 {
|
||||
return Job{}, false, nil
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
res, err := s.DB.Exec(`
|
||||
INSERT INTO jobs(stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,created_at,not_before)
|
||||
VALUES(?,?,?,?,?,'queued',100,0,?,?,?)`,
|
||||
streamID, name, app, channel, reason, maxAttempts, now(), now())
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
j, err := s.Get(id)
|
||||
return j, true, err
|
||||
}
|
||||
|
||||
func (s *Store) Get(id int64) (Job, error) {
|
||||
row := s.DB.QueryRow(`
|
||||
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||
FROM jobs WHERE id=?`, id)
|
||||
return scanJob(row)
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanJob(row scannable) (Job, error) {
|
||||
var j Job
|
||||
var status, nb, ca, sa, fa string
|
||||
err := row.Scan(
|
||||
&j.ID, &j.StreamID, &j.StreamName, &j.App, &j.Channel, &j.Reason, &status,
|
||||
&j.Priority, &j.Attempts, &j.MaxAttempts, &j.DeviceSerial, &j.LastError,
|
||||
&nb, &ca, &sa, &fa,
|
||||
)
|
||||
if err != nil {
|
||||
return j, err
|
||||
}
|
||||
j.Status = Status(status)
|
||||
j.NotBefore = parseTime(nb)
|
||||
j.CreatedAt = parseTime(ca)
|
||||
j.StartedAt = parseTime(sa)
|
||||
j.FinishedAt = parseTime(fa)
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// ClaimNext marks the next ready queued job as running. Returns false if none.
|
||||
func (s *Store) ClaimNext() (Job, bool, error) {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
nowStr := now()
|
||||
row := tx.QueryRow(`
|
||||
SELECT id FROM jobs
|
||||
WHERE status='queued' AND (not_before='' OR not_before<=?)
|
||||
ORDER BY priority ASC, id ASC
|
||||
LIMIT 1`, nowStr)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return Job{}, false, nil
|
||||
}
|
||||
return Job{}, false, err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE jobs SET status='running', started_at=?, last_error='' WHERE id=? AND status='queued'`, nowStr, id)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
j, err := s.Get(id)
|
||||
return j, true, err
|
||||
}
|
||||
|
||||
func (s *Store) SetDevice(id int64, serial string) error {
|
||||
_, err := s.DB.Exec(`UPDATE jobs SET device_serial=? WHERE id=?`, serial, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkOK(id int64) error {
|
||||
_, err := s.DB.Exec(`UPDATE jobs SET status='ok', finished_at=?, last_error='' WHERE id=?`, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkFailed increments attempts. Re-queues with backoff unless maxed out.
|
||||
// waitingDevice=true keeps status queued without burning an attempt.
|
||||
func (s *Store) MarkFailed(id int64, msg string, waitingDevice bool) error {
|
||||
j, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if waitingDevice {
|
||||
_, err = s.DB.Exec(`
|
||||
UPDATE jobs SET status='queued', device_serial='', last_error=?, started_at='' WHERE id=?`,
|
||||
msg, id)
|
||||
return err
|
||||
}
|
||||
attempts := j.Attempts + 1
|
||||
if attempts >= j.MaxAttempts {
|
||||
_, err = s.DB.Exec(`
|
||||
UPDATE jobs SET status='failed', attempts=?, last_error=?, finished_at=? WHERE id=?`,
|
||||
attempts, msg, now(), id)
|
||||
return err
|
||||
}
|
||||
backoff := time.Duration(1<<uint(min(attempts, 4))) * time.Minute
|
||||
if backoff > 15*time.Minute {
|
||||
backoff = 15 * time.Minute
|
||||
}
|
||||
nb := time.Now().UTC().Add(backoff).Format(time.RFC3339)
|
||||
_, err = s.DB.Exec(`
|
||||
UPDATE jobs SET status='queued', attempts=?, last_error=?, not_before=?, device_serial='', started_at='' WHERE id=?`,
|
||||
attempts, msg, nb, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *Store) Cancel(id int64) error {
|
||||
_, err := s.DB.Exec(`
|
||||
UPDATE jobs SET status='cancelled', finished_at=? WHERE id=? AND status IN ('queued','running')`, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListRecent(limit int) ([]Job, error) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
rows, err := s.DB.Query(`
|
||||
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||
FROM jobs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Job
|
||||
for rows.Next() {
|
||||
j, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ActiveForStream(streamID int64) (bool, error) {
|
||||
var n int
|
||||
err := s.DB.QueryRow(`SELECT COUNT(1) FROM jobs WHERE stream_id=? AND status IN ('queued','running')`, streamID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// RecoverStaleRunning re-queues jobs left in status=running after a crash/kill
|
||||
// so a down stream is not blocked forever by EnqueueIfIdle.
|
||||
func (s *Store) RecoverStaleRunning() (int, error) {
|
||||
nowStr := now()
|
||||
res, err := s.DB.Exec(`
|
||||
UPDATE jobs SET status='queued', device_serial='', started_at='',
|
||||
last_error='recovered after agent restart', not_before=?
|
||||
WHERE status='running'`, nowStr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// NextDelayed returns the soonest queued job that is still waiting on not_before.
|
||||
func (s *Store) NextDelayed() (Job, time.Duration, bool, error) {
|
||||
nowStr := now()
|
||||
row := s.DB.QueryRow(`
|
||||
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||
FROM jobs
|
||||
WHERE status='queued' AND not_before!='' AND not_before>?
|
||||
ORDER BY not_before ASC LIMIT 1`, nowStr)
|
||||
j, err := scanJob(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return Job{}, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Job{}, 0, false, err
|
||||
}
|
||||
until := time.Until(j.NotBefore)
|
||||
if until < 0 {
|
||||
until = 0
|
||||
}
|
||||
return j, until, true, nil
|
||||
}
|
||||
|
||||
// ClearBackoff makes a queued job runnable immediately (e.g. after fixing auth).
|
||||
func (s *Store) ClearBackoff(id int64) error {
|
||||
_, err := s.DB.Exec(`UPDATE jobs SET not_before=? WHERE id=? AND status='queued'`, now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ActiveJobForStream returns the queued/running job for a stream, if any.
|
||||
func (s *Store) ActiveJobForStream(streamID int64) (Job, bool, error) {
|
||||
row := s.DB.QueryRow(`
|
||||
SELECT id,stream_id,stream_name,app,channel,reason,status,priority,attempts,max_attempts,
|
||||
device_serial,last_error,not_before,created_at,started_at,finished_at
|
||||
FROM jobs WHERE stream_id=? AND status IN ('queued','running')
|
||||
ORDER BY id DESC LIMIT 1`, streamID)
|
||||
j, err := scanJob(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return Job{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
return j, true, nil
|
||||
}
|
||||
149
apps/agent/internal/worker/worker.go
Normal file
149
apps/agent/internal/worker/worker.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drmdecryption/app"
|
||||
"drmdecryption/phonecap"
|
||||
|
||||
"drmdecryption/apps/agent/internal/client"
|
||||
"drmdecryption/apps/agent/internal/device"
|
||||
"drmdecryption/apps/agent/internal/queue"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
ID int
|
||||
API *client.Client
|
||||
Queue *queue.Store
|
||||
Pool *device.Pool
|
||||
AgentID string
|
||||
CaptureWait time.Duration
|
||||
Python string
|
||||
WVD string
|
||||
UserAgent string
|
||||
Log *log.Logger
|
||||
|
||||
lastDelayLog time.Time
|
||||
}
|
||||
|
||||
func (w *Worker) Loop(stop <-chan struct{}) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
w.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) tick() {
|
||||
job, ok, err := w.Queue.ClaimNext()
|
||||
if err != nil {
|
||||
w.logf("claim next: %v", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if j, until, found, err := w.Queue.NextDelayed(); err == nil && found {
|
||||
if time.Since(w.lastDelayLog) > 30*time.Second {
|
||||
w.lastDelayLog = time.Now()
|
||||
w.logf("no runnable jobs; next is job=%d stream=%s in %s (backoff)",
|
||||
j.ID, j.StreamName, until.Round(time.Second))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
w.logf("job=%d running stream=%s app=%s channel=%s reason=%s",
|
||||
job.ID, job.StreamName, job.App, job.Channel, job.Reason)
|
||||
if err := w.runJob(job); err != nil {
|
||||
if device.IsWait(err) || isAuthErr(err) || isUnknownAppErr(err) {
|
||||
// Config/environment issues — do not burn attempts or multi-minute backoff.
|
||||
w.logf("job=%d waiting: %v", job.ID, err)
|
||||
_ = w.Queue.MarkFailed(job.ID, err.Error(), true)
|
||||
return
|
||||
}
|
||||
w.logf("job=%d failed: %v", job.ID, err)
|
||||
_ = w.Queue.MarkFailed(job.ID, err.Error(), false)
|
||||
return
|
||||
}
|
||||
_ = w.Queue.MarkOK(job.ID)
|
||||
w.logf("job=%d ok stream=%s", job.ID, job.StreamName)
|
||||
}
|
||||
|
||||
func isAuthErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "401") || strings.Contains(s, "unauthorized")
|
||||
}
|
||||
|
||||
func isUnknownAppErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "unknown app")
|
||||
}
|
||||
|
||||
func (w *Worker) runJob(job queue.Job) error {
|
||||
a, err := app.Get(job.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
label := fmt.Sprintf("job-%d:%s", job.ID, job.StreamName)
|
||||
c, serial, err := w.Pool.Acquire(a.Package(), label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Pool.Release(serial)
|
||||
_ = w.Queue.SetDevice(job.ID, serial)
|
||||
w.logf("job=%d device=%s package=%s", job.ID, serial, a.Package())
|
||||
|
||||
// Short TTL; /ws/agent heartbeats renew while this process is alive.
|
||||
if err := w.API.Claim(job.StreamID, w.AgentID, 90); err != nil {
|
||||
return fmt.Errorf("streamd claim: %w", err)
|
||||
}
|
||||
defer func() { _ = w.API.ReleaseClaim(job.StreamID, w.AgentID) }()
|
||||
|
||||
res, err := phonecap.Run(phonecap.Options{
|
||||
App: a,
|
||||
Channel: job.Channel,
|
||||
Client: c,
|
||||
Python: w.Python,
|
||||
WVD: w.WVD,
|
||||
UserAgent: w.UserAgent,
|
||||
Wait: w.CaptureWait,
|
||||
CloseApp: true,
|
||||
ClearProxy: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred := client.Credentials{
|
||||
MPD: res.MPD,
|
||||
Key: res.Key,
|
||||
PSSH: res.Session.PSSH,
|
||||
Auth: res.Session.Auth,
|
||||
PID: res.Session.PID,
|
||||
}
|
||||
if err := w.API.PostCredentials(job.StreamID, cred); err != nil {
|
||||
return fmt.Errorf("post credentials: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) logf(format string, args ...any) {
|
||||
prefix := fmt.Sprintf("worker/%d: ", w.ID)
|
||||
if w.Log != nil {
|
||||
w.Log.Printf(prefix+format, args...)
|
||||
return
|
||||
}
|
||||
log.Printf(prefix+format, args...)
|
||||
}
|
||||
148
apps/agent/internal/wsclient/wsclient.go
Normal file
148
apps/agent/internal/wsclient/wsclient.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package wsclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client keeps a streamd /ws/agent connection alive and sends heartbeats
|
||||
// so claims auto-renew; on disconnect streamd releases this agent's claims.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
AgentID string
|
||||
Log *log.Logger
|
||||
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func New(baseURL, token, agentID string, lg *log.Logger) *Client {
|
||||
if lg == nil {
|
||||
lg = log.Default()
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
AgentID: agentID,
|
||||
Log: lg,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Stop() { close(c.stop) }
|
||||
|
||||
// Loop reconnects forever until Stop.
|
||||
func (c *Client) Loop() {
|
||||
backoff := time.Second
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := c.session(); err != nil {
|
||||
c.Log.Printf("ws: session ended: %v — retry in %s", err, backoff)
|
||||
}
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < 30*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) session() error {
|
||||
u, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
u.Scheme = "wss"
|
||||
default:
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/ws/agent"
|
||||
q := u.Query()
|
||||
q.Set("agent_id", c.AgentID)
|
||||
if c.Token != "" {
|
||||
q.Set("token", c.Token)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
hdr := http.Header{}
|
||||
if c.Token != "" {
|
||||
hdr.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||
conn, _, err := dialer.Dial(u.String(), hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
c.Log.Printf("ws: connected to %s as %s", u.Host, c.AgentID)
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
var msg map[string]any
|
||||
if json.Unmarshal(data, &msg) == nil {
|
||||
if t, _ := msg["type"].(string); t == "heartbeat_ack" {
|
||||
if ok, _ := msg["ok"].(bool); !ok {
|
||||
c.Log.Printf("ws: heartbeat nack: %v", msg["error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Immediate heartbeat so existing claims renew right away on reconnect.
|
||||
if err := c.writeHeartbeat(conn); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
_ = conn.WriteJSON(map[string]string{"type": "release_all"})
|
||||
return nil
|
||||
case <-done:
|
||||
return fmt.Errorf("read closed")
|
||||
case <-ticker.C:
|
||||
if err := c.writeHeartbeat(conn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeHeartbeat(conn *websocket.Conn) error {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
return conn.WriteJSON(map[string]string{
|
||||
"type": "heartbeat",
|
||||
"agent_id": c.AgentID,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue