penguin/homekey-go

homekey-go

cmd/homekey/main.go

raw ยท 3859 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")
	}
	c, err := app.Load(*config)
	if err != nil {
		return err
	}
	var ifaces []string
	if c.Interface != "" {
		if _, err := net.InterfaceByName(c.Interface); err != nil {
			return err
		}
		ifaces = []string{c.Interface}
	}
	if err = os.MkdirAll(*state, 0700); err != nil {
		return err
	}
	if err = os.Chmod(*state, 0700); err != nil {
		return err
	}
	if !*status {
		unlock, e := lockState(filepath.Join(*state, ".lock"))
		if e != nil {
			return e
		}
		defer unlock()
	}
	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 := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	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)
	}
	web := &http.Server{Addr: *webAddr, Handler: app.Handler(locks, c.Readers, *ingress), 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:
		return nil
	}
}