// 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 } } }