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:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

63
apps/agent/README.md Normal file
View 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
View 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.

View 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
View 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
View 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=

View 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
}

View 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
}

View 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
}

View 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...)
}

View 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
}

View 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...)
}

View 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,
})
}