ADBKit agent: source code and verification

Everything below is generated from the same files this release was built from. Nothing here is loaded from anywhere else.

What the agent does

The install scripts

Read them first if you like, from a terminal:

curl -fsSL https://adbkit.com/install.sh | less
irm https://adbkit.com/install.ps1 | more
install.sh (macOS and Linux) 167 lines
#!/bin/sh
# ADBKit agent installer for macOS and Linux.
#
#   curl -fsSL https://adbkit.com/install.sh | sh
#   curl -fsSL https://adbkit.com/install.sh | sh -s -- --port 8730
#
# What this script does, and nothing else:
#   1. Detects your OS and CPU (darwin/linux, amd64/arm64).
#   2. Downloads adbkit-agent_<os>_<arch> and checksums.txt from
#      https://adbkit.com/agent/ and verifies the SHA-256 checksum. A
#      mismatch aborts; nothing is run.
#   3. Saves the binary to ~/.adbkit/bin/adbkit-agent (no sudo, no PATH edits)
#      and, on macOS, removes the download quarantine flag from that one file.
#   4. Runs the agent in the foreground. Press Ctrl+C to stop it.
# If the binary cannot be downloaded and Node.js 18+ is installed, it falls
# back to https://adbkit.com/adbkit-bridge.mjs (no Wireless debugging pairing).
# No telemetry. Nothing is installed anywhere else.
#
# Environment: ADBKIT_NO_RUN=1 (install only), ADBKIT_RELEASE_BASE and
#   ADBKIT_NODE_URL (override download locations, for testing).
# Flags: --port <n> is passed to the agent.

set -eu

BASE="${ADBKIT_RELEASE_BASE:-https://adbkit.com/agent}"
NODE_URL="${ADBKIT_NODE_URL:-https://adbkit.com/adbkit-bridge.mjs}"
HOME_DIR="${ADBKIT_HOME:-$HOME/.adbkit}"
BIN_DIR="$HOME_DIR/bin"

say() { printf '%s\n' "$*"; }
fail() { printf 'adbkit: %s\n' "$*" >&2; exit 1; }

# Parse --port (the only flag); keep the rest for the agent.
PORT_ARGS=""
PORT_VALUE=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --port)
      [ "$#" -ge 2 ] || fail "--port needs a number"
      PORT_VALUE="$2"
      shift 2
      ;;
    --port=*)
      PORT_VALUE="${1#--port=}"
      shift
      ;;
    -h|--help)
      sed -n '2,24p' "$0" 2>/dev/null || say "See https://adbkit.com/blog/adbkit-wifi-helper-setup"
      exit 0
      ;;
    *) fail "unknown option: $1" ;;
  esac
done
if [ -n "$PORT_VALUE" ]; then
  case "$PORT_VALUE" in
    ''|*[!0-9]*) fail "--port must be a number" ;;
  esac
  PORT_ARGS="--port"
fi

detect_platform() {
  os_raw="${ADBKIT_OS:-$(uname -s)}"
  arch_raw="${ADBKIT_ARCH:-$(uname -m)}"
  case "$os_raw" in
    Darwin|darwin) OS="darwin" ;;
    Linux|linux) OS="linux" ;;
    *) fail "unsupported OS: $os_raw (on Windows use: irm https://adbkit.com/install.ps1 | iex)" ;;
  esac
  case "$arch_raw" in
    x86_64|amd64) ARCH="amd64" ;;
    arm64|aarch64) ARCH="arm64" ;;
    *) fail "unsupported CPU architecture: $arch_raw" ;;
  esac
}

# download <url> <destination>; returns non-zero on failure.
download() {
  if command -v curl >/dev/null 2>&1; then
    curl -fsSL --retry 2 -o "$2" "$1"
  elif command -v wget >/dev/null 2>&1; then
    wget -q -O "$2" "$1"
  else
    fail "need curl or wget to download files"
  fi
}

sha256_of() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | cut -d ' ' -f 1
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$1" | cut -d ' ' -f 1
  else
    fail "need sha256sum or shasum to verify the download"
  fi
}

run_node_fallback() {
  command -v node >/dev/null 2>&1 || return 1
  node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
  [ "$node_major" -ge 18 ] 2>/dev/null || return 1
  mkdir -p "$BIN_DIR"
  say "Falling back to the Node.js edition (Node $node_major)."
  download "$NODE_URL" "$BIN_DIR/adbkit-bridge.mjs" || return 1
  say "Note: Wireless debugging pairing (Android 11+ pairing code) is NOT available"
  say "in the Node.js edition. USB-less \"adb tcpip\" Wi-Fi works."
  if [ "${ADBKIT_NO_RUN:-}" = "1" ]; then
    say "Installed $BIN_DIR/adbkit-bridge.mjs (not started: ADBKIT_NO_RUN=1)."
    exit 0
  fi
  say "Return to ADBKit in your browser and choose Connect over Wi-Fi."
  say "Press Ctrl+C to stop."
  if [ -n "$PORT_ARGS" ]; then
    exec node "$BIN_DIR/adbkit-bridge.mjs" --port "$PORT_VALUE"
  fi
  exec node "$BIN_DIR/adbkit-bridge.mjs"
}

detect_platform
ASSET="adbkit-agent_${OS}_${ARCH}"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/adbkit.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT INT TERM

say "ADBKit agent: $OS/$ARCH"
if download "$BASE/$ASSET" "$TMP/$ASSET" && download "$BASE/checksums.txt" "$TMP/checksums.txt"; then
  expected="$(awk -v f="$ASSET" '$2 == f || $2 == "*" f { print $1; exit }' "$TMP/checksums.txt")"
  [ -n "$expected" ] || fail "checksums.txt has no entry for $ASSET; refusing to run it"
  actual="$(sha256_of "$TMP/$ASSET")"
  if [ "$expected" != "$actual" ]; then
    fail "checksum mismatch for $ASSET (expected $expected, got $actual); nothing was installed"
  fi
  say "Checksum OK (sha256 $actual)."
  mkdir -p "$BIN_DIR"
  cp "$TMP/$ASSET" "$BIN_DIR/adbkit-agent"
  chmod +x "$BIN_DIR/adbkit-agent"
  if [ "$OS" = "darwin" ]; then
    xattr -d com.apple.quarantine "$BIN_DIR/adbkit-agent" 2>/dev/null || true
  fi
  say "Installed $BIN_DIR/adbkit-agent"
  if [ "${ADBKIT_NO_RUN:-}" = "1" ]; then
    say "Not started (ADBKIT_NO_RUN=1). Run it with: $BIN_DIR/adbkit-agent"
    exit 0
  fi
  say ""
  say "Starting the agent. Return to ADBKit in your browser (https://adbkit.com/app)"
  say "and choose Connect over Wi-Fi. Press Ctrl+C to stop."
  rm -rf "$TMP"
  trap - EXIT INT TERM
  if [ -n "$PORT_ARGS" ]; then
    exec "$BIN_DIR/adbkit-agent" --port "$PORT_VALUE"
  fi
  exec "$BIN_DIR/adbkit-agent"
fi

say "Could not download the agent binary from $BASE."
if run_node_fallback; then exit 0; fi

cat >&2 <<MSG

adbkit: could not install the agent automatically. Next steps:
  1. Check your internet connection and try again, or
  2. Download adbkit-agent_${OS}_${ARCH} manually from
     $REPO_URL and run it, or
  3. Install Node.js 18+ (https://nodejs.org) and run:
       curl -fsSL $NODE_URL -o adbkit-bridge.mjs && node adbkit-bridge.mjs
Docs: https://adbkit.com/blog/adbkit-wifi-helper-setup
MSG
exit 1
install.ps1 (Windows PowerShell) 104 lines
# ADBKit agent installer for Windows (PowerShell 5+).
#
#   irm https://adbkit.com/install.ps1 | iex
#
# What this script does, and nothing else:
#   1. Detects your CPU (amd64/arm64).
#   2. Downloads adbkit-agent_windows_<arch>.exe and checksums.txt from
#      https://adbkit.com/agent/ and verifies the SHA-256 checksum. A
#      mismatch aborts; nothing is run.
#   3. Saves it to %LOCALAPPDATA%\adbkit\bin\adbkit-agent.exe (no admin, no
#      PATH edits) and runs it in the foreground. Press Ctrl+C to stop.
# If the binary cannot be downloaded and Node.js 18+ is installed, it falls
# back to https://adbkit.com/adbkit-bridge.mjs (no Wireless debugging pairing).
# No telemetry.
#
# Environment: ADBKIT_NO_RUN=1 (install only), ADBKIT_PORT (agent port),
# ADBKIT_RELEASE_BASE / ADBKIT_NODE_URL (override download locations, for
# testing).

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}

$base = if ($env:ADBKIT_RELEASE_BASE) { $env:ADBKIT_RELEASE_BASE } else { 'https://adbkit.com/agent' }
$nodeUrl = if ($env:ADBKIT_NODE_URL) { $env:ADBKIT_NODE_URL } else { 'https://adbkit.com/adbkit-bridge.mjs' }
$root = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'adbkit' } else { Join-Path $HOME '.adbkit' }
$binDir = Join-Path $root 'bin'

$agentArgs = @()
if ($env:ADBKIT_PORT) {
  if ($env:ADBKIT_PORT -notmatch '^\d+$') { throw 'ADBKIT_PORT must be a number' }
  $agentArgs = @('--port', $env:ADBKIT_PORT)
}

$archRaw = if ($env:ADBKIT_ARCH) { $env:ADBKIT_ARCH } else { $env:PROCESSOR_ARCHITECTURE }
switch -Regex ($archRaw) {
  '^(AMD64|x86_64|amd64)$' { $arch = 'amd64'; break }
  '^(ARM64|arm64|aarch64)$' { $arch = 'arm64'; break }
  default { throw "Unsupported CPU architecture: $archRaw" }
}
$asset = "adbkit-agent_windows_$arch.exe"
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("adbkit-" + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmp | Out-Null

function Start-NodeFallback {
  $node = Get-Command node -ErrorAction SilentlyContinue
  if (-not $node) { return $false }
  $major = [int](& node -p 'process.versions.node.split(".")[0]')
  if ($major -lt 18) { return $false }
  New-Item -ItemType Directory -Force -Path $binDir | Out-Null
  $script = Join-Path $binDir 'adbkit-bridge.mjs'
  try { Invoke-WebRequest -UseBasicParsing -Uri $nodeUrl -OutFile $script } catch { return $false }
  Write-Host "Falling back to the Node.js edition (Node $major)."
  Write-Host 'Note: Wireless debugging pairing (Android 11+ pairing code) is NOT available in the Node.js edition.'
  if ($env:ADBKIT_NO_RUN -eq '1') { Write-Host "Installed $script (not started: ADBKIT_NO_RUN=1)."; return $true }
  Write-Host 'Return to ADBKit in your browser and choose Connect over Wi-Fi. Press Ctrl+C to stop.'
  & node $script @agentArgs
  return $true
}

try {
  Write-Host "ADBKit agent: windows/$arch"
  $downloaded = $false
  try {
    Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile (Join-Path $tmp $asset)
    Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile (Join-Path $tmp 'checksums.txt')
    $downloaded = $true
  } catch {
    Write-Host "Could not download the agent binary from $base."
  }

  if ($downloaded) {
    $line = Get-Content (Join-Path $tmp 'checksums.txt') |
      Where-Object { ($_ -split '\s+')[1] -in @($asset, "*$asset") } | Select-Object -First 1
    if (-not $line) { throw "checksums.txt has no entry for $asset; refusing to run it" }
    $expected = ($line -split '\s+')[0].ToLowerInvariant()
    $actual = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $tmp $asset)).Hash.ToLowerInvariant()
    if ($expected -ne $actual) { throw "Checksum mismatch for $asset (expected $expected, got $actual); nothing was installed" }
    Write-Host "Checksum OK (sha256 $actual)."
    New-Item -ItemType Directory -Force -Path $binDir | Out-Null
    $exe = Join-Path $binDir 'adbkit-agent.exe'
    Copy-Item -Force (Join-Path $tmp $asset) $exe
    try { Unblock-File -Path $exe } catch {}
    Write-Host "Installed $exe"
    if ($env:ADBKIT_NO_RUN -eq '1') { Write-Host "Not started (ADBKIT_NO_RUN=1). Run it with: $exe"; return }
    Write-Host ''
    Write-Host 'Starting the agent. Return to ADBKit in your browser (https://adbkit.com/app)'
    Write-Host 'and choose Connect over Wi-Fi. Press Ctrl+C to stop.'
    & $exe @agentArgs
    return
  }

  if (Start-NodeFallback) { return }
  Write-Host ''
  Write-Host 'adbkit: could not install the agent automatically. Next steps:'
  Write-Host '  1. Check your internet connection and try again, or'
  Write-Host "  2. Download $asset manually from $repo and run it, or"
  Write-Host '  3. Install Node.js 18+ (https://nodejs.org), then download'
  Write-Host "     $nodeUrl and run: node adbkit-bridge.mjs"
  Write-Host 'Docs: https://adbkit.com/blog/adbkit-wifi-helper-setup'
  throw 'Install failed'
} finally {
  Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}

The agent source code (Go)

11 files, about 1,996 lines of Go. Unit tests are included in the archive below.

main.go 128 lines
// Command adbkit-agent is the ADBKit local agent (protocol v2): a loopback
// relay that lets the ADBKit web app reach phones over Wi-Fi (adb tcpip) and
// Wireless debugging (mDNS discovery, pairing, TLS connect).
package main

import (
	"context"
	_ "embed"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"

	"github.com/adbkit/agent/internal/keys"
	"github.com/adbkit/agent/internal/pairing"
	"github.com/adbkit/agent/internal/qrpair"
	"github.com/adbkit/agent/internal/server"
	"github.com/adbkit/agent/internal/tlsconnect"
)

// VERSION is the single source of truth for the agent version. The linker can
// still override it: go build -ldflags "-X main.version=1.2.3".
//
//go:embed VERSION
var embeddedVersion string

// version is empty unless set with -ldflags -X (which only works on a plain
// string variable); resolveVersion falls back to the embedded VERSION file.
var version string

func resolveVersion(override, embedded string) string {
	if v := strings.TrimSpace(override); v != "" {
		return v
	}
	if v := strings.TrimSpace(embedded); v != "" {
		return v
	}
	return "dev"
}

type multiFlag []string

func (m *multiFlag) String() string     { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }

func main() {
	var extra multiFlag
	port := flag.Int("port", server.DefaultPort, "loopback port to listen on")
	flag.Var(&extra, "origin", "additional allowed page origin (repeatable)")
	keyPath := flag.String("key", "", "agent key path (default ~/.adbkit/agent-key.pem)")
	showVersion := flag.Bool("version", false, "print version and exit")
	flag.Parse()
	version = resolveVersion(version, embeddedVersion)
	if *showVersion {
		fmt.Println("adbkit-agent", version)
		return
	}
	log.SetFlags(log.LstdFlags)

	origins := append([]string{}, server.DefaultOrigins...)
	origins = append(origins, extra...)
	for _, o := range strings.Split(os.Getenv("ADBKIT_BRIDGE_ORIGINS"), ",") {
		if o = strings.TrimSpace(o); o != "" {
			origins = append(origins, o)
		}
	}

	path := *keyPath
	if path == "" {
		dir, err := keys.Dir()
		if err != nil {
			fatal(err)
		}
		path = filepath.Join(dir, "agent-key.pem")
	}
	id, err := keys.LoadOrCreate(path)
	if err != nil {
		fatal(fmt.Errorf("agent key: %w", err))
	}

	pair := func(ctx context.Context, host string, port int, code string) (*pairing.Result, error) {
		return pairing.Pair(ctx, host, port, code, id.Cert, id.AdbPublicKeyText())
	}
	srv := server.New(server.Config{
		Version: version,
		Origins: origins,
		Pair:    pair,
		QR:      qrpair.New(qrpair.Config{Pair: pair}),
		WrapTLS: func(raw net.Conn) io.ReadWriteCloser {
			return tlsconnect.Wrap(raw, id.Cert)
		},
	})

	// Never 0.0.0.0: loopback only.
	ln, err := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", *port))
	if err != nil {
		fatal(fmt.Errorf("could not start the agent: %w", err))
	}
	httpSrv := &http.Server{Handler: srv, ReadHeaderTimeout: 10 * time.Second, ErrorLog: log.New(io.Discard, "", 0)}

	fmt.Printf("ADBKit agent %s listening on http://127.0.0.1:%d (protocol %d)\n", version, *port, server.ProtocolVersion)
	fmt.Printf("Allowed pages: %s\n", strings.Join(origins, ", "))
	fmt.Println("Leave this running while you use Wi-Fi in ADBKit. Ctrl+C to stop.")

	go func() {
		sig := make(chan os.Signal, 1)
		signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
		<-sig
		_ = httpSrv.Close()
	}()
	if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
		fatal(err)
	}
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, err)
	os.Exit(1)
}
internal/keys/keys.go 143 lines
// Package keys owns the agent's RSA identity: one 2048-bit key, persisted in
// ~/.adbkit/agent-key.pem (0600), used both as the adb public key registered
// during pairing and as the TLS client certificate for Wireless debugging.
package keys

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/base64"
	"encoding/binary"
	"encoding/pem"
	"errors"
	"fmt"
	"math/big"
	"os"
	"path/filepath"
	"time"
)

// Identity is the agent's key plus the derived certificate and adb public key.
type Identity struct {
	Key  *rsa.PrivateKey
	Cert tls.Certificate
	// Name is the "user@host" label stored next to the key on the phone.
	Name string
}

// Dir returns the state directory (~/.adbkit).
func Dir() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".adbkit"), nil
}

// LoadOrCreate reads the key at path or generates and persists a new one.
func LoadOrCreate(path string) (*Identity, error) {
	var key *rsa.PrivateKey
	data, err := os.ReadFile(path)
	switch {
	case err == nil:
		block, _ := pem.Decode(data)
		if block == nil {
			return nil, errors.New("agent key: not PEM")
		}
		parsed, perr := x509.ParsePKCS8PrivateKey(block.Bytes)
		if perr != nil {
			return nil, fmt.Errorf("agent key: %w", perr)
		}
		var ok bool
		if key, ok = parsed.(*rsa.PrivateKey); !ok {
			return nil, errors.New("agent key: not an RSA key")
		}
	case errors.Is(err, os.ErrNotExist):
		key, err = rsa.GenerateKey(rand.Reader, 2048)
		if err != nil {
			return nil, err
		}
		der, merr := x509.MarshalPKCS8PrivateKey(key)
		if merr != nil {
			return nil, merr
		}
		if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
			return nil, err
		}
		pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
		if err = os.WriteFile(path, pemBytes, 0o600); err != nil {
			return nil, err
		}
	default:
		return nil, err
	}
	return FromKey(key)
}

// FromKey derives the identity for an existing key.
func FromKey(key *rsa.PrivateKey) (*Identity, error) {
	if key.N.BitLen() != 2048 {
		return nil, errors.New("agent key must be RSA 2048")
	}
	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{Country: []string{"US"}, Organization: []string{"Android"}, CommonName: "Adb"},
		NotBefore:    time.Now().Add(-24 * time.Hour),
		NotAfter:     time.Now().AddDate(10, 0, 0),
		KeyUsage:     x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, err
	}
	name := "adbkit@agent"
	if h, herr := os.Hostname(); herr == nil && h != "" {
		name = "adbkit@" + h
	}
	return &Identity{
		Key:  key,
		Cert: tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key},
		Name: name,
	}, nil
}

// AndroidPublicKey encodes pub in the Android "mincrypt" RSAPublicKey layout
// (little-endian words, Montgomery constants for 32-bit limbs).
func AndroidPublicKey(pub *rsa.PublicKey) []byte {
	const words = 64
	n := pub.N
	out := make([]byte, 0, 4+4+256+256+4)
	out = binary.LittleEndian.AppendUint32(out, words)

	two32 := new(big.Int).Lsh(big.NewInt(1), 32)
	n0 := new(big.Int).Mod(n, two32)
	inv := new(big.Int).ModInverse(n0, two32)
	n0inv := uint32(new(big.Int).Sub(two32, inv).Uint64()) // -1/n[0] mod 2^32
	out = binary.LittleEndian.AppendUint32(out, n0inv)

	out = append(out, leBytes(n, 256)...)
	rr := new(big.Int).Lsh(big.NewInt(1), 4096)
	rr.Mod(rr, n)
	out = append(out, leBytes(rr, 256)...)
	out = binary.LittleEndian.AppendUint32(out, uint32(pub.E))
	return out
}

func leBytes(v *big.Int, size int) []byte {
	be := v.FillBytes(make([]byte, size))
	for i, j := 0, size-1; i < j; i, j = i+1, j-1 {
		be[i], be[j] = be[j], be[i]
	}
	return be
}

// AdbPublicKeyText returns "<base64 key> <name>\x00", the form adb stores and
// the pairing PeerInfo carries.
func (id *Identity) AdbPublicKeyText() []byte {
	text := base64.StdEncoding.EncodeToString(AndroidPublicKey(&id.Key.PublicKey)) + " " + id.Name
	return append([]byte(text), 0)
}
internal/mdns/mdns.go 114 lines
// Package mdns browses the local network for Wireless debugging services.
package mdns

import (
	"context"
	"strconv"
	"strings"
	"sync"
	"time"

	hmdns "github.com/hashicorp/mdns"

	"github.com/adbkit/agent/internal/scan"
)

// Endpoint is one discovered service.
type Endpoint struct {
	Name string `json:"name"`
	Host string `json:"host"`
	Port int    `json:"port"`
}

// Result of a discovery round.
type Result struct {
	Connect []Endpoint `json:"connect"`
	Pairing []Endpoint `json:"pairing"`
}

// Browser abstracts the mDNS query so handlers can be tested without a network.
type Browser func(ctx context.Context, service string, timeout time.Duration) ([]*hmdns.ServiceEntry, error)

// Query is the real Browser (IPv4 only).
func Query(ctx context.Context, service string, timeout time.Duration) ([]*hmdns.ServiceEntry, error) {
	entries := make(chan *hmdns.ServiceEntry, 32)
	var out []*hmdns.ServiceEntry
	done := make(chan struct{})
	go func() {
		for e := range entries {
			out = append(out, e)
		}
		close(done)
	}()
	err := hmdns.Query(&hmdns.QueryParam{
		Service:             service,
		Domain:              "local",
		Timeout:             timeout,
		Entries:             entries,
		DisableIPv6:         true,
		WantUnicastResponse: true,
	})
	close(entries)
	<-done
	return out, err
}

// Discover browses both service types in parallel.
func Discover(ctx context.Context, browse Browser, timeout time.Duration) Result {
	var res Result
	var wg sync.WaitGroup
	run := func(service string, dst *[]Endpoint) {
		defer wg.Done()
		entries, _ := browse(ctx, service, timeout)
		*dst = FromEntries(entries, service)
	}
	wg.Add(2)
	res.Connect, res.Pairing = []Endpoint{}, []Endpoint{}
	go run("_adb-tls-connect._tcp", &res.Connect)
	go run("_adb-tls-pairing._tcp", &res.Pairing)
	wg.Wait()
	return res
}

// FromEntries converts raw entries to endpoints: instance name, IPv4 private
// address only, de-duplicated.
func FromEntries(entries []*hmdns.ServiceEntry, service string) []Endpoint {
	out := []Endpoint{}
	seen := map[string]bool{}
	for _, e := range entries {
		if e == nil || e.AddrV4 == nil || e.Port == 0 {
			continue
		}
		host := e.AddrV4.String()
		if !scan.IsPrivateHost(host) {
			continue
		}
		name := e.Name
		if i := strings.Index(name, "."+service); i >= 0 {
			name = name[:i]
		} else if i := strings.Index(name, "._"); i >= 0 {
			name = name[:i]
		}
		key := host + ":" + strconv.Itoa(e.Port)
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, Endpoint{Name: name, Host: host, Port: e.Port})
	}
	return out
}

// ClampTimeout parses the timeout query (ms) into [500ms, 8000ms], default 3000.
func ClampTimeout(ms int, present bool) time.Duration {
	if !present {
		ms = 3000
	}
	if ms < 500 {
		ms = 500
	}
	if ms > 8000 {
		ms = 8000
	}
	return time.Duration(ms) * time.Millisecond
}
internal/pairing/pairing.go 253 lines
// Package pairing implements the Android 11+ Wireless debugging pairing
// protocol (AOSP adb/pairing_connection + pairing_auth): TLS 1.3, SPAKE2 keyed
// with the pairing code plus TLS exported keying material, then an
// AES-128-GCM protected PeerInfo exchange. Pairing codes and keys are never
// logged.
package pairing

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/hkdf"
	"crypto/sha256"
	"crypto/tls"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"net"
	"strconv"
	"strings"
	"time"
)

const (
	headerVersion  = 1
	typeSpake2Msg  = 0
	typePeerInfo   = 1
	peerInfoSize   = 8192 // 1 type byte + 8191 data bytes
	maxPayload     = peerInfoSize * 2
	exporterLabel  = "adb-label\x00"
	exporterLength = 64

	// PeerInfo types.
	PeerRSAPublicKey = 0
	PeerDeviceGUID   = 1
)

// Error classes surfaced over the HTTP API.
var (
	ErrUnreachable = errors.New("unreachable")
	ErrTimeout     = errors.New("timeout")
	ErrWrongCode   = errors.New("wrong-code")
	ErrProtocol    = errors.New("protocol")
)

// Result of a successful pairing.
type Result struct {
	// PeerName is the device's PeerInfo GUID (e.g. "adb-SERIAL-xxxxxx"), if sent.
	PeerName string
}

// Pair performs the client side of pairing against host:port with the given
// 6-digit code, registering publicKey (adb text form) with the phone.
func Pair(ctx context.Context, host string, port int, code string, cert tls.Certificate, publicKey []byte) (*Result, error) {
	dialer := net.Dialer{Timeout: 5 * time.Second}
	raw, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
	if err != nil {
		var ne net.Error
		if errors.As(err, &ne) && ne.Timeout() {
			return nil, ErrTimeout
		}
		return nil, ErrUnreachable
	}
	defer raw.Close()
	deadline := time.Now().Add(20 * time.Second)
	if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
		deadline = d
	}
	_ = raw.SetDeadline(deadline)

	tconn := tls.Client(raw, &tls.Config{
		MinVersion:         tls.VersionTLS13,
		MaxVersion:         tls.VersionTLS13,
		InsecureSkipVerify: true, // adb does not verify the peer during pairing; SPAKE2 authenticates it.
		GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
			return &cert, nil
		},
	})
	if err := tconn.HandshakeContext(ctx); err != nil {
		return nil, classify(err, ErrProtocol)
	}
	state := tconn.ConnectionState()
	exported, err := state.ExportKeyingMaterial(exporterLabel, nil, exporterLength)
	if err != nil {
		return nil, ErrProtocol
	}
	password := append([]byte(code), exported...)
	peer, err := exchange(tconn, Client, password, PeerInfo(PeerRSAPublicKey, publicKey))
	if err != nil {
		return nil, err
	}
	res := &Result{}
	if peer[0] == PeerDeviceGUID {
		res.PeerName = cString(peer[1:])
	}
	return res, nil
}

// PeerInfo builds the fixed-size PeerInfo struct.
func PeerInfo(kind byte, data []byte) []byte {
	buf := make([]byte, peerInfoSize)
	buf[0] = kind
	copy(buf[1:], data)
	return buf
}

func cString(b []byte) string {
	if i := strings.IndexByte(string(b), 0); i >= 0 {
		b = b[:i]
	}
	return string(b)
}

func classify(err error, fallback error) error {
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		return ErrTimeout
	}
	return fallback
}

func writePacket(w io.Writer, kind byte, payload []byte) error {
	hdr := make([]byte, 6, 6+len(payload))
	hdr[0] = headerVersion
	hdr[1] = kind
	binary.BigEndian.PutUint32(hdr[2:], uint32(len(payload)))
	_, err := w.Write(append(hdr, payload...))
	return err
}

func readPacket(r io.Reader, want byte) ([]byte, error) {
	var hdr [6]byte
	if _, err := io.ReadFull(r, hdr[:]); err != nil {
		return nil, err
	}
	if hdr[0] < 1 || hdr[0] > headerVersion {
		return nil, fmt.Errorf("unsupported header version %d", hdr[0])
	}
	if hdr[1] != want {
		return nil, fmt.Errorf("unexpected packet type %d", hdr[1])
	}
	n := binary.BigEndian.Uint32(hdr[2:])
	if n == 0 || n > maxPayload {
		return nil, errors.New("bad payload size")
	}
	buf := make([]byte, n)
	_, err := io.ReadFull(r, buf)
	return buf, err
}

// aead is AES-128-GCM keyed from the SPAKE2 key, with 12-byte little-endian
// counter nonces (independent send and receive counters).
type aead struct {
	gcm      cipher.AEAD
	enc, dec uint64
}

func newAEAD(spakeKey []byte) (*aead, error) {
	key, err := hkdf.Key(sha256.New, spakeKey, nil, "adb pairing_auth aes-128-gcm key", 16)
	if err != nil {
		return nil, err
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	return &aead{gcm: gcm}, nil
}

func nonce(counter uint64) []byte {
	n := make([]byte, 12)
	binary.LittleEndian.PutUint64(n, counter)
	return n
}

func (a *aead) seal(pt []byte) []byte {
	out := a.gcm.Seal(nil, nonce(a.enc), pt, nil)
	a.enc++
	return out
}

func (a *aead) open(ct []byte) ([]byte, error) {
	out, err := a.gcm.Open(nil, nonce(a.dec), ct, nil)
	a.dec++
	return out, err
}

// exchange runs SPAKE2 and the PeerInfo swap over an established channel.
// Errors are already mapped to the package's error classes: failures after the
// SPAKE2 messages are exchanged (decrypt failure, peer hanging up) mean the
// code was wrong.
func exchange(conn io.ReadWriter, role Role, password []byte, myInfo []byte) ([]byte, error) {
	s, err := newSpake2(role, password)
	if err != nil {
		return nil, ErrProtocol
	}
	if err := writePacket(conn, typeSpake2Msg, s.Msg()); err != nil {
		return nil, classify(err, ErrProtocol)
	}
	theirMsg, err := readPacket(conn, typeSpake2Msg)
	if err != nil {
		return nil, classify(err, ErrProtocol)
	}
	key, err := s.ProcessMsg(theirMsg)
	if err != nil {
		return nil, ErrProtocol
	}
	a, err := newAEAD(key)
	if err != nil {
		return nil, ErrProtocol
	}
	if err := writePacket(conn, typePeerInfo, a.seal(myInfo)); err != nil {
		return nil, classify(err, ErrWrongCode)
	}
	enc, err := readPacket(conn, typePeerInfo)
	if err != nil {
		return nil, classify(err, ErrWrongCode)
	}
	info, err := a.open(enc)
	if err != nil || len(info) != peerInfoSize {
		return nil, ErrWrongCode
	}
	return info, nil
}

// Serve runs the server role (what a phone's pairing server does) on an
// accepted connection: TLS 1.3 server, client certificate requested but not
// verified, same exporter, SPAKE2 server identity. myInfo is the PeerInfo sent
// back; the client's PeerInfo is returned. Used by tests and tooling; the
// agent itself pairs as the client.
func Serve(conn net.Conn, cert tls.Certificate, password string, myInfo []byte, timeout time.Duration) ([]byte, error) {
	_ = conn.SetDeadline(time.Now().Add(timeout))
	tc := tls.Server(conn, &tls.Config{
		MinVersion:   tls.VersionTLS13,
		MaxVersion:   tls.VersionTLS13,
		Certificates: []tls.Certificate{cert},
		ClientAuth:   tls.RequestClientCert,
	})
	if err := tc.Handshake(); err != nil {
		return nil, classify(err, ErrProtocol)
	}
	state := tc.ConnectionState()
	exported, err := state.ExportKeyingMaterial(exporterLabel, nil, exporterLength)
	if err != nil {
		return nil, ErrProtocol
	}
	return exchange(tc, Server, append([]byte(password), exported...), myInfo)
}
internal/pairing/spake2.go 136 lines
package pairing

import (
	"crypto/rand"
	"crypto/sha512"
	"encoding/binary"
	"encoding/hex"
	"errors"

	"filippo.io/edwards25519"
)

// Role of a SPAKE2 participant. adb's client is Alice, its server is Bob.
type Role int

const (
	Client Role = iota
	Server
)

var (
	clientName = []byte("adb pair client\x00")
	serverName = []byte("adb pair server\x00")
)

// Fixed SPAKE2 points from BoringSSL crypto/curve25519/spake25519.cc.
const (
	pointMHex = "5ada7e4bf6ddd9adb6626d32131c6b5c51a1e347a3478f53cfcf441b88eed12e"
	pointNHex = "10e3df0ae37d8e7a99b5fe74b44672103dbddcbd06af680d71329a11693bc778"
)

var pointM, pointN = mustPoint(pointMHex), mustPoint(pointNHex)

func mustPoint(h string) *edwards25519.Point {
	b, _ := hex.DecodeString(h)
	p, err := edwards25519.NewIdentityPoint().SetBytes(b)
	if err != nil {
		panic(err)
	}
	return p
}

// spake2 implements the BoringSSL SPAKE2 variant over edwards25519.
type spake2 struct {
	role         Role
	x            *edwards25519.Scalar
	w            *edwards25519.Scalar
	passwordHash [64]byte
	myMsg        []byte
}

func mul8(p *edwards25519.Point) *edwards25519.Point {
	r := new(edwards25519.Point).Add(p, p)
	r.Add(r, r)
	return r.Add(r, r)
}

// newSpake2 generates this side's message for the given password.
func newSpake2(role Role, password []byte) (*spake2, error) {
	return newSpake2WithRand(role, password, func(b []byte) { _, _ = rand.Read(b) })
}

func newSpake2WithRand(role Role, password []byte, fill func([]byte)) (*spake2, error) {
	s := &spake2{role: role}
	var seed [64]byte
	fill(seed[:])
	x, err := new(edwards25519.Scalar).SetUniformBytes(seed[:])
	if err != nil {
		return nil, err
	}
	s.x = x
	s.passwordHash = sha512.Sum512(password)
	s.w, err = new(edwards25519.Scalar).SetUniformBytes(s.passwordHash[:])
	if err != nil {
		return nil, err
	}
	// P = 8*x*B (the cofactor clears small-order components later).
	p := mul8(new(edwards25519.Point).ScalarBaseMult(x))
	mask := new(edwards25519.Point).ScalarMult(s.w, s.myPoint())
	s.myMsg = p.Add(p, mask).Bytes()
	return s, nil
}

func (s *spake2) myPoint() *edwards25519.Point {
	if s.role == Client {
		return pointM
	}
	return pointN
}

func (s *spake2) peerPoint() *edwards25519.Point {
	if s.role == Client {
		return pointN
	}
	return pointM
}

// Msg is the 32-byte message to send to the peer.
func (s *spake2) Msg() []byte { return s.myMsg }

func lenPrefixed(h interface{ Write([]byte) (int, error) }, data []byte) {
	var l [8]byte
	binary.LittleEndian.PutUint64(l[:], uint64(len(data)))
	_, _ = h.Write(l[:])
	_, _ = h.Write(data)
}

// ProcessMsg consumes the peer's message and returns the 64-byte shared key.
func (s *spake2) ProcessMsg(their []byte) ([]byte, error) {
	if len(their) != 32 {
		return nil, errors.New("spake2: bad message length")
	}
	qStar, err := edwards25519.NewIdentityPoint().SetBytes(their)
	if err != nil {
		return nil, errors.New("spake2: bad point")
	}
	peerMask := new(edwards25519.Point).ScalarMult(s.w, s.peerPoint())
	q := new(edwards25519.Point).Subtract(qStar, peerMask)
	dh := mul8(new(edwards25519.Point).ScalarMult(s.x, q))

	h := sha512.New()
	if s.role == Client {
		lenPrefixed(h, clientName)
		lenPrefixed(h, serverName)
		lenPrefixed(h, s.myMsg)
		lenPrefixed(h, their)
	} else {
		lenPrefixed(h, clientName)
		lenPrefixed(h, serverName)
		lenPrefixed(h, their)
		lenPrefixed(h, s.myMsg)
	}
	lenPrefixed(h, dh.Bytes())
	lenPrefixed(h, s.passwordHash[:])
	return h.Sum(nil), nil
}
internal/qrpair/qrpair.go 284 lines
// Package qrpair runs QR-code pairing sessions.
//
// Android's Wireless debugging > "Pair device with QR code" scans a code shown
// on the computer ("WIFI:T:ADB;S:<instance>;P:<password>;;"). The phone then
// starts its own pairing *server* (AdbDebuggingManager.PairingThread:
// native_pairing_start with the password from the code) and advertises mDNS
// _adb-tls-pairing._tcp under the instance name from the code. The computer
// therefore stays the pairing CLIENT: it browses mDNS for that instance and
// runs the ordinary pairing handshake with the QR password. No listener and no
// mDNS advertisement are needed on the computer.
package qrpair

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"sync"
	"time"

	"github.com/adbkit/agent/internal/mdns"
	"github.com/adbkit/agent/internal/pairing"
	"github.com/adbkit/agent/internal/scan"
)

// States reported to the page.
const (
	StateWaiting   = "waiting"
	StatePaired    = "paired"
	StateExpired   = "expired"
	StateCancelled = "cancelled"
	StateFailed    = "failed"
)

// Failure reasons.
const (
	ReasonTooManyAttempts = "too-many-attempts"
	ReasonProtocol        = "protocol"
	ReasonNoNetwork       = "no-network"
)

// ErrNoNetwork means the computer has no private LAN IPv4 address.
var ErrNoNetwork = errors.New("no-network")

const pairingService = "_adb-tls-pairing._tcp"

// Config wires a Manager; zero values pick production defaults.
type Config struct {
	Browse        mdns.Browser
	Pair          func(ctx context.Context, host string, port int, code string) (*pairing.Result, error)
	HasNetwork    func() bool
	Expiry        time.Duration // default 120s
	PollInterval  time.Duration // pause between mDNS rounds, default 500ms
	BrowseTimeout time.Duration // per mDNS round, default 1500ms
	PairTimeout   time.Duration // per pairing attempt, default 25s
	MaxFailures   int           // default 3
	// NewSecrets returns (instance, password, id); tests override it.
	NewSecrets func() (instance, password, id string)
}

// Started is returned by Start.
type Started struct {
	ID        string
	Payload   string
	ExpiresIn int // seconds
}

// Status is the state reported by GET.
type Status struct {
	State      string
	DeviceName string
	Reason     string
}

type session struct {
	cancel   context.CancelFunc
	mu       sync.Mutex
	status   Status
	finished time.Time
}

func (s *session) finish(st Status) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.status.State != StateWaiting {
		return
	}
	s.status = st
	s.finished = time.Now()
}

// Manager owns at most one active session.
type Manager struct {
	cfg      Config
	mu       sync.Mutex
	sessions map[string]*session
	active   *session
}

// New builds a Manager.
func New(cfg Config) *Manager {
	if cfg.Expiry == 0 {
		cfg.Expiry = 120 * time.Second
	}
	if cfg.PollInterval == 0 {
		cfg.PollInterval = 500 * time.Millisecond
	}
	if cfg.BrowseTimeout == 0 {
		cfg.BrowseTimeout = 1500 * time.Millisecond
	}
	if cfg.PairTimeout == 0 {
		cfg.PairTimeout = 25 * time.Second
	}
	if cfg.MaxFailures == 0 {
		cfg.MaxFailures = 3
	}
	if cfg.Browse == nil {
		cfg.Browse = mdns.Query
	}
	if cfg.HasNetwork == nil {
		cfg.HasNetwork = func() bool { return len(scan.LocalHosts()) > 0 }
	}
	if cfg.NewSecrets == nil {
		cfg.NewSecrets = randomSecrets
	}
	return &Manager{cfg: cfg, sessions: map[string]*session{}}
}

const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

func randomString(n int, alphabet string) string {
	out := make([]byte, 0, n)
	max := byte(256 - 256%len(alphabet)) // rejection sampling: no modulo bias
	buf := make([]byte, 32)
	for len(out) < n {
		if _, err := rand.Read(buf); err != nil {
			panic(err)
		}
		for _, b := range buf {
			if b < max && len(out) < n {
				out = append(out, alphabet[int(b)%len(alphabet)])
			}
		}
	}
	return string(out)
}

func randomSecrets() (string, string, string) {
	var raw [4]byte
	if _, err := rand.Read(raw[:]); err != nil {
		panic(err)
	}
	return "adbkit-" + hex.EncodeToString(raw[:]), randomString(12, alnum),
		randomString(16, "abcdefghijklmnopqrstuvwxyz0123456789")
}

// Start cancels any running session and begins a new one.
func (m *Manager) Start() (*Started, error) {
	if !m.cfg.HasNetwork() {
		return nil, ErrNoNetwork
	}
	instance, password, id := m.cfg.NewSecrets()
	ctx, cancel := context.WithCancel(context.Background())
	s := &session{cancel: cancel, status: Status{State: StateWaiting}}

	m.mu.Lock()
	if m.active != nil {
		m.active.finish(Status{State: StateCancelled})
		m.active.cancel()
	}
	m.active = s
	m.sessions[id] = s
	for k, old := range m.sessions { // keep finished sessions queryable for 10 minutes
		old.mu.Lock()
		stale := !old.finished.IsZero() && time.Since(old.finished) > 10*time.Minute
		old.mu.Unlock()
		if stale && old != s {
			delete(m.sessions, k)
		}
	}
	m.mu.Unlock()

	go m.run(ctx, s, instance, password)
	return &Started{
		ID:        id,
		Payload:   "WIFI:T:ADB;S:" + instance + ";P:" + password + ";;",
		ExpiresIn: int(m.cfg.Expiry / time.Second),
	}, nil
}

// Status returns the session state; ok is false for unknown ids.
func (m *Manager) Status(id string) (Status, bool) {
	m.mu.Lock()
	s := m.sessions[id]
	m.mu.Unlock()
	if s == nil {
		return Status{}, false
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.status, true
}

// Cancel stops a session; ok is false for unknown ids. Finished sessions keep
// their final state.
func (m *Manager) Cancel(id string) bool {
	m.mu.Lock()
	s := m.sessions[id]
	m.mu.Unlock()
	if s == nil {
		return false
	}
	s.finish(Status{State: StateCancelled})
	s.cancel()
	return true
}

// run browses for the phone's pairing service and pairs with the QR password.
// The password is only ever passed to pairing.Pair; it is never logged or
// placed in an error.
func (m *Manager) run(ctx context.Context, s *session, instance, password string) {
	defer s.cancel()
	expiry := time.NewTimer(m.cfg.Expiry)
	defer expiry.Stop()
	failures, wrong := 0, 0
	for {
		select {
		case <-ctx.Done():
			s.finish(Status{State: StateCancelled})
			return
		case <-expiry.C:
			s.finish(Status{State: StateExpired})
			return
		default:
		}

		bctx, bcancel := context.WithTimeout(ctx, m.cfg.BrowseTimeout+time.Second)
		entries, _ := m.cfg.Browse(bctx, pairingService, m.cfg.BrowseTimeout)
		bcancel()
		for _, ep := range mdns.FromEntries(entries, pairingService) {
			if ep.Name != instance {
				continue
			}
			pctx, pcancel := context.WithTimeout(ctx, m.cfg.PairTimeout)
			res, err := m.cfg.Pair(pctx, ep.Host, ep.Port, password)
			pcancel()
			if ctx.Err() != nil {
				break
			}
			switch {
			case err == nil:
				name := ""
				if res != nil {
					name = res.PeerName
				}
				s.finish(Status{State: StatePaired, DeviceName: name})
				return
			case errors.Is(err, pairing.ErrUnreachable):
				// stale advertisement; keep waiting
			default:
				failures++
				if errors.Is(err, pairing.ErrWrongCode) {
					wrong++
				}
				if failures >= m.cfg.MaxFailures {
					reason := ReasonProtocol
					if wrong > 0 {
						reason = ReasonTooManyAttempts
					}
					s.finish(Status{State: StateFailed, Reason: reason})
					return
				}
			}
			break
		}
		select {
		case <-ctx.Done():
		case <-expiry.C:
			s.finish(Status{State: StateExpired})
			return
		case <-time.After(m.cfg.PollInterval):
		}
	}
}
internal/relay/relay.go 94 lines
package relay

import (
	"errors"
	"io"
	"net"
	"strings"
	"syscall"

	"github.com/adbkit/agent/internal/tlsconnect"
)

// ErrUnreachable marks a failure to open the TCP connection to the phone.
var ErrUnreachable = errors.New("unreachable")

// DialClose is the close code for a failed dial to the phone: 4004 when the
// phone refused the connection (port closed), 4002 for anything else.
func DialClose(err error) (int, string) {
	// 10061 is WSAECONNREFUSED, what Windows reports for the same thing.
	if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.Errno(10061)) {
		return CloseRefused, "refused"
	}
	return CloseUnreachble, "unreachable"
}

// CloseFor maps a relay error to a WebSocket close code and reason.
//
//	nil / EOF / peer reset after a normal session -> 1000
//	not paired (TLS rejected)                      -> 4001 not-paired
//	cannot reach the phone                         -> 4002 unreachable
//	adb/TLS protocol failure                       -> 4003 protocol
//	anything else                                  -> 1011
func CloseFor(err error) (int, string) {
	switch {
	case err == nil, errors.Is(err, io.EOF), errors.Is(err, ErrClosed),
		errors.Is(err, net.ErrClosed), errors.Is(err, io.ErrClosedPipe),
		errors.Is(err, syscall.ECONNRESET):
		return CloseNormal, ""
	case errors.Is(err, tlsconnect.ErrNotPaired):
		return CloseNotPaired, "not-paired"
	case errors.Is(err, ErrUnreachable):
		return CloseUnreachble, "unreachable"
	case errors.Is(err, tlsconnect.ErrProtocol):
		return CloseProtocol, "protocol"
	}
	if strings.Contains(err.Error(), "tls:") {
		return CloseProtocol, "protocol"
	}
	return CloseInternal, "error"
}

// Pipe pumps bytes between ws and conn until either side ends, then closes
// both. The WebSocket close code reflects how the phone side ended.
func Pipe(ws *WS, conn io.ReadWriteCloser) {
	phoneDone := make(chan error, 1)
	go func() {
		buf := make([]byte, 64<<10)
		for {
			n, err := conn.Read(buf)
			if n > 0 {
				if werr := ws.WriteBinary(buf[:n]); werr != nil {
					phoneDone <- nil
					return
				}
			}
			if err != nil {
				phoneDone <- err
				return
			}
		}
	}()
	browserDone := make(chan struct{})
	go func() {
		defer close(browserDone)
		for {
			msg, err := ws.ReadMessage()
			if err != nil {
				return
			}
			if _, err := conn.Write(msg); err != nil {
				return
			}
		}
	}()
	select {
	case err := <-phoneDone:
		code, reason := CloseFor(err)
		ws.Close(code, reason)
		conn.Close()
	case <-browserDone:
		conn.Close()
		ws.Close(CloseNormal, "")
	}
}
internal/relay/ws.go 194 lines
// Package relay holds the minimal server-side WebSocket used by /connect and
// the byte pump between a WebSocket and a TCP (or TLS-shimmed) connection.
package relay

import (
	"bufio"
	"crypto/sha1" //nolint:gosec // mandated by RFC 6455
	"encoding/base64"
	"encoding/binary"
	"errors"
	"io"
	"net"
	"net/http"
	"strings"
	"sync"
)

const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

// MaxMessage caps one reassembled WebSocket message (adb payloads are <= 1 MiB).
const MaxMessage = 16 << 20

// WebSocket close codes used by the agent.
const (
	CloseNormal     = 1000
	CloseInternal   = 1011
	CloseNotPaired  = 4001
	CloseUnreachble = 4002
	CloseProtocol   = 4003
	// CloseRefused: the phone answered but nothing listens on the port, so
	// network debugging is off (Android turns adb tcpip off at every restart).
	CloseRefused = 4004
)

// WS is a server-side WebSocket carrying binary messages.
type WS struct {
	conn net.Conn
	rd   *bufio.Reader
	wmu  sync.Mutex
	done bool
}

// Accept validates the upgrade request, hijacks the connection and answers 101.
// Callers must have already decided the request is allowed.
func Accept(w http.ResponseWriter, r *http.Request) (*WS, error) {
	key := r.Header.Get("Sec-WebSocket-Key")
	if key == "" || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") ||
		!headerHasToken(r.Header, "Connection", "upgrade") {
		http.Error(w, "bad websocket request", http.StatusBadRequest)
		return nil, errors.New("not a websocket upgrade")
	}
	hj, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "no hijack", http.StatusInternalServerError)
		return nil, errors.New("hijack unsupported")
	}
	conn, brw, err := hj.Hijack()
	if err != nil {
		return nil, err
	}
	sum := sha1.Sum([]byte(key + wsGUID)) //nolint:gosec
	accept := base64.StdEncoding.EncodeToString(sum[:])
	_, err = brw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n")
	if err == nil {
		err = brw.Flush()
	}
	if err != nil {
		conn.Close()
		return nil, err
	}
	if tc, ok := conn.(*net.TCPConn); ok {
		_ = tc.SetNoDelay(true)
	}
	return &WS{conn: conn, rd: brw.Reader}, nil
}

func headerHasToken(h http.Header, name, token string) bool {
	for _, v := range h.Values(name) {
		for _, t := range strings.Split(v, ",") {
			if strings.EqualFold(strings.TrimSpace(t), token) {
				return true
			}
		}
	}
	return false
}

func encodeFrame(opcode byte, payload []byte) []byte {
	n := len(payload)
	var hdr []byte
	switch {
	case n < 126:
		hdr = []byte{0x80 | opcode, byte(n)}
	case n < 65536:
		hdr = []byte{0x80 | opcode, 126, 0, 0}
		binary.BigEndian.PutUint16(hdr[2:], uint16(n))
	default:
		hdr = make([]byte, 10)
		hdr[0], hdr[1] = 0x80|opcode, 127
		binary.BigEndian.PutUint64(hdr[2:], uint64(n))
	}
	return append(hdr, payload...)
}

func (w *WS) write(opcode byte, payload []byte) error {
	w.wmu.Lock()
	defer w.wmu.Unlock()
	if w.done && opcode != 0x8 {
		return io.ErrClosedPipe
	}
	_, err := w.conn.Write(encodeFrame(opcode, payload))
	return err
}

// WriteBinary sends one binary message.
func (w *WS) WriteBinary(p []byte) error { return w.write(0x2, p) }

// Close sends a close frame with code/reason and closes the socket.
func (w *WS) Close(code int, reason string) {
	w.wmu.Lock()
	if !w.done {
		w.done = true
		payload := make([]byte, 2, 2+len(reason))
		binary.BigEndian.PutUint16(payload, uint16(code))
		payload = append(payload, reason...)
		_, _ = w.conn.Write(encodeFrame(0x8, payload))
	}
	w.wmu.Unlock()
	_ = w.conn.Close()
}

// ErrClosed is returned by ReadMessage after the peer sent a close frame.
var ErrClosed = errors.New("websocket closed")

// ReadMessage returns the next data message, handling ping/pong/close frames.
func (w *WS) ReadMessage() ([]byte, error) {
	var msg []byte
	for {
		var h [2]byte
		if _, err := io.ReadFull(w.rd, h[:]); err != nil {
			return nil, err
		}
		fin := h[0]&0x80 != 0
		opcode := h[0] & 0x0f
		masked := h[1]&0x80 != 0
		length := uint64(h[1] & 0x7f)
		switch length {
		case 126:
			var b [2]byte
			if _, err := io.ReadFull(w.rd, b[:]); err != nil {
				return nil, err
			}
			length = uint64(binary.BigEndian.Uint16(b[:]))
		case 127:
			var b [8]byte
			if _, err := io.ReadFull(w.rd, b[:]); err != nil {
				return nil, err
			}
			length = binary.BigEndian.Uint64(b[:])
		}
		if length > MaxMessage || uint64(len(msg))+length > MaxMessage {
			return nil, errors.New("websocket message too large")
		}
		var mask [4]byte
		if masked {
			if _, err := io.ReadFull(w.rd, mask[:]); err != nil {
				return nil, err
			}
		}
		payload := make([]byte, length)
		if _, err := io.ReadFull(w.rd, payload); err != nil {
			return nil, err
		}
		if masked {
			for i := range payload {
				payload[i] ^= mask[i%4]
			}
		}
		switch opcode {
		case 0x8:
			w.Close(CloseNormal, "")
			return nil, ErrClosed
		case 0x9:
			_ = w.write(0xa, payload)
			continue
		case 0xa:
			continue
		}
		msg = append(msg, payload...)
		if fin {
			return msg, nil
		}
	}
}
internal/scan/scan.go 137 lines
// Package scan finds private-network hosts accepting TCP on a port and holds
// the private-host policy shared by every endpoint.
package scan

import (
	"context"
	"net"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Device is one scan hit.
type Device struct {
	Host string `json:"host"`
	Port int    `json:"port"`
}

// IsPrivateHost reports whether host is an IPv4 literal in loopback, RFC 1918
// or link-local space. The agent must never be a way onto the internet.
func IsPrivateHost(host string) bool {
	parts := strings.Split(host, ".")
	if len(parts) != 4 {
		return false
	}
	var o [4]int
	for i, p := range parts {
		if p == "" || len(p) > 3 {
			return false
		}
		n := 0
		for _, c := range p {
			if c < '0' || c > '9' {
				return false
			}
			n = n*10 + int(c-'0')
		}
		if n > 255 {
			return false
		}
		o[i] = n
	}
	a, b := o[0], o[1]
	return a == 10 || a == 127 || (a == 172 && b >= 16 && b <= 31) ||
		(a == 192 && b == 168) || (a == 169 && b == 254)
}

// LocalHosts lists every host of each local private /24 (excluding .0/.255).
func LocalHosts() []string {
	var addrs []net.Addr
	ifaces, _ := net.Interfaces()
	for _, ifc := range ifaces {
		a, _ := ifc.Addrs()
		addrs = append(addrs, a...)
	}
	return hostsFromAddrs(addrs)
}

func hostsFromAddrs(addrs []net.Addr) []string {
	seen := map[string]bool{}
	var hosts []string
	for _, a := range addrs {
		ipn, ok := a.(*net.IPNet)
		if !ok {
			continue
		}
		ip := ipn.IP.To4()
		if ip == nil || ip.IsLoopback() || !IsPrivateHost(ip.String()) {
			continue
		}
		key := strconv.Itoa(int(ip[0])) + "." + strconv.Itoa(int(ip[1])) + "." + strconv.Itoa(int(ip[2]))
		if seen[key] {
			continue
		}
		seen[key] = true
		for i := 1; i <= 254; i++ {
			hosts = append(hosts, key+"."+strconv.Itoa(i))
		}
	}
	return hosts
}

// Hosts probes hosts on port with up to 128 workers and returns the hits
// sorted numerically by address.
func Hosts(ctx context.Context, hosts []string, port int, timeout time.Duration) []Device {
	found := []Device{}
	var mu sync.Mutex
	jobs := make(chan string)
	var wg sync.WaitGroup
	workers := 128
	if len(hosts) < workers {
		workers = len(hosts)
	}
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for h := range jobs {
				d := net.Dialer{Timeout: timeout}
				c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(h, strconv.Itoa(port)))
				if err == nil {
					c.Close()
					mu.Lock()
					found = append(found, Device{Host: h, Port: port})
					mu.Unlock()
				}
			}
		}()
	}
loop:
	for _, h := range hosts {
		select {
		case jobs <- h:
		case <-ctx.Done():
			break loop
		}
	}
	close(jobs)
	wg.Wait()
	sort.Slice(found, func(i, j int) bool { return ipLess(found[i].Host, found[j].Host) })
	return found
}

func ipLess(a, b string) bool {
	ia, ib := net.ParseIP(a).To4(), net.ParseIP(b).To4()
	if ia == nil || ib == nil {
		return a < b
	}
	for i := 0; i < 4; i++ {
		if ia[i] != ib[i] {
			return ia[i] < ib[i]
		}
	}
	return false
}
internal/server/server.go 339 lines
// Package server is the agent's HTTP/WebSocket surface (protocol v2). It binds
// loopback only (enforced by the caller), answers only allow-listed origins,
// and only relays to private addresses.
package server

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/adbkit/agent/internal/mdns"
	"github.com/adbkit/agent/internal/pairing"
	"github.com/adbkit/agent/internal/qrpair"
	"github.com/adbkit/agent/internal/relay"
	"github.com/adbkit/agent/internal/scan"
)

// DefaultPort is the agent's loopback port.
const DefaultPort = 8730

// DefaultOrigins match bridge/adbkit-bridge.mjs.
var DefaultOrigins = []string{
	"http://localhost:3000",
	"http://127.0.0.1:3000",
	"https://adbkit.com",
	"https://www.adbkit.com",
}

// ProtocolVersion is the agent protocol this server speaks.
const ProtocolVersion = 2

// Capabilities advertised in /health.

var codeRe = regexp.MustCompile(`^[0-9]{6}$`)

// Config wires the server. Zero values for the funcs use the real ones where
// possible; Identity-dependent funcs must be supplied by main (or tests).
type Config struct {
	Version     string
	Origins     []string
	Scan        func(ctx context.Context, port int) []scan.Device
	Browse      mdns.Browser
	Pair        func(ctx context.Context, host string, port int, code string) (*pairing.Result, error)
	WrapTLS     func(raw net.Conn) io.ReadWriteCloser // nil: tls=1 unsupported
	QR          *qrpair.Manager                       // nil: pair-qr unsupported
	DialTimeout time.Duration
}

// Server implements http.Handler.
type Server struct {
	cfg     Config
	allowed map[string]bool
	pairMu  sync.Mutex
}

// New builds a Server.
func New(cfg Config) *Server {
	if cfg.DialTimeout == 0 {
		cfg.DialTimeout = 5 * time.Second
	}
	if cfg.Scan == nil {
		cfg.Scan = func(ctx context.Context, port int) []scan.Device {
			return scan.Hosts(ctx, scan.LocalHosts(), port, 500*time.Millisecond)
		}
	}
	if cfg.Browse == nil {
		cfg.Browse = mdns.Query
	}
	s := &Server{cfg: cfg, allowed: map[string]bool{}}
	for _, o := range cfg.Origins {
		s.allowed[o] = true
	}
	return s
}

// OriginAllowed reports whether origin is on the allow-list.
func (s *Server) OriginAllowed(origin string) bool { return origin != "" && s.allowed[origin] }

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// loopbackHost guards against DNS rebinding: the Host header must be loopback.
func loopbackHost(hostport string) bool {
	h := hostport
	if hh, _, err := net.SplitHostPort(hostport); err == nil {
		h = hh
	}
	h = strings.Trim(h, "[]")
	return h == "127.0.0.1" || h == "localhost" || h == "::1"
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	origin := r.Header.Get("Origin")
	allowed := s.OriginAllowed(origin)
	if allowed {
		h := w.Header()
		h.Set("Access-Control-Allow-Origin", origin)
		h.Set("Access-Control-Allow-Private-Network", "true")
		h.Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
		h.Set("Access-Control-Allow-Headers", "Content-Type")
		h.Add("Vary", "Origin")
	}
	if !loopbackHost(r.Host) {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad-host"})
		return
	}
	if r.Method == http.MethodOptions {
		if allowed {
			w.WriteHeader(http.StatusNoContent)
		} else {
			writeJSON(w, http.StatusForbidden, map[string]string{"error": "origin-not-allowed"})
		}
		return
	}
	if r.URL.Path == "/health" {
		writeJSON(w, http.StatusOK, map[string]any{
			"name": "adbkit-agent", "version": s.cfg.Version, "protocol": ProtocolVersion,
			"runtime": "go", "capabilities": s.capabilities(),
		})
		return
	}
	if !allowed {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "origin-not-allowed"})
		return
	}
	if r.URL.Path == "/pair/qr" || strings.HasPrefix(r.URL.Path, "/pair/qr/") {
		s.handleQR(w, r)
		return
	}
	switch r.URL.Path {
	case "/scan":
		s.handleScan(w, r)
	case "/discover":
		s.handleDiscover(w, r)
	case "/pair":
		s.handlePair(w, r)
	case "/connect":
		s.handleConnect(w, r)
	default:
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not-found"})
	}
}

func (s *Server) capabilities() []string {
	caps := []string{"tcpip", "mdns"}
	if s.cfg.Pair != nil {
		caps = append(caps, "pair")
	}
	if s.cfg.WrapTLS != nil {
		caps = append(caps, "tls")
	}
	if s.cfg.QR != nil {
		caps = append(caps, "pair-qr")
	}
	return caps
}

func parsePort(v string, def int) (int, bool) {
	if v == "" {
		return def, true
	}
	n, err := strconv.Atoi(v)
	if err != nil || n < 1 || n > 65535 {
		return 0, false
	}
	return n, true
}

func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
	port, ok := parsePort(r.URL.Query().Get("port"), 5555)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-port"})
		return
	}
	devices := s.cfg.Scan(r.Context(), port)
	if devices == nil {
		devices = []scan.Device{}
	}
	writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}

func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
	ms, present := 0, false
	if v := r.URL.Query().Get("timeout"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad-timeout"})
			return
		}
		ms, present = n, true
	}
	res := mdns.Discover(r.Context(), s.cfg.Browse, mdns.ClampTimeout(ms, present))
	writeJSON(w, http.StatusOK, res)
}

type pairRequest struct {
	Host string `json:"host"`
	Port int    `json:"port"`
	Code string `json:"code"`
}

func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
	fail := func(code string) { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": code}) }
	if r.Method != http.MethodPost || s.cfg.Pair == nil {
		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method-not-allowed"})
		return
	}
	var req pairRequest
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
	if err := dec.Decode(&req); err != nil || !scan.IsPrivateHost(req.Host) ||
		req.Port < 1 || req.Port > 65535 || !codeRe.MatchString(req.Code) {
		fail("bad-request")
		return
	}
	s.pairMu.Lock()
	defer s.pairMu.Unlock()
	ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
	defer cancel()
	res, err := s.cfg.Pair(ctx, req.Host, req.Port, req.Code)
	switch {
	case err == nil:
		out := map[string]any{"ok": true}
		if res != nil && res.PeerName != "" {
			out["deviceName"] = res.PeerName
		}
		writeJSON(w, http.StatusOK, out)
	case errors.Is(err, pairing.ErrWrongCode):
		fail("wrong-code")
	case errors.Is(err, pairing.ErrUnreachable):
		fail("unreachable")
	case errors.Is(err, pairing.ErrTimeout):
		fail("timeout")
	default:
		fail("protocol")
	}
}

func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	host := q.Get("host")
	port, ok := parsePort(q.Get("port"), 5555)
	useTLS := q.Get("tls") == "1"
	if !scan.IsPrivateHost(host) || !ok {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	if useTLS && s.cfg.WrapTLS == nil {
		http.Error(w, "tls unsupported", http.StatusNotImplemented)
		return
	}
	if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") || r.Header.Get("Sec-WebSocket-Key") == "" {
		http.Error(w, "websocket upgrade required", http.StatusBadRequest)
		return
	}
	addr := net.JoinHostPort(host, strconv.Itoa(port))
	dialer := net.Dialer{Timeout: s.cfg.DialTimeout}

	// Accept first so a failure can carry a close code (4001-4004) that says
	// why, which an HTTP error on the upgrade cannot.
	ws, err := relay.Accept(w, r)
	if err != nil {
		return
	}
	raw, err := dialer.DialContext(r.Context(), "tcp", addr)
	if err != nil {
		ws.Close(relay.DialClose(err))
		return
	}
	if useTLS {
		relay.Pipe(ws, s.cfg.WrapTLS(raw))
		return
	}
	if tc, ok := raw.(*net.TCPConn); ok {
		_ = tc.SetNoDelay(true)
	}
	relay.Pipe(ws, raw)
}

// handleQR serves POST /pair/qr, GET /pair/qr/<id> and DELETE /pair/qr/<id>.
func (s *Server) handleQR(w http.ResponseWriter, r *http.Request) {
	if s.cfg.QR == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not-found"})
		return
	}
	if r.URL.Path == "/pair/qr" {
		if r.Method != http.MethodPost {
			writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method-not-allowed"})
			return
		}
		started, err := s.cfg.QR.Start()
		switch {
		case errors.Is(err, qrpair.ErrNoNetwork):
			writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "no-network"})
		case err != nil:
			writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "failed"})
		default:
			writeJSON(w, http.StatusOK, map[string]any{
				"ok": true, "id": started.ID, "payload": started.Payload, "expiresIn": started.ExpiresIn,
			})
		}
		return
	}
	id := strings.TrimPrefix(r.URL.Path, "/pair/qr/")
	switch r.Method {
	case http.MethodGet:
		st, ok := s.cfg.QR.Status(id)
		if !ok {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "not-found"})
			return
		}
		out := map[string]any{"state": st.State}
		if st.DeviceName != "" {
			out["deviceName"] = st.DeviceName
		}
		if st.Reason != "" {
			out["reason"] = st.Reason
		}
		writeJSON(w, http.StatusOK, out)
	case http.MethodDelete:
		if !s.cfg.QR.Cancel(id) {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "not-found"})
			return
		}
		writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
	default:
		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method-not-allowed"})
	}
}
internal/tlsconnect/tlsconnect.go 174 lines
// Package tlsconnect implements the "STLS" shim for Wireless debugging
// connections. The browser speaks plain adb; the phone (Android 11+) answers
// the browser's CNXN with an STLS request. This shim answers that request,
// upgrades the TCP stream to TLS 1.3 with the agent's certificate, and from
// then on exposes the decrypted stream, so the browser never sees AUTH.
package tlsconnect

import (
	"crypto/tls"
	"encoding/binary"
	"errors"
	"io"
	"net"
	"sync"
	"time"
)

const (
	cmdSTLS = 0x534c5453 // "STLS"
	// HandshakeTimeout bounds the STLS exchange plus the TLS handshake.
	HandshakeTimeout = 10 * time.Second
	maxPayload       = 1 << 20
)

// ErrNotPaired means the phone rejected the agent's certificate (or hung up
// right after the TLS handshake): the agent key is not paired.
var ErrNotPaired = errors.New("not-paired")

// ErrProtocol means the phone did not speak the expected adb/TLS protocol.
var ErrProtocol = errors.New("protocol")

// Conn wraps a raw adb TCP connection. Writes go straight to the phone until
// the phone's first packet arrives; if that packet is STLS the shim upgrades
// the stream transparently inside Read.
type Conn struct {
	cert tls.Certificate

	mu   sync.Mutex // guards cur for writes and the swap
	cur  net.Conn
	raw  net.Conn
	once sync.Once

	pending []byte // bytes of a non-STLS first packet still to hand out
	started bool
	upgrade bool // true once TLS is active
	gotData bool // true once a byte was read after the upgrade
}

// Wrap returns the shim around raw.
func Wrap(raw net.Conn, cert tls.Certificate) *Conn {
	return &Conn{cert: cert, raw: raw, cur: raw}
}

// Write sends bytes to the phone (plain before the upgrade, TLS after).
func (c *Conn) Write(p []byte) (int, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.cur.Write(p)
}

// Read returns phone bytes (decrypted once upgraded). Only one goroutine may read.
func (c *Conn) Read(p []byte) (int, error) {
	if !c.started {
		c.started = true
		if err := c.firstPacket(); err != nil {
			return 0, err
		}
	}
	if len(c.pending) > 0 {
		n := copy(p, c.pending)
		c.pending = c.pending[n:]
		return n, nil
	}
	n, err := c.cur.Read(p)
	if c.upgrade && !c.gotData {
		if err != nil && n == 0 {
			// TLS 1.3 lets the client finish before the server has checked
			// its certificate: a rejection shows up as the first read failing.
			if errors.Is(err, io.EOF) || isTLSAlert(err) || isReset(err) {
				return 0, ErrNotPaired
			}
		}
		if n > 0 {
			c.gotData = true
		}
	}
	return n, err
}

// Close closes the underlying connection.
func (c *Conn) Close() error {
	c.once.Do(func() {})
	return c.raw.Close()
}

func isTLSAlert(err error) bool {
	var op *net.OpError
	if errors.As(err, &op) && op.Op == "remote error" {
		return true
	}
	return false
}

func isReset(err error) bool {
	var op *net.OpError
	return errors.As(err, &op) && !op.Timeout()
}

// firstPacket reads the phone's first adb packet and handles STLS.
func (c *Conn) firstPacket() error {
	_ = c.raw.SetReadDeadline(time.Now().Add(HandshakeTimeout))
	var hdr [24]byte
	if _, err := io.ReadFull(c.raw, hdr[:]); err != nil {
		_ = c.raw.SetReadDeadline(time.Time{})
		return err
	}
	cmd := binary.LittleEndian.Uint32(hdr[0:])
	arg0 := binary.LittleEndian.Uint32(hdr[4:])
	length := binary.LittleEndian.Uint32(hdr[12:])
	if length > maxPayload {
		return ErrProtocol
	}
	payload := make([]byte, length)
	if _, err := io.ReadFull(c.raw, payload); err != nil {
		return err
	}
	if cmd != cmdSTLS {
		// Not a TLS device (adb tcpip, or auth-free): hand the bytes through.
		_ = c.raw.SetReadDeadline(time.Time{})
		c.pending = append(hdr[:], payload...)
		return nil
	}

	// Answer STLS with the version the phone offered, then upgrade. Writers
	// are blocked (mu) until the handshake finishes so nothing interleaves.
	c.mu.Lock()
	defer c.mu.Unlock()
	if _, err := c.raw.Write(StlsPacket(arg0)); err != nil {
		return err
	}
	cfg := &tls.Config{
		MinVersion:         tls.VersionTLS13,
		MaxVersion:         tls.VersionTLS13,
		InsecureSkipVerify: true, // the phone is authenticated by us being paired with it
		GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
			return &c.cert, nil
		},
	}
	tc := tls.Client(c.raw, cfg)
	_ = c.raw.SetDeadline(time.Now().Add(HandshakeTimeout))
	if err := tc.Handshake(); err != nil {
		var ne net.Error
		if errors.As(err, &ne) && ne.Timeout() {
			return ErrProtocol
		}
		if isTLSAlert(err) {
			return ErrNotPaired
		}
		return ErrProtocol
	}
	_ = c.raw.SetDeadline(time.Time{})
	c.cur = tc
	c.upgrade = true
	return nil
}

// StlsPacket builds an empty STLS packet with the given version in arg0.
func StlsPacket(version uint32) []byte {
	b := make([]byte, 24)
	binary.LittleEndian.PutUint32(b[0:], cmdSTLS)
	binary.LittleEndian.PutUint32(b[4:], version)
	binary.LittleEndian.PutUint32(b[20:], ^uint32(cmdSTLS))
	return b
}
go.mod 17 lines
module github.com/adbkit/agent

go 1.25.1

require (
	filippo.io/edwards25519 v1.1.0
	github.com/hashicorp/mdns v1.0.5
)

require (
	github.com/miekg/dns v1.1.66 // indirect
	golang.org/x/mod v0.24.0 // indirect
	golang.org/x/net v0.39.0 // indirect
	golang.org/x/sync v0.13.0 // indirect
	golang.org/x/sys v0.32.0 // indirect
	golang.org/x/tools v0.32.0 // indirect
)
Makefile 35 lines
# ADBKit agent build. Requires Go 1.25+. The version comes from the VERSION file
# (override with `make release VERSION=...`).
VERSION ?= $(shell cat VERSION)
LDFLAGS := -s -w -X main.version=$(VERSION)
DIST    := dist
TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64

.PHONY: build test vet dist release clean

build:
	CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(DIST)/adbkit-agent .

vet:
	go vet ./...

test:
	go test -race -count=1 ./...

# Cross-compile every release target into dist/adbkit-agent_<os>_<arch>[.exe]
# and write dist/checksums.txt (sha256).
release: vet test dist

dist:
	rm -rf $(DIST) && mkdir -p $(DIST)
	@for t in $(TARGETS); do \
		os=$${t%/*}; arch=$${t#*/}; ext=""; [ "$$os" = windows ] && ext=".exe"; \
		out="$(DIST)/adbkit-agent_$${os}_$${arch}$$ext"; \
		echo "building $$out"; \
		CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o $$out . || exit 1; \
	done
	cd $(DIST) && (shasum -a 256 adbkit-agent_* > checksums.txt 2>/dev/null || sha256sum adbkit-agent_* > checksums.txt)
	@cat $(DIST)/checksums.txt

clean:
	rm -rf $(DIST)

Verify what you download

  1. Download the file and checksums.txt from the download page, then compare: shasum -a 256 -c checksums.txt --ignore-missing (macOS), sha256sum -c checksums.txt --ignore-missing (Linux) or Get-FileHash .\adbkit-agent_windows_amd64.exe (Windows). The install scripts do this for you and stop on a mismatch.
  2. See how a binary was built: go version -m adbkit-agent_linux_amd64 prints the Go version and build settings.
  3. Build it yourself: get adbkit-agent-source.tar.gz (sha256), unpack it and run make dist VERSION=1.0.0 with Go 1.25 or newer. The binaries are static and built with -trimpath, so the same Go version produces the same file.

Download page · Full guide · Back to the workspace