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
- Listens on
127.0.0.1only, so only programs on your own computer can reach it. Pages must come from an allowed origin. - Connects only to addresses on your own network (your phone). Public internet addresses are refused.
- Writes exactly one file: its key,
~/.adbkit/agent-key.pem(permissions 0600). - Has no HTTP client, makes no calls to the internet, sends no telemetry, and starts no other programs.
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
- 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) orGet-FileHash .\adbkit-agent_windows_amd64.exe(Windows). The install scripts do this for you and stop on a mismatch. - See how a binary was built:
go version -m adbkit-agent_linux_amd64prints the Go version and build settings. - Build it yourself: get adbkit-agent-source.tar.gz (sha256), unpack it and run
make dist VERSION=1.0.0with Go 1.25 or newer. The binaries are static and built with-trimpath, so the same Go version produces the same file.