evcc-io/server/http.go
Michael Geers 4c907c5afa
Merge commit from fork
* Config: require admin password for script plugins

* refactor
2026-06-24 15:45:15 +02:00

416 lines
19 KiB
Go

package server
import (
"fmt"
"net/http"
"os"
"time"
eapi "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/shm"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/server/remote"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/telemetry"
"github.com/go-http-utils/etag"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
type publisher func(string, any)
type route struct {
Method string
Pattern string
HandlerFunc http.HandlerFunc
}
func (r route) Methods() []string {
if r.Method == http.MethodGet {
return []string{r.Method}
}
return []string{r.Method, http.MethodOptions}
}
// HTTPd wraps an http.Server and adds the root router
type HTTPd struct {
*http.Server
}
// NewHTTPd creates HTTP server with configured routes for loadpoint
func NewHTTPd(addr string, hub *SocketHub, customCssFile string) *HTTPd {
router := mux.NewRouter().StrictSlash(true)
log := util.NewLogger("httpd")
// log all requests
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.TRACE.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
})
// websocket
router.HandleFunc("/ws", socketHandler(hub))
// static - individual handlers per root and folders
static := router.PathPrefix("/").Subrouter()
static.Use(handlers.CompressHandler)
static.Use(handlers.CompressHandler, func(h http.Handler) http.Handler {
return etag.Handler(h, false)
})
// allow requesting http assets from a non-private host. see https://developer.chrome.com/blog/cors-rfc1918-feedback?hl=de#step-2:-sending-preflight-requests-with-a-special-header
static.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Private-Network", "true")
next.ServeHTTP(w, r)
})
})
if customCssFile != "" {
log.WARN.Printf("❗ using custom CSS: %s", customCssFile)
if _, err := os.Stat(customCssFile); os.IsNotExist(err) {
log.FATAL.Fatalf("custom CSS file does not exist: %s", customCssFile)
}
static.HandleFunc("/custom.css", func(w http.ResponseWriter, r *http.Request) {
// disable caching
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
http.ServeFile(w, r, customCssFile)
})
}
static.HandleFunc("/", indexHandler(customCssFile != ""))
for _, dir := range []string{"assets", "meta"} {
static.PathPrefix("/" + dir).Handler(http.FileServer(http.FS(assets.Web)))
}
static.PathPrefix("/i18n").Handler(http.StripPrefix("/i18n", http.FileServer(http.FS(assets.I18n))))
srv := &HTTPd{
Server: &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ErrorLog: log.ERROR,
},
}
srv.SetKeepAlivesEnabled(true)
return srv
}
// Router returns the main router
func (s *HTTPd) Router() *mux.Router {
return s.Handler.(*mux.Router)
}
// RegisterSiteHandlers connects the http handlers to the site
func (s *HTTPd) RegisterSiteHandlers(site site.API) {
router := s.Server.Handler.(*mux.Router)
// api
api := router.PathPrefix("/api").Subrouter()
api.Use(jsonHandler)
api.Use(handlers.CompressHandler)
api.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
// site api
smartCostLimit := func(lp loadpoint.API, limit *float64) {
lp.SetSmartCostLimit(limit)
}
smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) {
lp.SetSmartFeedInPriorityLimit(limit)
}
routes := map[string]route{
"buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"bufferstartsoc": {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[01truefalse]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
"batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(site.SetBatteryGridChargeLimit, site.GetBatteryGridChargeLimit)},
"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(site.SetBatteryGridChargeLimit, site.GetBatteryGridChargeLimit)},
"batterymode": {"POST", "/batterymode/{value:[a-z]+}", updateBatteryMode(site)},
"batterymodedelete": {"DELETE", "/batterymode", updateBatteryMode(site)},
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartCostLimit)},
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)},
"smartfeedin": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartFeedInPriorityLimit)},
"smartfeedindelete": {"DELETE", "/smartfeedinprioritylimit", updateSmartCostLimit(site, smartFeedInPriorityLimit)},
"tariff": {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
"sessions": {"GET", "/sessions", sessionHandler},
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
"energyhistory": {"GET", "/history/energy", energyHistoryHandler},
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
"optimizerchargingstrategy": {"POST", "/optimizerchargingstrategy/{value:[a-z_]+}", stringHandler(site.SetOptimizerChargingStrategy, site.GetOptimizerChargingStrategy)},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// vehicle api
vehicles := map[string]route{
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
"limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)},
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)},
"plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
"repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)},
"planStrategy": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/strategy", updatePlanStrategyHandler(site)},
// config ui
// "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)},
// "mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(pass(v.SetMinCurrent), v.GetMinCurrent)},
// "maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(pass(v.SetMaxCurrent), v.GetMaxCurrent)},
// "phases": {"POST", "/phases/{value:[0-9]+}", intHandler(pass(v.SetMinSoc), v.GetMinSoc)},
}
for _, r := range vehicles {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// loadpoint api
// TODO any loadpoint
for id, lp := range site.Loadpoints() {
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
routes := map[string]route{
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)},
"plan": {"GET", "/plan", planHandler(lp)},
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
"planStrategy": {"POST", "/plan/strategy", planStrategyHandler(lp)},
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
"smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })},
"batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
}
// RegisterSystemHandler provides system level handlers
func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string, remoteAccess *remote.Remote) {
router := s.Server.Handler.(*mux.Router)
// api
api := router.PathPrefix("/api").Subrouter()
api.Use(jsonHandler)
api.Use(handlers.CompressHandler)
api.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
if site == nil {
// If site is nil, create a new empty site. Settings will be loaded during this process and
// site meter references and title can be updated using APIs.
var err error
site, err = core.NewSiteFromConfig(nil)
if err != nil {
// should not happen
panic(err)
}
}
{ // /api
routes := map[string]route{
"state": {"GET", "/state", stateHandler(cache)},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
{
// api/auth
api := api.PathPrefix("/auth").Subrouter()
routes := map[string]route{
"password": {"PUT", "/password", updatePasswordHandler(auth)},
"auth": {"GET", "/status", authStatusHandler(auth)},
"login": {"POST", "/login", loginHandler(auth)},
"logout": {"POST", "/logout", logoutHandler},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// API key endpoints require an authenticated session.
ensureAuth := ensureAuthHandler(auth)
api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth)))
api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth)))
}
{ // api/config
api := api.PathPrefix("/config").Subrouter()
api.Use(ensureAuthHandler(auth))
routes := map[string]route{
"auth": {"POST", "/auth", authHandler},
"authmerged": {"POST", "/auth/{class:[a-z]+}/merge/{id:[0-9.]+}", authHandler},
"templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler},
"products": {"GET", "/products/{class:[a-z]+}", productsHandler},
"devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler},
"device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)},
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)},
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
"experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)},
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
"remote": {"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)},
"remoteclients": {"GET", "/remote/clients", remoteClientsHandler(remoteAccess)},
"createremoteclient": {"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)},
"deleteremoteclient": {"DELETE", "/remote/clients", deleteRemoteClientHandler(remoteAccess)},
}
// yaml handlers
for key, fun := range map[string]func() (any, any){
keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} },
keys.Tariffs: func() (any, any) { return map[string]any{}, globalconfig.Tariffs{} },
keys.Messaging: func() (any, any) { return map[string]any{}, globalconfig.Messaging{} }, // has default
keys.Circuits: func() (any, any) { return []map[string]any{}, []config.Named{} }, // slice
} {
other, struc := fun()
routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetStringHandler(key)}
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetYamlHandler(key, other, struc)}
routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteHandler(key)}
}
// json handlers
for key, fun := range map[string]func() any{
keys.Network: func() any { return new(globalconfig.Network) }, // has default
keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default
keys.ModbusProxy: func() any { return new([]globalconfig.ModbusProxy) }, // slice
keys.Shm: func() any { return new(shm.Config) },
keys.Influx: func() any { return new(globalconfig.Influx) },
keys.EEBus: func() any { return new(eebus.Config) },
keys.MessagingEvents: func() any { return new(globalconfig.MessagingEvents) },
} {
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, pub, fun)}
routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, pub, fun())}
}
// ocpp forwarder rules apply at runtime and republish via the ocpp package
routes["updateocppforwarder"] = route{Method: "POST", Pattern: "/ocppforwarder", HandlerFunc: updateOcppForwarderHandler}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// services
api.PathPrefix("/service").Handler(http.StripPrefix("/api/config/service", service.Handler()))
// site
for _, r := range map[string]route{
"site": {"GET", "/site", siteHandler(site)},
"updatesite": {"PUT", "/site", updateSiteHandler(site)},
} {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// tariffs
for _, r := range map[string]route{
"tariff": {"GET", "/tariff", tariffsHandler},
"updatetariff": {"PUT", "/tariff", updateTariffHandler},
"updatecurrency": {"PUT", "/currency", updateCurrencyHandler(pub)},
} {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// loadpoints
for _, r := range map[string]route{
"loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},
"loadpoint": {"GET", "/loadpoints/{id:[0-9.]+}", loadpointConfigHandler()},
"updateloadpoint": {"PUT", "/loadpoints/{id:[0-9.]+}", updateLoadpointHandler()},
"deleteloadpoint": {"DELETE", "/loadpoints/{id:[0-9.]+}", deleteLoadpointHandler()},
"newloadpoint": {"POST", "/loadpoints", newLoadpointHandler()},
} {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
{ // api/system
api := api.PathPrefix("/system").Subrouter()
api.Use(ensureAuthHandler(auth))
routes := map[string]route{
"log": {"GET", "/log", logHandler},
"logareas": {"GET", "/log/areas", logAreasHandler},
"clearcache": {"DELETE", "/cache", clearCacheHandler},
"shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
shutdown()
w.WriteHeader(http.StatusNoContent)
}},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
{ // api/db — destructive DB operations; require session+X-Admin-Password or API key
api := api.PathPrefix("/db").Subrouter()
api.Use(ensureDbAuth(auth))
routes := map[string]route{
"backup": {"GET", "/backup", getBackup()},
"restore": {"POST", "/restore", restoreDatabase(shutdown)},
"reset": {"POST", "/reset", resetDatabase(shutdown)},
}
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
}