cmd/homekey/main.go
raw ยท 5215 bytes
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"homekey.local/provisioner/internal/app"
"homekey.local/provisioner/internal/blegateway"
"homekey.local/provisioner/internal/provision"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
func main() {
if err := run(); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run() error {
state := flag.String("state", "./state", "persistent state directory")
config := flag.String("config", "./config.json", "lock and reader configuration JSON")
webAddr := flag.String("web-addr", "127.0.0.1:8099", "configuration/pairing web address")
ingress := flag.Bool("ingress", false, "restrict Web UI to the HA ingress gateway")
status := flag.Bool("status", false, "print credential counts per lock and exit")
flag.Parse()
if flag.NArg() != 0 {
return fmt.Errorf("unexpected positional argument")
}
if err := os.MkdirAll(*state, 0700); err != nil {
return err
}
if err := os.Chmod(*state, 0700); err != nil {
return err
}
if !*status {
unlock, err := lockState(filepath.Join(*state, ".lock"))
if err != nil {
return err
}
defer unlock()
}
activeConfig := *config
seed := ""
if *ingress {
activeConfig = filepath.Join(*state, "config.json")
seed = *config
}
processCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
for {
settings, err := app.OpenSettings(activeConfig, seed, *status)
if err != nil {
return err
}
err = serve(processCtx, *state, *webAddr, *ingress, *status, settings)
if !errors.Is(err, errReload) {
return err
}
if processCtx.Err() != nil {
return nil
}
}
}
var errReload = errors.New("configuration reload")
func serve(processCtx context.Context, state, webAddr string, ingress, status bool, settings *app.Settings) error {
c, _ := settings.Snapshot()
var ifaces []string
if c.Interface != "" {
if _, err := net.InterfaceByName(c.Interface); err != nil {
return err
}
ifaces = []string{c.Interface}
}
var locks []*app.Lock
summaries := map[string]provision.Summary{}
for _, cfg := range c.Locks {
dir := filepath.Join(state, "locks", cfg.ID)
store, e := provision.Open(dir)
if e != nil {
return e
}
if status {
summaries[cfg.ID] = store.Summary()
continue
}
// Retire the old persistent setup code; HAP identities and pairings stay.
if e = store.Delete("provisioning-pin"); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
l, e := app.NewLock(cfg, store, ifaces)
if e != nil {
return e
}
locks = append(locks, l)
}
if status {
return json.NewEncoder(os.Stdout).Encode(summaries)
}
ctx, cancel := context.WithCancel(processCtx)
defer cancel()
var wg sync.WaitGroup
failures := make(chan error, len(locks)+1)
for _, l := range locks {
wg.Add(1)
go func(l *app.Lock) {
defer wg.Done()
if err := l.Device.Server.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
failures <- fmt.Errorf("lock %s: %w", l.Config.ID, err)
cancel()
}
}(l)
}
for _, r := range c.Readers {
assigned := app.Assigned(locks, r.ID)
if len(assigned) == 0 {
continue
}
wg.Add(1)
go func(r app.ReaderConfig, assigned []*app.Lock) {
defer wg.Done()
lastGroupError := ""
blegateway.Run(ctx, r.Address, r.Adapter, 10*time.Second, func(authCtx context.Context, card blegateway.Card, relay *blegateway.Relay) error {
return app.Authenticate(authCtx, ctx, assigned, r.ID, card, relay)
}, func() []byte {
group, err := app.Group(assigned)
if err != nil {
if lastGroupError != err.Error() {
log.Print(err)
lastGroupError = err.Error()
}
return nil
}
lastGroupError = ""
return group
})
}(r, assigned)
}
settings.BeforeSave = func(next app.Config) error {
if next.Interface != "" {
if _, err := net.InterfaceByName(next.Interface); err != nil {
return err
}
}
oldPorts := map[int]bool{}
for _, l := range c.Locks {
oldPorts[l.Port] = true
}
for _, l := range next.Locks {
if !oldPorts[l.Port] {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", l.Port))
if err != nil {
return fmt.Errorf("HomeKit port %d is unavailable: %w", l.Port, err)
}
listener.Close()
}
}
return nil
}
reload := make(chan struct{}, 1)
settings.Reload = func() {
select {
case reload <- struct{}{}:
default:
}
cancel()
}
web := &http.Server{Addr: webAddr, Handler: app.Handler(locks, c.Readers, ingress, settings), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
wg.Add(1)
go func() {
defer wg.Done()
if err := web.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
failures <- err
cancel()
}
}()
log.Printf("Configured %d locks and %d readers; pairing UI at %s", len(locks), len(c.Readers), webAddr)
<-ctx.Done()
shutdown, done := context.WithTimeout(context.Background(), 5*time.Second)
defer done()
_ = web.Shutdown(shutdown)
wg.Wait()
select {
case err := <-failures:
return err
default:
select {
case <-reload:
return errReload
default:
return nil
}
}
}