From d53e976a36636d14e9124455f7b2cd7c8faa5b06 Mon Sep 17 00:00:00 2001 From: Jarno Rankinen Date: Tue, 14 Mar 2023 09:37:36 +0200 Subject: [PATCH] Squashed commit of the following: commit 468bc33f7e2c7fc29c01bbdfa39a8176446c544a Author: Jarno Rankinen Date: Tue Mar 14 09:37:07 2023 +0200 gh-7 Log failed authentications. Forked rocketlaunchr/https-go to add CN to the generated certifcate commit 0e10c9d925be968bb167358dd69a3cadd0419e89 Author: Jarno Rankinen Date: Sun Mar 12 22:30:53 2023 +0200 gh-7 Added HTTP basic authentication to REST API. Default credentials pingvin:enervent. Still need to implement logging failed auth requests. A log file option is also a good idea. commit a3165db6314163123b0b559b66f40236962d2477 Author: Jarno Rankinen Date: Sun Mar 12 12:41:41 2023 +0200 gh-7 Updated Home Assistant rest example to https commit 54b555c523dd2fcd587f1605f346e3264b91bbcf Author: Jarno Rankinen Date: Sun Mar 12 12:25:53 2023 +0200 gh-7 Enabled SSL encryption commit 2738b603866853c48229d1940e7accdff5a7df9b Author: Jarno Rankinen Date: Sun Mar 12 12:07:36 2023 +0200 gh-7 Implemented automatic self-signed SSL generation on startup under ~/.config/enervent-ctrl Closes gh-7 --- enervent-ctrl-go/go.mod | 3 + enervent-ctrl-go/go.sum | 4 + enervent-ctrl-go/main.go | 140 ++++++++++++++++++++++---- homeassistant/homeassistant-rest.yaml | 60 ++++++++--- 4 files changed, 176 insertions(+), 31 deletions(-) diff --git a/enervent-ctrl-go/go.mod b/enervent-ctrl-go/go.mod index 6810a3e..bec317b 100644 --- a/enervent-ctrl-go/go.mod +++ b/enervent-ctrl-go/go.mod @@ -3,10 +3,13 @@ module github.com/0ranki/enervent-ctrl/enervent-ctrl-go go 1.18 require ( + github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433 github.com/goburrow/modbus v0.1.0 github.com/gorilla/handlers v1.5.1 ) +require golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + require ( github.com/felixge/httpsnoop v1.0.1 // indirect github.com/goburrow/serial v0.1.0 // indirect diff --git a/enervent-ctrl-go/go.sum b/enervent-ctrl-go/go.sum index aff35a4..d617551 100644 --- a/enervent-ctrl-go/go.sum +++ b/enervent-ctrl-go/go.sum @@ -1,3 +1,5 @@ +github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433 h1:QT2IRJnhIdCSr26LJktnZnBpHdiLfTrUFzLSdP3h9Wo= +github.com/0ranki/https-go v0.0.0-20230314064508-ba9a558db433/go.mod h1:r4Jb05+PuiVKHDYwSsSBuSz4LpOlC2DgOY4N58+K8Hk= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/goburrow/modbus v0.1.0 h1:DejRZY73nEM6+bt5JSP6IsFolJ9dVcqxsYbpLbeW/ro= @@ -6,3 +8,5 @@ github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= diff --git a/enervent-ctrl-go/main.go b/enervent-ctrl-go/main.go index 3f309dc..92ac073 100644 --- a/enervent-ctrl-go/main.go +++ b/enervent-ctrl-go/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "crypto/subtle" "embed" "encoding/json" "flag" @@ -10,8 +12,10 @@ import ( "os" "strconv" "strings" + "time" "github.com/0ranki/enervent-ctrl/enervent-ctrl-go/pingvinKL" + "github.com/0ranki/https-go" "github.com/gorilla/handlers" ) @@ -22,13 +26,62 @@ import ( var static embed.FS var ( - version = "0.0.17" - pingvin pingvinKL.PingvinKL - DEBUG *bool - INTERVAL *int - ACCESS_LOG *bool + version = "0.0.21" + pingvin pingvinKL.PingvinKL + DEBUG *bool + INTERVAL *int + ACCESS_LOG *bool + usernamehash [32]byte + passwordhash [32]byte ) +// HTTP Basic Authentication middleware for http.HandlerFunc +func authHandlerFunc(next http.HandlerFunc) http.HandlerFunc { + // Based on https://www.alexedwards.net/blog/basic-authentication-in-go + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if ok { + userHash := sha256.Sum256([]byte(user)) + passHash := sha256.Sum256([]byte(pass)) + usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1) + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } + } + if len(user) == 0 { + user = "-" + } + log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user) + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} + +// HTTP Basic Authentication middleware for http.Handler +func authHandler(next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if ok { + userHash := sha256.Sum256([]byte(user)) + passHash := sha256.Sum256([]byte(pass)) + usernameMatch := (subtle.ConstantTimeCompare(userHash[:], usernamehash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passHash[:], passwordhash[:]) == 1) + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } + } + if len(user) == 0 { + user = "-" + } + log.Println("Authentication failed: IP:", r.RemoteAddr, "URI:", r.RequestURI, "username:", user) + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} + func coils(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/coils/"), "/") @@ -121,50 +174,99 @@ func temperature(w http.ResponseWriter, r *http.Request) { } } -func listen() { +func serve(cert, key *string) { log.Println("Starting pingvinAPI...") - http.HandleFunc("/api/v1/coils/", coils) - http.HandleFunc("/api/v1/registers/", registers) - http.HandleFunc("/api/v1/status", status) - http.HandleFunc("/api/v1/temperature/", temperature) + http.HandleFunc("/api/v1/coils/", authHandlerFunc(coils)) + http.HandleFunc("/api/v1/status", authHandlerFunc(status)) + http.HandleFunc("/api/v1/registers/", authHandlerFunc(registers)) + http.HandleFunc("/api/v1/temperature/", authHandlerFunc(temperature)) html, err := fs.Sub(static, "static/html") if err != nil { log.Fatal(err) } htmlroot := http.FileServer(http.FS(html)) - http.Handle("/", htmlroot) - if *ACCESS_LOG { - handler := handlers.LoggingHandler(os.Stdout, http.DefaultServeMux) - err = http.ListenAndServe(":8888", handler) - } else { - err = http.ListenAndServe(":8888", nil) + http.HandleFunc("/", authHandler(htmlroot)) + logdst, err := os.OpenFile(os.DevNull, os.O_WRONLY, os.ModeAppend) + if err != nil { + log.Fatal(err) } + if *ACCESS_LOG { + logdst = os.Stdout + } + handler := handlers.LoggingHandler(logdst, http.DefaultServeMux) + err = http.ListenAndServeTLS(":8888", *cert, *key, handler) if err != nil { log.Fatal(err) } } -func configure() { +func generateCertificate(certpath, cert, key string) { + if _, err := os.Stat(certpath); err != nil { + log.Println("Generating configuration directory", certpath) + if err := os.MkdirAll(certpath, 0750); err != nil { + log.Fatal("Failed to generate configuration directory:", err) + } + } + opts := https.GenerateOptions{Host: "enervent-ctrl.local", RSABits: 4096, ValidFor: 10 * 365 * 24 * time.Hour} + log.Println("Generating new self-signed SSL keypair to ", certpath) + log.Println("This may take a while...") + pub, priv, err := https.GenerateKeys(opts) + if err != nil { + log.Fatal("Error generating SSL certificate: ", err) + } + pingvin.Debug.Println("Certificate:\n", string(pub)) + pingvin.Debug.Println("Key:\n", string(priv)) + if err := os.WriteFile(key, priv, 0600); err != nil { + log.Fatal("Error writing private key ", key, ": ", err) + } + log.Println("Wrote new SSL private key ", cert) + if err := os.WriteFile(cert, pub, 0644); err != nil { + log.Fatal("Error writing certificate ", cert, ": ", err) + } + log.Println("Wrote new SSL public key ", cert) +} + +func configure() (certfile, keyfile *string) { log.Println("Reading configuration") + // Get the user home directory path + homedir, err := os.UserHomeDir() + if err != nil { + log.Fatal("Could not determine user home directory") + } + certpath := homedir + "/.config/enervent-ctrl/" DEBUG = flag.Bool("debug", false, "Enable debug logging") INTERVAL = flag.Int("interval", 4, "Set the interval of background updates") ACCESS_LOG = flag.Bool("httplog", false, "Enable HTTP access logging") + generatecert := flag.Bool("regenerate-certs", false, "Generate a new SSL certificate. A new one is generated on startup as `~/.config/enervent-ctrl/server.crt` if it doesn't exist.") + cert := flag.String("cert", certpath+"certificate.pem", "Path to SSL public key to use for HTTPS") + key := flag.String("key", certpath+"privatekey.pem", "Path to SSL private key to use for HTTPS") + username := flag.String("username", "pingvin", "Username for HTTP Basic Authentication") + password := flag.String("password", "enervent", "Password for HTTP Basic Authentication") + // TODO: log file flag flag.Parse() + usernamehash = sha256.Sum256([]byte(*username)) + passwordhash = sha256.Sum256([]byte(*password)) + // Check that certificate file exists + if _, err = os.Stat(*cert); err != nil || *generatecert { + generateCertificate(certpath, *cert, *key) + } if *DEBUG { log.Println("Debug logging enabled") } if *ACCESS_LOG { log.Println("HTTP Access logging enabled") } + log.Println("Update interval set to", *INTERVAL, "seconds") + return cert, key } func main() { log.Println("enervent-ctrl version", version) - configure() + cert, key := configure() pingvin = pingvinKL.New(*DEBUG) pingvin.Update() go pingvin.Monitor(*INTERVAL) - listen() + serve(cert, key) pingvin.Quit() } diff --git a/homeassistant/homeassistant-rest.yaml b/homeassistant/homeassistant-rest.yaml index 87f52d3..087ae4f 100644 --- a/homeassistant/homeassistant-rest.yaml +++ b/homeassistant/homeassistant-rest.yaml @@ -1,6 +1,9 @@ rest: - - resource: http://IP_ADDRESS:8888/api/v1/status + - resource: https://IP_ADDRESS:8888/api/v1/status scan_interval: 5 + verify_ssl: false + username: pingvin + password: enervent sensor: - name: "Penguin operating mode" value_template: "{{ value_json['op_mode'] }}" @@ -101,40 +104,73 @@ template: rest_command: penguin_circulation_adaptive: - url: http://IP_ADDRESS:8888/api/v1/coils/11/1 + url: https://IP_ADDRESS:8888/api/v1/coils/11/1 method: POST icon: mdi:fan-auto + verify_ssl: false + username: pingvin + password: enervent penguin_circulation_manual: - url: http://IP_ADDRESS:8888/api/v1/coils/11/0 + url: https://IP_ADDRESS:8888/api/v1/coils/11/0 method: POST icon: mdi:fan + verify_ssl: false + username: pingvin + password: enervent penguin_boost_on: - url: http://192.168.0.210:8888/api/v1/coils/10/1 + url: https://192.168.0.210:8888/api/v1/coils/10/1 method: POST + verify_ssl: false + username: pingvin + password: enervent penguin_boost_off: - url: http://192.168.0.210:8888/api/v1/coils/10/0 + url: https://192.168.0.210:8888/api/v1/coils/10/0 method: POST + verify_ssl: false + username: pingvin + password: enervent penguin_overpressure_toggle: - url: http://IP_ADDRESS:8888/api/v1/coils/3 + url: https://IP_ADDRESS:8888/api/v1/coils/3 method: POST icon: mdi:arrow-expand-all + verify_ssl: false + username: pingvin + password: enervent penguin_max_heating_on: - url: http://IP_ADDRESS:8888/api/v1/coils/6/1 + url: https://IP_ADDRESS:8888/api/v1/coils/6/1 method: POST icon: mdi:heat-wave + verify_ssl: false + username: pingvin + password: enervent penguin_max_heating_off: - url: http://IP_ADDRESS:8888/api/v1/coils/6/0 + url: https://IP_ADDRESS:8888/api/v1/coils/6/0 method: POST icon: mdi:scent-off + verify_ssl: false + username: pingvin + password: enervent penguin_max_cooling_on: - url: http://192.168.0.210:8888/api/v1/coils/7/1 + url: https://192.168.0.210:8888/api/v1/coils/7/1 method: POST + verify_ssl: false + username: pingvin + password: enervent penguin_max_cooling_off: - url: http://192.168.0.210:8888/api/v1/coils/7/0 + url: https://192.168.0.210:8888/api/v1/coils/7/0 method: POST + verify_ssl: false + username: pingvin + password: enervent penguin_temperature_up: - url: http://192.168.0.210:8888/api/v1/temperature/up + url: https://192.168.0.210:8888/api/v1/temperature/up method: POST + verify_ssl: false + username: pingvin + password: enervent penguin_temperature_down: - url: http://192.168.0.210:8888/api/v1/temperature/down + url: https://192.168.0.210:8888/api/v1/temperature/down method: POST + verify_ssl: false + username: pingvin + password: enervent