penguin/homekey-go

homekey-go

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