enervent-ctrl/main.go

363 lines
12 KiB
Go
Raw Normal View History

package main
import (
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/json"
"flag"
"io/fs"
"io/ioutil"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
2023-01-29 14:42:03 +02:00
2023-03-15 21:29:57 +02:00
"github.com/0ranki/enervent-ctrl/pingvin"
"github.com/0ranki/https-go"
"github.com/gorilla/handlers"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"gopkg.in/yaml.v3"
)
// Remember to dereference the symbolic links under ./static/html
// prior to building the binary e.g. by using tar
//go:embed static/html/*
var static embed.FS
var (
2023-03-15 21:42:44 +02:00
version = "0.0.24"
2023-03-15 21:39:14 +02:00
device pingvin.Pingvin
config Conf
usernamehash [32]byte
passwordhash [32]byte
)
type Conf struct {
Port int `yaml:"port"`
SslCertificate string `yaml:"ssl_certificate"`
SslPrivatekey string `yaml:"ssl_privatekey"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Interval int `yaml:"interval"`
EnableMetrics bool `yaml:"enable_metrics"`
LogFile string `yaml:"log_file"`
LogAccess bool `yaml:"log_access"`
Debug bool `yaml:"debug"`
}
// 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)
})
}
// \/api/v1/coils endpoint
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/"), "/")
if len(pathparams[0]) == 0 {
2023-03-15 21:35:23 +02:00
json.NewEncoder(w).Encode(device.Coils)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
2023-03-15 21:35:23 +02:00
device.ReadCoil(uint16(intaddr))
json.NewEncoder(w).Encode(device.Coils[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
boolval, err := strconv.ParseBool(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse coil value", pathparams[1])
log.Println(err)
return
}
2023-03-15 21:35:23 +02:00
device.WriteCoil(uint16(intaddr), boolval)
json.NewEncoder(w).Encode(device.Coils[intaddr])
2023-03-03 08:08:36 +02:00
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse coil address", pathparams[0])
log.Println(err)
return
}
2023-03-15 21:35:23 +02:00
device.WriteCoil(uint16(intaddr), !device.Coils[intaddr].Value)
json.NewEncoder(w).Encode(device.Coils[intaddr])
}
}
// \/api/v1/registers endpoint
func registers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/registers/"), "/")
if len(pathparams[0]) == 0 {
2023-03-15 21:35:23 +02:00
json.NewEncoder(w).Encode(device.Registers)
} else if len(pathparams[0]) > 0 && r.Method == "GET" && len(pathparams) < 2 { // && r.Method == "POST"
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
2023-03-15 21:35:23 +02:00
device.ReadRegister(uint16(intaddr))
json.NewEncoder(w).Encode(device.Registers[intaddr])
} else if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 2 {
intaddr, err := strconv.Atoi(pathparams[0])
if err != nil {
log.Println("ERROR: Could not parse register address", pathparams[0])
log.Println(err)
return
}
intval, err := strconv.Atoi(pathparams[1])
if err != nil {
log.Println("ERROR: Could not parse register value", pathparams[1])
log.Println(err)
return
}
2023-03-15 21:35:23 +02:00
_, err = device.WriteRegister(uint16(intaddr), uint16(intval))
if err != nil {
log.Println(err)
}
2023-03-15 21:35:23 +02:00
json.NewEncoder(w).Encode(device.Registers[intaddr])
2023-02-01 22:19:57 +02:00
}
}
// \/status endpoint
func status(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
2023-03-15 21:35:23 +02:00
json.NewEncoder(w).Encode(device.Status)
}
// \/api/v1/temperature endpoint
func temperature(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pathparams := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/temperature/"), "/")
if len(pathparams[0]) > 0 && r.Method == "POST" && len(pathparams) == 1 {
2023-03-15 21:35:23 +02:00
device.Temperature(pathparams[0])
json.NewEncoder(w).Encode(device.Registers[135])
} else {
return
}
}
// Start the HTTP server
func serve(cert, key *string) {
2023-03-15 21:42:44 +02:00
log.Println("Starting service")
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))
if config.EnableMetrics {
http.Handle("/metrics", promhttp.Handler())
}
html, err := fs.Sub(static, "static/html")
if err != nil {
log.Fatal(err)
}
htmlroot := http.FileServer(http.FS(html))
http.HandleFunc("/", authHandler(htmlroot))
logdst, err := os.OpenFile(os.DevNull, os.O_WRONLY, os.ModeAppend)
if err != nil {
log.Fatal(err)
}
if config.LogAccess {
logdst = os.Stdout
}
handler := handlers.LoggingHandler(logdst, http.DefaultServeMux)
err = http.ListenAndServeTLS(":8888", *cert, *key, handler)
if err != nil {
log.Fatal(err)
}
}
// Generate self-signed SSL keypair
func generateCertificate(cert, key string) {
opts := https.GenerateOptions{Host: "enervent-ctrl.local", RSABits: 4096, ValidFor: 10 * 365 * 24 * time.Hour}
log.Println("Generating new self-signed SSL keypair")
log.Println("This may take a while...")
pub, priv, err := https.GenerateKeys(opts)
if err != nil {
log.Fatal("Error generating SSL certificate: ", err)
}
2023-03-15 21:35:23 +02:00
device.Debug.Println("Certificate:\n", string(pub))
device.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)
}
// Read & parse the configuration file
func parseConfigFile() {
homedir, err := os.UserHomeDir()
if err != nil {
log.Fatal("Could not determine user home directory")
}
confpath := homedir + "/.config/enervent-ctrl"
if _, err := os.Stat(confpath); err != nil {
log.Println("Generating configuration directory", confpath)
if err := os.MkdirAll(confpath, 0700); err != nil {
log.Fatal("Failed to generate configuration directory:", err)
}
}
conffile := confpath + "/configuration.yaml"
yamldata, err := ioutil.ReadFile(conffile)
if err != nil {
log.Println("Configuration file", conffile, "not found")
log.Println("Generating", conffile, "with default values")
initDefaultConfig(confpath)
if yamldata, err = ioutil.ReadFile(conffile); err != nil {
log.Fatal("Error parsing configuration:", err)
}
}
err = yaml.Unmarshal(yamldata, &config)
if err != nil {
log.Fatal("Failed to parse YAML:", err)
}
}
// Write the default configuration to $HOME/.config/enervent-ctrl/configuration.yaml
func initDefaultConfig(confpath string) {
config = Conf{
8888,
confpath + "/certificate.pem",
confpath + "/privatekey.pem",
2023-03-15 21:35:23 +02:00
"device",
"enervent",
4,
false,
"",
false,
false,
}
conffile := confpath + "/configuration.yaml"
confbytes, err := yaml.Marshal(&config)
if err != nil {
log.Println("Error writing default configuration:", err)
}
if err := os.WriteFile(conffile, confbytes, 0600); err != nil {
log.Fatal("Failed to write default configuration:", err)
}
}
// Read configuration. CLI flags take presedence over configuration file
func configure() {
log.Println("Reading configuration")
parseConfigFile()
debugflag := flag.Bool("debug", config.Debug, "Enable debug logging")
intervalflag := flag.Int("interval", config.Interval, "Set the interval of background updates")
logaccflag := flag.Bool("httplog", config.LogAccess, "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.")
certflag := flag.String("cert", config.SslCertificate, "Path to SSL public key to use for HTTPS")
keyflag := flag.String("key", config.SslPrivatekey, "Path to SSL private key to use for HTTPS")
usernflag := flag.String("username", config.Username, "Username for HTTP Basic Authentication")
passwflag := flag.String("password", config.Password, "Password for HTTP Basic Authentication")
promflag := flag.Bool("enable-metrics", config.EnableMetrics, "Enable the built-in Prometheus exporter")
logflag := flag.String("logfile", config.LogFile, "Path to log file. Default is empty string, log to stdout")
// TODO: log file flag
flag.Parse()
config.Debug = *debugflag
config.Interval = *intervalflag
config.LogAccess = *logaccflag
config.SslCertificate = *certflag
config.SslPrivatekey = *keyflag
config.Username = *usernflag
config.Password = *passwflag
config.EnableMetrics = *promflag
config.LogFile = *logflag
usernamehash = sha256.Sum256([]byte(config.Username))
passwordhash = sha256.Sum256([]byte(config.Password))
if len(config.LogFile) != 0 {
logfile, err := os.OpenFile(config.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
log.Fatal("Failed to open log file", config.LogFile)
}
log.SetOutput(logfile)
log.Println("Opened logfile")
}
// Check that certificate file exists, generate if needed
if _, err := os.Stat(config.SslCertificate); err != nil || *generatecert {
generateCertificate(config.SslCertificate, config.SslPrivatekey)
}
// Enable debug if configured
if config.Debug {
log.Println("Debug logging enabled")
}
// Enable HTTP access logging if configured
if config.LogAccess {
log.Println("HTTP Access logging enabled")
}
log.Println("Update interval set to", config.Interval, "seconds")
if config.EnableMetrics {
log.Println("Prometheus exporter enabled (/metrics)")
2023-03-15 21:35:23 +02:00
prometheus.MustRegister(&device)
}
}
func main() {
log.Println("enervent-ctrl version", version)
configure()
2023-03-15 21:42:44 +02:00
device = *pingvin.New(config.Debug)
2023-03-15 21:35:23 +02:00
device.Update()
go device.Monitor(config.Interval)
serve(&config.SslCertificate, &config.SslPrivatekey)
2023-03-15 21:35:23 +02:00
device.Quit()
}