2023-01-28 23:28:37 +02:00
package main
import (
2023-03-14 09:37:36 +02:00
"crypto/sha256"
"crypto/subtle"
2023-02-02 11:44:30 +02:00
"embed"
2023-01-29 23:31:21 +02:00
"encoding/json"
2023-03-03 10:09:43 +02:00
"flag"
2023-02-02 11:44:30 +02:00
"io/fs"
2023-03-14 12:37:34 +02:00
"io/ioutil"
2023-01-29 23:31:21 +02:00
"log"
"net/http"
2023-03-03 10:09:43 +02:00
"os"
2023-02-24 22:52:13 +02:00
"strconv"
"strings"
2023-03-14 09:37:36 +02:00
"time"
2023-01-29 14:42:03 +02:00
2023-03-15 21:29:57 +02:00
"github.com/0ranki/enervent-ctrl/pingvin"
2023-03-14 09:37:36 +02:00
"github.com/0ranki/https-go"
2023-03-03 10:09:43 +02:00
"github.com/gorilla/handlers"
2023-03-14 23:28:09 +02:00
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
2023-03-14 12:37:34 +02:00
"gopkg.in/yaml.v3"
2023-01-28 23:28:37 +02:00
)
2023-02-02 13:38:31 +02:00
// Remember to dereference the symbolic links under ./static/html
// prior to building the binary e.g. by using tar
2023-02-02 11:44:30 +02:00
//go:embed static/html/*
var static embed . FS
2023-01-29 23:31:21 +02:00
var (
2023-10-09 21:33:06 +03:00
version = "0.0.28"
2023-03-15 21:39:14 +02:00
device pingvin . Pingvin
2023-03-14 12:37:34 +02:00
config Conf
2023-03-14 09:37:36 +02:00
usernamehash [ 32 ] byte
passwordhash [ 32 ] byte
2023-01-29 23:31:21 +02:00
)
2023-03-14 12:37:34 +02:00
type Conf struct {
2023-03-20 20:29:00 +02:00
SerialAddress string ` yaml:"serial_address" `
2023-03-14 12:37:34 +02:00
Port int ` yaml:"port" `
SslCertificate string ` yaml:"ssl_certificate" `
SslPrivatekey string ` yaml:"ssl_privatekey" `
2023-10-09 21:08:26 +03:00
DisableAuth bool ` yaml:"disable_auth" `
2023-03-14 12:37:34 +02:00
Username string ` yaml:"username" `
Password string ` yaml:"password" `
Interval int ` yaml:"interval" `
2023-03-14 23:28:09 +02:00
EnableMetrics bool ` yaml:"enable_metrics" `
2023-03-14 23:43:56 +02:00
LogFile string ` yaml:"log_file" `
2023-03-14 12:37:34 +02:00
LogAccess bool ` yaml:"log_access" `
Debug bool ` yaml:"debug" `
2023-10-09 21:33:06 +03:00
ReadOnly bool ` yaml:"read_only" `
2023-03-14 12:37:34 +02:00
}
2023-03-14 09:37:36 +02:00
// HTTP Basic Authentication middleware for http.HandlerFunc
2023-10-09 21:08:26 +03:00
// This is used for the API
2023-03-14 09:37:36 +02:00
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 ) {
2023-10-09 21:08:26 +03:00
if config . DisableAuth {
next . ServeHTTP ( w , r )
return
}
2023-03-14 09:37:36 +02:00
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
2023-10-09 21:08:26 +03:00
// Used for the HTML monitor views
2023-03-14 09:37:36 +02:00
func authHandler ( next http . Handler ) http . HandlerFunc {
return http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2023-10-09 21:08:26 +03:00
if config . DisableAuth {
next . ServeHTTP ( w , r )
return
}
2023-03-14 09:37:36 +02:00
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 )
} )
}
2023-03-14 12:37:34 +02:00
// \/api/v1/coils endpoint
2023-01-29 23:31:21 +02:00
func coils ( w http . ResponseWriter , r * http . Request ) {
2023-01-29 23:43:36 +02:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2023-02-24 22:52:13 +02:00
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 )
2023-02-24 22:52:13 +02:00
} 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 ] )
2023-02-24 22:52:13 +02:00
} 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-10-09 21:33:06 +03:00
if config . ReadOnly {
log . Println ( "WARNING: Read only mode, refusing to write to device" )
} else {
device . WriteCoil ( uint16 ( intaddr ) , boolval )
}
2023-03-15 21:35:23 +02:00
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-10-09 21:33:06 +03:00
if config . ReadOnly {
log . Println ( "WARNING: Read only mode, refusing to write to device" )
} else {
device . WriteCoil ( uint16 ( intaddr ) , ! device . Coils [ intaddr ] . Value )
}
2023-03-15 21:35:23 +02:00
json . NewEncoder ( w ) . Encode ( device . Coils [ intaddr ] )
2023-02-24 22:52:13 +02:00
}
2023-01-29 23:31:21 +02:00
}
2023-03-14 12:37:34 +02:00
// \/api/v1/registers endpoint
2023-02-01 22:12:56 +02:00
func registers ( w http . ResponseWriter , r * http . Request ) {
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2023-03-02 14:24:05 +02:00
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 )
2023-03-02 14:24:05 +02:00
} 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 ] )
2023-03-02 14:24:05 +02:00
} 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-10-09 21:33:06 +03:00
if config . ReadOnly {
log . Println ( "WARNING: Read only mode, refusing to write to device" )
} else {
_ , err = device . WriteRegister ( uint16 ( intaddr ) , uint16 ( intval ) )
if err != nil {
log . Println ( err )
}
2023-03-02 14:24:05 +02:00
}
2023-03-15 21:35:23 +02:00
json . NewEncoder ( w ) . Encode ( device . Registers [ intaddr ] )
2023-02-01 22:19:57 +02:00
}
2023-02-01 22:12:56 +02:00
}
2023-03-14 12:37:34 +02:00
// \/status endpoint
2023-02-18 21:52:53 +02:00
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 )
2023-02-18 21:52:53 +02:00
}
2023-03-14 12:37:34 +02:00
// \/api/v1/temperature endpoint
2023-03-09 16:15:54 +02:00
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 ] )
2023-03-09 16:15:54 +02:00
} else {
return
}
}
2023-03-14 12:37:34 +02:00
// Start the HTTP server
2023-03-14 09:37:36 +02:00
func serve ( cert , key * string ) {
2023-03-15 21:42:44 +02:00
log . Println ( "Starting service" )
2023-03-14 09:37:36 +02:00
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 ) )
2023-03-14 23:28:09 +02:00
if config . EnableMetrics {
http . Handle ( "/metrics" , promhttp . Handler ( ) )
}
2023-02-02 11:44:30 +02:00
html , err := fs . Sub ( static , "static/html" )
if err != nil {
log . Fatal ( err )
}
htmlroot := http . FileServer ( http . FS ( html ) )
2023-03-14 09:37:36 +02:00
http . HandleFunc ( "/" , authHandler ( htmlroot ) )
logdst , err := os . OpenFile ( os . DevNull , os . O_WRONLY , os . ModeAppend )
if err != nil {
log . Fatal ( err )
}
2023-03-14 12:37:34 +02:00
if config . LogAccess {
2023-03-14 09:37:36 +02:00
logdst = os . Stdout
2023-03-03 10:09:43 +02:00
}
2023-03-14 09:37:36 +02:00
handler := handlers . LoggingHandler ( logdst , http . DefaultServeMux )
err = http . ListenAndServeTLS ( ":8888" , * cert , * key , handler )
2023-02-01 21:52:55 +02:00
if err != nil {
log . Fatal ( err )
}
2023-01-29 23:31:21 +02:00
}
2023-03-14 12:37:34 +02:00
// Generate self-signed SSL keypair
func generateCertificate ( cert , key string ) {
2023-03-14 09:37:36 +02:00
opts := https . GenerateOptions { Host : "enervent-ctrl.local" , RSABits : 4096 , ValidFor : 10 * 365 * 24 * time . Hour }
2023-03-14 12:37:34 +02:00
log . Println ( "Generating new self-signed SSL keypair" )
2023-03-14 09:37:36 +02:00
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 ) )
2023-03-14 09:37:36 +02:00
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 )
}
2023-03-14 12:37:34 +02:00
// Read & parse the configuration file
func parseConfigFile ( ) {
2023-03-14 09:37:36 +02:00
homedir , err := os . UserHomeDir ( )
if err != nil {
log . Fatal ( "Could not determine user home directory" )
}
2023-03-14 12:37:34 +02:00
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 {
2023-10-09 21:08:26 +03:00
SerialAddress : "/dev/ttyS0" ,
Port : 8888 ,
SslCertificate : confpath + "/certificate.pem" ,
SslPrivatekey : confpath + "/privatekey.pem" ,
DisableAuth : false ,
Username : "pingvin" ,
Password : "enervent" ,
Interval : 4 ,
EnableMetrics : false ,
LogAccess : false ,
LogFile : "" ,
Debug : false ,
2023-10-09 21:33:06 +03:00
ReadOnly : false ,
2023-03-14 12:37:34 +02:00
}
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" )
2023-03-14 09:37:36 +02:00
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." )
2023-03-14 12:37:34 +02:00
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" )
2023-10-09 21:08:26 +03:00
noauthflag := flag . Bool ( "disable-auth" , config . DisableAuth , "Disable HTTP basic authentication" )
2023-03-14 12:37:34 +02:00
usernflag := flag . String ( "username" , config . Username , "Username for HTTP Basic Authentication" )
passwflag := flag . String ( "password" , config . Password , "Password for HTTP Basic Authentication" )
2023-03-14 23:28:09 +02:00
promflag := flag . Bool ( "enable-metrics" , config . EnableMetrics , "Enable the built-in Prometheus exporter" )
2023-03-14 23:43:56 +02:00
logflag := flag . String ( "logfile" , config . LogFile , "Path to log file. Default is empty string, log to stdout" )
2023-03-20 20:29:00 +02:00
serialflag := flag . String ( "serial" , config . SerialAddress , "Path to serial console for RS-485 connection. Defaults to /dev/ttyS0" )
2023-10-09 21:33:06 +03:00
readOnly := flag . Bool ( "read-only" , config . ReadOnly , "Read only mode, no writes to device are allowed" )
2023-03-14 09:37:36 +02:00
// TODO: log file flag
2023-03-03 10:09:43 +02:00
flag . Parse ( )
2023-03-14 12:37:34 +02:00
config . Debug = * debugflag
config . Interval = * intervalflag
config . LogAccess = * logaccflag
config . SslCertificate = * certflag
config . SslPrivatekey = * keyflag
2023-10-09 21:08:26 +03:00
config . DisableAuth = * noauthflag
2023-03-14 12:37:34 +02:00
config . Username = * usernflag
config . Password = * passwflag
2023-03-14 23:28:09 +02:00
config . EnableMetrics = * promflag
2023-03-14 23:43:56 +02:00
config . LogFile = * logflag
2023-03-20 20:29:00 +02:00
config . SerialAddress = * serialflag
2023-10-09 21:33:06 +03:00
config . ReadOnly = * readOnly
2023-03-14 12:37:34 +02:00
usernamehash = sha256 . Sum256 ( [ ] byte ( config . Username ) )
passwordhash = sha256 . Sum256 ( [ ] byte ( config . Password ) )
2023-03-14 23:43:56 +02:00
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" )
}
2023-03-14 12:37:34 +02:00
// Check that certificate file exists, generate if needed
if _ , err := os . Stat ( config . SslCertificate ) ; err != nil || * generatecert {
generateCertificate ( config . SslCertificate , config . SslPrivatekey )
2023-03-14 09:37:36 +02:00
}
2023-03-14 12:37:34 +02:00
// Enable debug if configured
if config . Debug {
2023-03-03 10:09:43 +02:00
log . Println ( "Debug logging enabled" )
}
2023-03-14 12:37:34 +02:00
// Enable HTTP access logging if configured
if config . LogAccess {
2023-03-03 10:09:43 +02:00
log . Println ( "HTTP Access logging enabled" )
}
2023-03-14 12:37:34 +02:00
log . Println ( "Update interval set to" , config . Interval , "seconds" )
2023-03-14 23:28:09 +02:00
if config . EnableMetrics {
2023-03-14 23:43:56 +02:00
log . Println ( "Prometheus exporter enabled (/metrics)" )
2023-03-15 21:35:23 +02:00
prometheus . MustRegister ( & device )
2023-03-14 23:28:09 +02:00
}
2023-03-03 10:09:43 +02:00
}
2023-01-28 23:28:37 +02:00
func main ( ) {
2023-01-29 23:31:21 +02:00
log . Println ( "enervent-ctrl version" , version )
2023-03-14 12:37:34 +02:00
configure ( )
2023-03-20 20:29:00 +02:00
device = * pingvin . New ( config . SerialAddress , config . Debug )
2023-03-15 21:35:23 +02:00
device . Update ( )
go device . Monitor ( config . Interval )
2023-03-14 12:37:34 +02:00
serve ( & config . SslCertificate , & config . SslPrivatekey )
2023-03-15 21:35:23 +02:00
device . Quit ( )
2023-01-28 23:28:37 +02:00
}