penguin/homekey-go

homekey-go

internal/physical/manager.go

raw ยท 8086 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
	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{}}
	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")
		}
		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
	}
	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)
	m.clients[r.ID] = c
	m.wg.Add(1)
	go func() { defer m.wg.Done(); c.Run(m.ctx) }()
}
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")
	}
	ctx, cancel := context.WithTimeout(ctx, 30*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
}