1
0
Fork 0

API token auth, readme updates

main
Jordan Hotmann 2023-11-17 11:24:12 -07:00
parent 6f1e9f4a75
commit 2076619576
No known key found for this signature in database
GPG Key ID: 01B504170C2A2EA3
5 changed files with 140 additions and 23 deletions

View File

@ -1,3 +1,92 @@
# HATS
Push Home Assistant websocket events to a NATS message queue. Additionally acts as a caching proxy for Home Assistant API requests.
[Home Assistant](https://www.home-assistant.io/) + [NATS](https://nats.io/) = HATS
## Features
- Push Home Assistant websocket events to a NATS message queue
- Caching proxy for Home Assistant API
- Clients for some application APIs (limited functionality)
- [Gokapi](https://github.com/Forceu/Gokapi)
- [ntfy](https://github.com/binwiederhier/ntfy)
- [qBittorrent](https://github.com/qbittorrent/qBittorrent)
- [Syncthing](https://github.com/syncthing/syncthing)
- [National Weather Service](https://www.weather.gov/)
## Example Client
```golang
package main
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"code.jhot.me/jhot/hats/pkg/client"
"code.jhot.me/jhot/hats/pkg/config"
ha "code.jhot.me/jhot/hats/pkg/homeassistant"
n "code.jhot.me/jhot/hats/pkg/nats"
"github.com/nats-io/nats.go"
)
var (
logger *slog.Logger
hatsClient *client.HatsClient
natsClient *n.NatsConnection
)
func main() {
cfg := config.FromEnvironment()
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: cfg.GetLogLevel(),
}))
hatsClient = client.NewHatsClient(cfg.GetHatsBaseUrl(), cfg.HatsToken)
natsClient = n.DefaultNatsConnection().WithJetstream(false).WithHostName(cfg.NatsHost).WithPort(cfg.NatsPort).WithConnectionOption(nats.Name(cfg.NatsClientName))
defer natsClient.Close()
go GenericStateListener("sun.sun", SunHandler)
sigch := make(chan os.Signal, 1)
signal.Notify(sigch, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
<-sigch
logger.Info("SIGTERM received")
}
func SunHandler(state ha.StateData) error {
return hatsClient.CallService("light.some_light", ha.Services.TurnOn)
}
func GenericStateListener(entityId string, handler func(ha.StateData) error) {
topic := fmt.Sprintf("homeassistant.states.%s.*", entityId)
l := logger.With("topic", topic, "entity_id", entityId)
l.Debug("Subscribing to topic")
sub, ch, err := natsClient.Subscribe(topic)
if err != nil {
l.Error("Error subscribing to topic", "error", err)
return
}
defer sub.Unsubscribe()
for msg := range ch {
msg.Ack()
var data ha.EventData
err = json.Unmarshal(msg.Data, &data)
if err != nil {
l.Error("Error parsing message", "error", err)
continue
}
l.Debug("Event state " + data.NewState.State)
err = handler(data.NewState)
if err != nil {
l.Error("Error handling state event", "error", err)
continue
}
}
}
```

View File

@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"log/slog"
@ -37,8 +38,10 @@ func Listen(parentLogger *slog.Logger) {
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(loggerMiddleware)
router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(60 * time.Second))
router.Use(tokenAuthMiddleware)
router.Get(`/api/state/{entityId}`, getEntityStateHandler)
router.Post("/api/state/{entityId}/{service}", setEntityStateHandler)
@ -69,14 +72,36 @@ func Close() {
}
}
func logRequest(w http.ResponseWriter, r *http.Request) {
func tokenAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.HatsToken == "" { // No token required
next.ServeHTTP(w, r)
return
}
authHeaderParts := strings.Split(r.Header.Get("Authorization"), "")
switch {
case len(authHeaderParts) != 2:
case authHeaderParts[0] != "Bearer":
case authHeaderParts[1] != cfg.HatsToken:
http.Error(w, "Bearer authorization header doesn't match configured token", http.StatusUnauthorized)
return
default:
next.ServeHTTP(w, r)
}
})
}
func loggerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug(fmt.Sprintf("%s %s", r.Method, r.URL.Path), "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
// HOME ASSISTANT ENTITIES
func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
logRequest(w, r)
entityId := chi.URLParam(r, "entityId")
full := r.URL.Query().Get("full") == "true"
@ -103,7 +128,6 @@ func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
}
func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
logRequest(w, r)
entityId := chi.URLParam(r, "entityId")
service := chi.URLParam(r, "service")
domain := r.URL.Query().Get("domain")
@ -139,7 +163,6 @@ func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
func getTimerHandler(w http.ResponseWriter, r *http.Request) {
timerName := chi.URLParam(r, "timerName")
logRequest(w, r)
timer, err := nats.GetTimer(timerName)
if err != nil {
@ -157,7 +180,6 @@ type StartTimerData struct {
func startTimerHandler(w http.ResponseWriter, r *http.Request) {
timerName := chi.URLParam(r, "timerName")
logRequest(w, r)
data := &StartTimerData{}
if err := render.DecodeJSON(r.Body, data); err != nil {
@ -185,7 +207,6 @@ func startTimerHandler(w http.ResponseWriter, r *http.Request) {
}
func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
logRequest(w, r)
timerName := chi.URLParam(r, "timerName")
timer, err := nats.GetTimer(timerName)
@ -202,7 +223,6 @@ func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
func getScheduleHandler(w http.ResponseWriter, r *http.Request) {
scheduleName := chi.URLParam(r, "scheduleName")
logRequest(w, r)
schedule, err := nats.GetSchedule(scheduleName)
if err != nil {
@ -219,7 +239,6 @@ type CreateScheduleData struct {
func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
scheduleName := chi.URLParam(r, "scheduleName")
logRequest(w, r)
data := &CreateScheduleData{}
if err := render.DecodeJSON(r.Body, data); err != nil {
@ -239,7 +258,6 @@ func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
}
func deleteScheduleHandler(w http.ResponseWriter, r *http.Request) {
logRequest(w, r)
scheduleName := chi.URLParam(r, "scheduleName")
schedule, err := nats.GetSchedule(scheduleName)
@ -273,7 +291,6 @@ func postNtfyHandler(w http.ResponseWriter, r *http.Request) {
func postCommandHandler(w http.ResponseWriter, r *http.Request) {
commandName := chi.URLParam(r, "commandName")
logRequest(w, r)
switch commandName {
// Commands without payloads

View File

@ -13,8 +13,11 @@ type HatsClient struct {
client *resty.Client
}
func NewHatsClient(baseUrl string) *HatsClient {
func NewHatsClient(baseUrl string, token string) *HatsClient {
client := resty.New().SetBaseURL(baseUrl)
if token != "" {
client.SetHeader("Authorization", fmt.Sprintf("Bearer %s", token))
}
return &HatsClient{
client: client,
}

View File

@ -24,6 +24,7 @@ type HatsConfig struct {
HatsHost string
HatsPort string
HatsToken string
HatsSecure bool
NtfyHost string
@ -33,15 +34,20 @@ type HatsConfig struct {
func FromEnvironment() *HatsConfig {
config := &HatsConfig{
LogLevl: util.GetEnvWithDefault("LOG_LEVEL", "INFO"),
HomeAssistantHost: util.GetEnvWithDefault("HASS_HOST", "127.0.0.1"),
HomeAssistantPort: util.GetEnvWithDefault("HASS_PORT", "8123"),
HomeAssistantToken: util.GetEnvWithDefault("HASS_TOKEN", ""),
NatsHost: util.GetEnvWithDefault("NATS_HOST", "127.0.0.1"),
NatsPort: util.GetEnvWithDefault("NATS_PORT", "4222"),
NatsToken: util.GetEnvWithDefault("NATS_TOKEN", ""),
NatsClientName: util.GetEnvWithDefault("NATS_CLIENT_NAME", "hats"),
HatsHost: util.GetEnvWithDefault("HATS_HOST", "hats"),
HatsPort: util.GetEnvWithDefault("HATS_PORT", "8888"),
HatsToken: util.GetEnvWithDefault("HATS_TOKEN", ""),
NtfyHost: util.GetEnvWithDefault("NTFY_HOST", "https://ntfy.sh"),
NtfyToken: util.GetEnvWithDefault("NTFY_TOKEN", ""),
}

View File

@ -11,8 +11,10 @@ import (
//
// States that return true: "on", "home", "open", "playing", non-zero numbers, etc.
// All others return false
//
// regex: ^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|above_horizon|[1-9][\d\.]*|0\.0*[1-9]\d*)$
func StateToBool(state string) bool {
trueRegex := regexp.MustCompile(`^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|[1-9][\d\.]*|0\.0*[1-9]\d*)$`)
trueRegex := regexp.MustCompile(`^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|above_horizon|[1-9][\d\.]*|0\.0*[1-9]\d*)$`)
return trueRegex.MatchString(state)
}