internal/physical/manager.go
raw ยท 11260 bytes
// SPDX-License-Identifier: Apache-2.0
// Package physical owns enrolled actuator identities and their private keys.
package physical
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"homekey.local/provisioner/drivers/sesame"
)
type Record struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Target sesame.Target `json:"target"`
}
type View struct {
Record
State sesame.State `json:"state"`
}
type Enrollment struct {
Name string `json:"name"`
Target sesame.Target `json:"target"`
Mode string `json:"mode"`
// Accept the SDK's toKeyJson fields, bound to the scanned device identity.
Key *struct {
UUID string `json:"deviceUUID"`
Model string `json:"deviceModel"`
Secret string `json:"secretKey"`
Index string `json:"keyIndex"`
Public string `json:"sesame2PublicKey"`
} `json:"key,omitempty"`
}
type Manager struct {
ctx context.Context
cancel context.CancelFunc
dir string
dial sesame.Dialer
enroll sync.Mutex
mu sync.RWMutex
records map[string]Record
clients map[string]*sesame.Client
clientCancel map[string]context.CancelFunc
clientDone map[string]<-chan struct{}
wg sync.WaitGroup
}
func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error) {
if dial == nil {
dial = sesame.DialBlueZ
}
life, cancel := context.WithCancel(ctx)
m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}, clientCancel: map[string]context.CancelFunc{}, clientDone: map[string]<-chan struct{}{}}
if e := os.MkdirAll(filepath.Join(dir, "physical-lock-keys"), 0700); e != nil {
cancel()
return nil, e
}
raw, e := os.ReadFile(filepath.Join(dir, "physical-locks.json"))
if e != nil && !errors.Is(e, os.ErrNotExist) {
cancel()
return nil, e
}
var records []Record
if len(raw) > 0 {
if e = json.Unmarshal(raw, &records); e != nil {
cancel()
return nil, e
}
}
// Validate the whole registry before starting any device connections.
keys := map[string]sesame.Credential{}
for _, r := range records {
id, err := identity(r.Target)
if err != nil || id != r.ID || r.Driver != "sesame" || m.records[r.ID].ID != "" {
cancel()
return nil, errors.New("invalid physical lock registry")
}
// A crash before the registry update leaves a staged key removal.
// Restore it when the persisted registry still references this lock.
if _, err := os.Stat(m.keyPath(id)); errors.Is(err, os.ErrNotExist) {
if err = os.Rename(m.keyPath(id)+".removing", m.keyPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
cancel()
return nil, err
}
}
c, e := m.readKey(id)
if e != nil {
cancel()
return nil, fmt.Errorf("physical lock %s: %w", id, e)
}
keys[id] = c
m.records[id] = r
}
// After a committed removal, a crash may leave only the tombstone.
files, err := os.ReadDir(filepath.Join(dir, "physical-lock-keys"))
if err != nil {
cancel()
return nil, err
}
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".json.removing") {
continue
}
id := strings.TrimSuffix(f.Name(), ".json.removing")
if _, ok := m.records[id]; ok {
continue
}
if !strings.HasPrefix(id, "sesame-") {
continue
}
if _, err := sesame.NormalizeUUID(strings.TrimPrefix(id, "sesame-")); err != nil {
continue
}
if err := os.Remove(filepath.Join(dir, "physical-lock-keys", f.Name())); err != nil {
cancel()
return nil, err
}
}
for _, r := range records {
m.start(r, keys[r.ID])
}
return m, nil
}
func identity(t sesame.Target) (string, error) {
if e := sesame.ValidateTarget(t); e != nil {
return "", e
}
uuid, e := sesame.NormalizeUUID(t.UUID)
if e != nil {
return "", e
}
return "sesame-" + uuid, nil
}
func (m *Manager) keyPath(id string) string {
return filepath.Join(m.dir, "physical-lock-keys", id+".json")
}
func (m *Manager) readKey(id string) (sesame.Credential, error) {
var c sesame.Credential
b, e := os.ReadFile(m.keyPath(id))
if e != nil {
return c, e
}
if e = json.Unmarshal(b, &c); e != nil {
return c, e
}
return c, c.Validate()
}
func writeJSON(path string, v any) error {
b, e := json.MarshalIndent(v, "", " ")
if e != nil {
return e
}
f, e := os.CreateTemp(filepath.Dir(path), ".physical-*")
if e != nil {
return e
}
defer os.Remove(f.Name())
if _, e = f.Write(append(b, '\n')); e != nil {
f.Close()
return e
}
if e = f.Sync(); e != nil {
f.Close()
return e
}
if e = f.Close(); e != nil {
return e
}
if e = os.Rename(f.Name(), path); e != nil {
return e
}
d, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func (m *Manager) start(r Record, key sesame.Credential) {
c := sesame.NewClient(r.Target, key, m.dial)
ctx, cancel := context.WithCancel(m.ctx)
done := make(chan struct{})
m.clients[r.ID] = c
m.clientCancel[r.ID] = cancel
m.clientDone[r.ID] = done
m.wg.Add(1)
go func() { defer m.wg.Done(); defer close(done); c.Run(ctx) }()
}
var ErrNotFound = errors.New("unknown physical lock")
// Remove forgets local enrollment; it never sends a reset or changes the lock.
// App configuration must reject removal while a virtual lock references this ID.
func (m *Manager) Remove(id string) error {
m.enroll.Lock()
defer m.enroll.Unlock()
m.mu.Lock()
if _, ok := m.records[id]; !ok {
m.mu.Unlock()
return ErrNotFound
}
// Move the credential out of the import/recovery path first. If the registry
// update fails, restore it and keep the active controller intact.
key := m.keyPath(id)
tombstone := key + ".removing"
moved := false
if e := os.Rename(key, tombstone); e != nil && !errors.Is(e, os.ErrNotExist) {
m.mu.Unlock()
return fmt.Errorf("remove physical lock key: %w", e)
} else if e == nil {
moved = true
}
records := make([]Record, 0, len(m.records)-1)
for rid, r := range m.records {
if rid != id {
records = append(records, r)
}
}
if e := writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil {
var restore error
if moved {
restore = os.Rename(tombstone, key)
}
m.mu.Unlock()
return errors.Join(e, restore)
}
cancel, done := m.clientCancel[id], m.clientDone[id]
delete(m.records, id)
delete(m.clients, id)
delete(m.clientCancel, id)
delete(m.clientDone, id)
cancel()
m.mu.Unlock()
<-done // Includes transport cleanup; do not leave a reconnect goroutine alive.
if moved {
if e := os.Remove(tombstone); e != nil {
return fmt.Errorf("lock removed, but saved key cleanup failed: %w", e)
}
}
return nil
}
func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); m.wg.Wait() }
func (m *Manager) Has(id string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.records[id]
return ok
}
func (m *Manager) Views() []View {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]View, 0, len(m.records))
for id, r := range m.records {
out = append(out, View{r, m.clients[id].State()})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) {
m.enroll.Lock()
defer m.enroll.Unlock()
name := strings.TrimSpace(in.Name)
if len(name) == 0 || len(name) > 128 {
return Record{}, errors.New("physical lock name is required (maximum 128 bytes)")
}
uuid, e := sesame.NormalizeUUID(in.Target.UUID)
if e != nil {
return Record{}, e
}
in.Target.UUID = uuid
id, e := identity(in.Target)
if e != nil {
return Record{}, e
}
if m.Has(id) {
return Record{}, errors.New("physical lock is already added")
}
if in.Mode != "register" && in.Mode != "import" {
return Record{}, errors.New("choose register or import")
}
// Leave room for separate BLE discovery/connect, initial-token and
// registration budgets, plus persistence. A slow dial must not exhaust
// the handshake deadline before the lock can answer.
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
stop := context.AfterFunc(m.ctx, cancel)
defer stop()
// Check writable private storage before starting irreversible registration.
probe, e := os.CreateTemp(filepath.Join(m.dir, "physical-lock-keys"), ".probe-*")
if e != nil {
return Record{}, e
}
probe.Close()
os.Remove(probe.Name())
var key sesame.Credential
if recovered, err := m.readKey(id); err == nil {
// Retry can recover a key persisted before a failed registry write.
key = recovered
e = sesame.Probe(ctx, in.Target, key, m.dial)
} else if !errors.Is(err, os.ErrNotExist) {
return Record{}, err
} else if in.Mode == "import" {
if in.Key == nil {
return Record{}, errors.New("paste the Sesame key JSON")
}
keyUUID, err := sesame.NormalizeUUID(in.Key.UUID)
if err != nil || keyUUID != uuid || in.Key.Model != in.Target.Model {
return Record{}, errors.New("imported key does not match the selected lock")
}
key = sesame.Credential{Secret: strings.TrimSpace(in.Key.Secret)}
if e = key.Validate(); e != nil {
return Record{}, e
}
e = sesame.Probe(ctx, in.Target, key, m.dial)
if e == nil {
e = writeJSON(m.keyPath(id), key)
}
} else {
e = sesame.Register(ctx, in.Target, m.dial, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) })
}
if e != nil {
return Record{}, e
}
in.Target.Registered = true
r := Record{ID: id, Name: name, Driver: "sesame", Target: in.Target}
m.mu.Lock()
defer m.mu.Unlock()
records := make([]Record, 0, len(m.records)+1)
for _, v := range m.records {
records = append(records, v)
}
records = append(records, r)
if e = writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil {
return Record{}, fmt.Errorf("key saved, but physical lock list could not be saved; retry adding this device: %w", e)
}
m.records[id] = r
m.start(r, key)
return r, nil
}
func (m *Manager) Action(ctx context.Context, id, name string) error {
m.mu.RLock()
c := m.clients[id]
m.mu.RUnlock()
if c == nil {
return errors.New("unknown physical lock")
}
return c.Action(ctx, name)
}
// Dispatch accepts an authorized command without holding the provision store's
// mutex through BLE I/O. Cancellation and a short deadline apply to the work.
func (m *Manager) Dispatch(ctx context.Context, ids []string, locked bool) <-chan error {
result := make(chan error, 1)
copyIDs := append([]string(nil), ids...)
go func() {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
stop := context.AfterFunc(m.ctx, cancel)
defer stop()
name := "unlock"
if locked {
name = "lock"
}
var wg sync.WaitGroup
errs := make(chan error, len(copyIDs))
seen := map[string]bool{}
for _, id := range copyIDs {
if seen[id] {
continue
}
seen[id] = true
wg.Add(1)
go func(id string) {
defer wg.Done()
if e := m.Action(ctx, id, name); e != nil {
errs <- fmt.Errorf("%s: %w", id, e)
}
}(id)
}
wg.Wait()
close(errs)
var all []error
for e := range errs {
all = append(all, e)
}
result <- errors.Join(all...)
}()
return result
}