internal/blegateway/gateway.go
raw ยท 8901 bytes
// SPDX-License-Identifier: Apache-2.0
// Package blegateway implements the Linux BlueZ NFC APDU relay and PING/PONG probe.
package blegateway
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/godbus/dbus/v5"
)
const ServiceUUID = "7a6b0001-5b21-4f36-8e5d-9c3a26d74210"
const PingUUID = "7a6b0002-5b21-4f36-8e5d-9c3a26d74210"
type objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
// ValidateAddress deliberately requires an explicit reader, rather than
// connecting to whichever device happens to advertise our service first.
func ValidateAddress(address string) error {
a, err := net.ParseMAC(address)
if err != nil || len(a) != 6 {
return fmt.Errorf("BLE address must be six colon-separated octets")
}
if strings.Count(address, ":") != 5 {
return fmt.Errorf("BLE address must use colon separators")
}
return nil
}
func request(seq uint32) []byte {
b := make([]byte, 8)
copy(b, "PING")
binary.BigEndian.PutUint32(b[4:], seq)
return b
}
func validReply(b []byte, seq uint32) bool {
return len(b) == 8 && bytes.Equal(b[:4], []byte("PONG")) && binary.BigEndian.Uint32(b[4:]) == seq
}
func wait(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
func getObjects(ctx context.Context, c *dbus.Conn) (objects, error) {
var o objects
err := c.Object("org.bluez", "/").CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&o)
return o, err
}
func text(p map[string]dbus.Variant, k string) string { v, _ := p[k].Value().(string); return v }
// Run reconnects independently of HAP. A Bluetooth failure does not stop
// HomeKit provisioning. Each attempt gets a fresh system-bus connection.
func Run(ctx context.Context, address, adapter string, interval time.Duration, handler Handler, groupProvider func() []byte) {
for ctx.Err() == nil {
if err := session(ctx, strings.ToUpper(address), adapter, interval, handler, groupProvider); err != nil && ctx.Err() == nil {
log.Printf("BLE gateway: %v; retrying in 5s", err)
}
if !wait(ctx, 5*time.Second) {
return
}
}
}
func session(ctx context.Context, address, adapter string, interval time.Duration, handler Handler, groupProvider func() []byte) error {
c, err := dbus.ConnectSystemBus()
if err != nil {
return fmt.Errorf("system D-Bus: %w", err)
}
defer c.Close()
adapterPath := dbus.ObjectPath("/org/bluez/" + adapter)
a := c.Object("org.bluez", adapterPath)
// BlueZ reference-counts discovery sessions; stop only our own session.
scanCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err = a.CallWithContext(scanCtx, "org.bluez.Adapter1.StartDiscovery", 0).Err
cancel()
if err != nil {
return fmt.Errorf("start discovery on %s: %w", adapter, err)
}
stopScan := func() {
cleanup, done := context.WithTimeout(context.Background(), 3*time.Second)
defer done()
_ = a.CallWithContext(cleanup, "org.bluez.Adapter1.StopDiscovery", 0).Err
}
scanning := true
defer func() {
if scanning {
stopScan()
}
}()
log.Printf("BLE gateway: discovering %s on %s", address, adapter)
discoverCtx, done := context.WithTimeout(ctx, 30*time.Second)
defer done()
var device dbus.ObjectPath
for device == "" {
o, e := getObjects(discoverCtx, c)
if e != nil {
return e
}
for p, interfaces := range o {
props, ok := interfaces["org.bluez.Device1"]
if ok && strings.HasPrefix(string(p), string(adapterPath)+"/") && strings.EqualFold(text(props, "Address"), address) {
device = p
break
}
}
if device == "" && !wait(discoverCtx, 250*time.Millisecond) {
return fmt.Errorf("reader discovery: %w", discoverCtx.Err())
}
}
d := c.Object("org.bluez", device)
connectCtx, connectDone := context.WithTimeout(ctx, 20*time.Second)
err = d.CallWithContext(connectCtx, "org.bluez.Device1.Connect", 0).Err
connectDone()
if err != nil {
return fmt.Errorf("connect %s: %w", address, err)
}
defer func() {
cleanup, done := context.WithTimeout(context.Background(), 3*time.Second)
defer done()
_ = d.CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
}()
stopScan()
scanning = false
resolveCtx, resolveDone := context.WithTimeout(ctx, 20*time.Second)
defer resolveDone()
var characteristic dbus.ObjectPath
for characteristic == "" {
o, e := getObjects(resolveCtx, c)
if e != nil {
return e
}
resolved, _ := o[device]["org.bluez.Device1"]["ServicesResolved"].Value().(bool)
if resolved {
for p, interfaces := range o {
props, ok := interfaces["org.bluez.GattCharacteristic1"]
if !ok || !strings.EqualFold(text(props, "UUID"), PingUUID) {
continue
}
service, _ := props["Service"].Value().(dbus.ObjectPath)
sp := o[service]["org.bluez.GattService1"]
owner, _ := sp["Device"].Value().(dbus.ObjectPath)
if owner == device && strings.EqualFold(text(sp, "UUID"), ServiceUUID) {
characteristic = p
break
}
}
}
if characteristic == "" && !wait(resolveCtx, 250*time.Millisecond) {
return fmt.Errorf("PING service resolution: %w", resolveCtx.Err())
}
}
o, err := getObjects(ctx, c)
if err != nil {
return err
}
var statusPath, apduPath dbus.ObjectPath
writeSize := 20 // Works even at the mandatory ATT MTU of 23.
for path, ifs := range o {
props, ok := ifs["org.bluez.GattCharacteristic1"]
if !ok {
continue
}
service, _ := props["Service"].Value().(dbus.ObjectPath)
sp := o[service]["org.bluez.GattService1"]
owner, _ := sp["Device"].Value().(dbus.ObjectPath)
if owner != device || !strings.EqualFold(text(sp, "UUID"), ServiceUUID) {
continue
}
switch strings.ToLower(text(props, "UUID")) {
case StatusUUID:
statusPath = path
case APDUUUID:
apduPath = path
if mtu, ok := props["MTU"].Value().(uint16); ok && mtu >= 23 {
writeSize = int(mtu) - 3
if writeSize > 244 {
writeSize = 244
}
}
}
}
if statusPath == "" || apduPath == "" {
return fmt.Errorf("NFC relay characteristics missing; flash the matching ESP32 firmware")
}
log.Printf("BLE gateway: connected to %s; APDU write_size=%d", address, writeSize)
ch := c.Object("org.bluez", characteristic)
status := c.Object("org.bluez", statusPath)
apdu := c.Object("org.bluez", apduPath)
var configuredGroup []byte
configured := false
var lastSession uint32
seq := uint32(0)
nextPing := time.Time{}
for ctx.Err() == nil {
card, e := readCard(ctx, status)
if e != nil {
return fmt.Errorf("NFC status: %w", e)
}
// Configure only between sessions; the NFC owner takes a snapshot for
// each scan. Every new BLE connection gets a fresh configuration.
if card.Session == 0 && groupProvider != nil {
group := groupProvider()
if !configured || !bytes.Equal(group, configuredGroup) {
b, err := ecpConfigPacket(group)
if err != nil {
return err
}
configCtx, done := context.WithTimeout(ctx, 3*time.Second)
err = (&Relay{characteristic: apdu}).write(configCtx, b)
done()
if err != nil {
return fmt.Errorf("ECP configuration (flash matching firmware): %w", err)
}
configuredGroup = append([]byte(nil), group...)
configured = true
log.Printf("BLE gateway: Home Key ECP configured reader=%s enabled=%t", address, len(group) == 8)
}
}
if card.Session != 0 && card.Session != lastSession {
lastSession = card.Session
relay := &Relay{characteristic: apdu, session: card.Session, writeSize: writeSize}
authCtx, done := context.WithTimeout(ctx, 12*time.Second)
if handler != nil {
e = handler(authCtx, card, relay)
}
done()
cleanup, finish := context.WithTimeout(context.Background(), 3*time.Second)
releaseError := relay.Release(cleanup)
finish()
if e != nil {
log.Printf("NFC authentication rejected reader=%s session=%d: %v", address, card.Session, e)
}
if releaseError != nil && ctx.Err() == nil {
log.Printf("NFC release: %v", releaseError)
}
}
if !time.Now().Before(nextPing) {
seq++
pingCtx, pingDone := context.WithTimeout(ctx, 5*time.Second)
started := time.Now()
err := ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.WriteValue", 0, request(seq), map[string]dbus.Variant{"type": dbus.MakeVariant("request")}).Err
var reply []byte
if err == nil {
err = ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&reply)
}
pingDone()
if err != nil {
return fmt.Errorf("PING %d: %w", seq, err)
}
if !validReply(reply, seq) {
return fmt.Errorf("PING %d: unexpected reply", seq)
}
log.Printf("BLE PONG reader=%s seq=%d round_trip=%s", address, seq, time.Since(started).Round(time.Millisecond))
nextPing = time.Now().Add(interval)
}
if !wait(ctx, 100*time.Millisecond) {
return nil
}
}
return nil
}