diff --git a/CHANGELOG.md b/CHANGELOG.md index d77f75e..b7819bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ### Changed - adding randomized string to mqtt client (#15 by @LelandSindt) +## [1.4.0] - 2021-03-25 + +### Added +- added feature commands to proxy POST commands to Tesla owner API (#22) +- support for authentication on command endpoints + +## [1.3.1] - 2021-03-23 + +### Fixed +- fixing sql error when BatteryHeaterNoPower is null (#19 by @LelandSindt) + ## [1.3.0] - 2021-03-17 ### Added @@ -88,7 +99,9 @@ ## [1.0.0] - 2021-02-15 -[Unreleased]: https://github.com/tobiasehlert/teslamateapi/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/tobiasehlert/teslamateapi/compare/v1.4.0...HEAD +[1.3.1]: https://github.com/tobiasehlert/teslamateapi/compare/v1.3.1...v1.4.0 +[1.3.1]: https://github.com/tobiasehlert/teslamateapi/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/tobiasehlert/teslamateapi/compare/v1.2.3...v1.3.0 [1.2.3]: https://github.com/tobiasehlert/teslamateapi/compare/v1.2.2...v1.2.3 [1.2.2]: https://github.com/tobiasehlert/teslamateapi/compare/v1.2.1...v1.2.2 diff --git a/README.md b/README.md index 014d3af..45c57ed 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ TeslaMateApi is a RESTful API to get data collected by self-hosted data logger * - Written in **[Golang](https://golang.org/)** - Data is collected from TeslaMate **Postgres** database and local **MQTT** Broker - Endpoints return data in JSON format +- Send commands to your Tesla through the TeslaMateApi ### Table of Contents @@ -20,6 +21,9 @@ TeslaMateApi is a RESTful API to get data collected by self-hosted data logger * - [Docker-compose](#docker-compose) - [Environment variables](#environment-variables) - [API documentation](#api-documentation) + - [Available endpoints](#available-endpoints) + - [Authentication](#authentication) + - [Commands](#commands) - [Security information](#security-information) - [Credits](#credits) @@ -96,6 +100,7 @@ Basically the same environment variables for the database, mqqt and timezone nee **Optional** environment variables +- **API_TOKEN** string *(default: )* - **DATABASE_PORT** integer *(default: 5432)* - **DATABASE_TIMEOUT** integer *(default: 60000)* - **DATABASE_SSL** boolean *(default: true)* @@ -107,23 +112,84 @@ Basically the same environment variables for the database, mqqt and timezone nee - **MQTT_PASSWORD** string *(default: )* - **MQTT_NAMESPACE** string *(default: )* +**Commands** environment variables + +- **ENABLE_COMMANDS** boolean *(default: false)* +- **COMMANDS_ALL** boolean *(default: false)* +- **COMMANDS_ALLOWLIST** string *(default: allow_list.json)* +- **COMMANDS_WAKE** boolean *(default: false)* +- **COMMANDS_ALERT** boolean *(default: false)* +- **COMMANDS_REMOTESTART** boolean *(default: false)* +- **COMMANDS_HOMELINK** boolean *(default: false)* +- **COMMANDS_SPEEDLIMIT** boolean *(default: false)* +- **COMMANDS_VALET** boolean *(default: false)* +- **COMMANDS_SENTRYMODE** boolean *(default: false)* +- **COMMANDS_DOORS** boolean *(default: false)* +- **COMMANDS_TRUNK** boolean *(default: false)* +- **COMMANDS_WINDOWS** boolean *(default: false)* +- **COMMANDS_SUNROOF** boolean *(default: false)* +- **COMMANDS_CHARGING** boolean *(default: false)* +- **COMMANDS_CLIMATE** boolean *(default: false)* +- **COMMANDS_MEDIA** boolean *(default: false)* +- **COMMANDS_SHARING** boolean *(default: false)* +- **COMMANDS_SOFTWAREUPDATE** boolean *(default: false)* + ## API documentation More detailed documentation of every endpoint will come.. +### Available endpoints + - GET `/api` - GET `/api/v1` - GET `/api/v1/cars` - GET `/api/v1/cars/:CarID` - GET `/api/v1/cars/:CarID/charges` - GET `/api/v1/cars/:CarID/charges/:ChargeID` +- GET `/api/v1/cars/:CarID/command` +- POST `/api/v1/cars/:CarID/command/:Command` - GET `/api/v1/cars/:CarID/drives` - GET `/api/v1/cars/:CarID/drives/:DriveID` - GET `/api/v1/cars/:CarID/status` - GET `/api/v1/cars/:CarID/updates` +- POST `/api/v1/cars/:CarID/wake_up` - GET `/api/v1/globalsettings` - GET `/api/ping` +### Authentication + +If you want to use command endpoints such as `/api/v1/cars/:CarID/command/:Command` and `/api/v1/cars/:CarID/wake_up`, you need to add authentication to your request. + +You need to specify a token yourself (called **API_TOKEN**) in the environment variables file, to set it. The token has the requirement to be a minimum of 32 characters long. + +There are two options available for authentication to be done. + +1. Adding extra header `Authorization: Bearer ` to your request. (recommended option) + +2. Adding URI parameter `?token=` to the endpoint you try to reach. (not a good option) + +\* *Note: If you use the second option and your logs get compromised, your token will be leaked.* + +### Commands + +Commands are not enabled by default. + +You need to enable them in your environment variables (with `ENABLE_COMMANDS=true`) and you need to specify which commands you want to use as well. + +There are 3 ways of using Commands: + +1. Specific groups of commands can be enabled for example `COMMANDS_ALERT=true` will enable the [alert](https://tesla-api.timdorr.com/vehicle/commands/alerts) commands group. + +2. If you need a granular set of commands enabled `COMMANDS_ALLOWLIST=/path/to/allow_list.json` can be used to specify a [JSON formatted list of commands](./example/allow_list.json) to enable. + +3. The most coarse option `COMMANDS_ALL=true` will enable all commands (specific groups and allow_list will be ignored). + +\* *Note: if `COMMANDS_ALL` or any specific group of commands has been enabled `COMMANDS_ALLOWLIST` is ignored.* + +A list of possible commands can be found under [environment variables](#environment-variables). + +Regarding what fields you need to provide in the commands, we will referr to the [timdorr/tesla-api](https://tesla-api.timdorr.com/vehicle/commands) documentation. + ## Security information There is **no** possibility to get access to your Tesla account tokens by this API and we'll keep it this way! diff --git a/example/allow_list.json b/example/allow_list.json new file mode 100644 index 0000000..11817a1 --- /dev/null +++ b/example/allow_list.json @@ -0,0 +1 @@ +["/wake_up","/command/flash_lights"] diff --git a/src/AuthSupport.go b/src/AuthSupport.go new file mode 100644 index 0000000..5d0f8f5 --- /dev/null +++ b/src/AuthSupport.go @@ -0,0 +1,94 @@ +package main + +import ( + "log" + "strings" + + "github.com/gin-gonic/gin" +) + +// initAuthToken func +func initAuthToken() { + // get token from environment variable API_TOKEN + envToken = getEnv("API_TOKEN", "") + if envToken == "" { + log.Println("[warning] initAuthToken - environment variable API_TOKEN not set or is empty.") + } else if len(envToken) < 32 { + log.Println("[warning] initAuthToken - environment variable API_TOKEN too short.. should be 32 or longer.") + } else { + log.Println("[info] initAuthToken - environment variable API_TOKEN is set and good.") + } +} + +// validateAuthToken func +func validateAuthToken(c *gin.Context) (bool, string) { + + // trying with http header - Authorization: Bearer + reqHeaderToken := c.Request.Header.Get("Authorization") + + // if length of reqHeaderToken is more than zero + if len(reqHeaderToken) > 0 { + + // removing Bearer part from header to get token out of header + splitToken := strings.Split(reqHeaderToken, "Bearer") + + if len(splitToken) != 2 { + // bearer token is not proper formatted.. returning bad request + log.Println("[info] validateAuthToken - header authorization bearer token is not proper formatted.. returning 401") + return false, "header authorization bearer token is not proper formatted" + + } else if strings.TrimSpace(splitToken[1]) == "" { + // bearer token is empty string.. we'll return unauthorized + log.Println("[info] validateAuthToken - header authorization bearer token is empty.. returning 401") + return false, "header authorization bearer token is empty" + + } else if checkAuthToken(strings.TrimSpace(splitToken[1])) { + // the bearer token is valid! + log.Println("[debug] validateAuthToken - header authorization bearer token valid.") + return true, "" + + } + // the check did fail.. bearer token is invalid + log.Println("[info] validateAuthToken - header authorization bearer token invalid.. returning 401") + return false, "header authorization bearer token invalid" + } + + // trying with http parameter - ?token= + tokenParamsValue := c.DefaultQuery("token", "") + + // if validTokenParams is longer than zero + if len(tokenParamsValue) > 0 { + + // checking if token is valid (since it's over zero length) + if checkAuthToken(tokenParamsValue) { + // the token is valid! + log.Println("[debug] validateAuthToken - param token valid.") + return true, "" + + } + // the token is invalid. + log.Println("[info] validateAuthToken - param token invalid.. returning 401") + return false, "param token invalid" + } + + // unauthozie all calls! + return false, "failed validation" +} + +// checkAuthToken func +func checkAuthToken(token string) bool { + // checking if it's valid or not + if token == envToken { + // check that envToken was longer than zero + if len(envToken) == 0 { + log.Println("[warning] checkAuthToken - returning false (API_TOKEN is not set or empty)") + return false + } + log.Println("[info] checkAuthToken - returning true") + return true + } + + // failing check what so ever.. + log.Println("[info] checkAuthToken - returning false (other reason)") + return false +} diff --git a/src/CommandSupport.go b/src/CommandSupport.go new file mode 100644 index 0000000..685af04 --- /dev/null +++ b/src/CommandSupport.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +// initCommandAllowList func +func initCommandAllowList() { + + // allow all commands available below + allowAll := getEnvAsBool("COMMANDS_ALL", false) + + // https://tesla-api.timdorr.com/vehicle/commands/wake + if getEnvAsBool("COMMANDS_WAKE", false) || allowAll { + allowList = append(allowList, "/wake_up") + } + + // https://tesla-api.timdorr.com/vehicle/commands/alerts + if getEnvAsBool("COMMANDS_ALERT", false) || allowAll { + allowList = append(allowList, + "/command/honk_horn", + "/command/flash_lights") + } + + // https://tesla-api.timdorr.com/vehicle/commands/remotestart + if getEnvAsBool("COMMANDS_REMOTESTART", false) || allowAll { + allowList = append(allowList, "/command/remote_start_drive") + } + + // https://tesla-api.timdorr.com/vehicle/commands/homelink + if getEnvAsBool("COMMANDS_HOMELINK", false) || allowAll { + allowList = append(allowList, "/command/trigger_homelink") + } + + // https://tesla-api.timdorr.com/vehicle/commands/speedlimit + if getEnvAsBool("COMMANDS_SPEEDLIMIT", false) || allowAll { + allowList = append(allowList, + "/command/speed_limit_set_limit", + "/command/speed_limit_activate", + "/command/speed_limit_deactivate", + "/command/speed_limit_clear_pin") + } + + // https://tesla-api.timdorr.com/vehicle/commands/valet + if getEnvAsBool("COMMANDS_VALET", false) || allowAll { + allowList = append(allowList, + "/command/set_valet_mode", + "/command/reset_valet_pin") + } + + // https://tesla-api.timdorr.com/vehicle/commands/sentrymode + if getEnvAsBool("COMMANDS_SENTRYMODE", false) || allowAll { + allowList = append(allowList, "/command/set_sentry_mode") + } + + // https://tesla-api.timdorr.com/vehicle/commands/doors + if getEnvAsBool("COMMANDS_DOORS", false) || allowAll { + allowList = append(allowList, + "/command/door_unlock", + "/command/door_lock") + } + + // https://tesla-api.timdorr.com/vehicle/commands/trunk + if getEnvAsBool("COMMANDS_TRUNK", false) || allowAll { + allowList = append(allowList, "/command/actuate_trunk") + } + + // https://tesla-api.timdorr.com/vehicle/commands/windows + if getEnvAsBool("COMMANDS_WINDOWS", false) || allowAll { + allowList = append(allowList, "/command/window_control") + } + + // https://tesla-api.timdorr.com/vehicle/commands/sunroof + if getEnvAsBool("COMMANDS_SUNROOF", false) || allowAll { + allowList = append(allowList, "/command/sun_roof_control") + } + + // https://tesla-api.timdorr.com/vehicle/commands/charging + if getEnvAsBool("COMMANDS_CHARGING", false) || allowAll { + allowList = append(allowList, + "/command/charge_port_door_open", + "/command/charge_port_door_close", + "/command/charge_start", + "/command/charge_stop", + "/command/charge_standard", + "/command/charge_max_range", + "/command/set_charge_limit") + } + + // https://tesla-api.timdorr.com/vehicle/commands/climate + if getEnvAsBool("COMMANDS_CLIMATE", false) || allowAll { + allowList = append(allowList, + "/command/auto_conditioning_start", + "/command/auto_conditioning_stop", + "/command/set_temps", + "/command/set_preconditioning_max", + "/command/remote_seat_heater_request", + "/command/remote_steering_wheel_heater_request") + } + + // https://tesla-api.timdorr.com/vehicle/commands/media + if getEnvAsBool("COMMANDS_MEDIA", false) || allowAll { + allowList = append(allowList, + "/command/media_toggle_playback", + "/command/media_next_track", + "/command/media_prev_track", + "/command/media_next_fav", + "/command/media_prev_fav", + "/command/media_volume_up", + "/command/media_volume_down") + } + + // https://tesla-api.timdorr.com/vehicle/commands/sharing + if getEnvAsBool("COMMANDS_SHARING", false) || allowAll { + allowList = append(allowList, "/command/share") + } + + // https://tesla-api.timdorr.com/vehicle/commands/softwareupdate + if getEnvAsBool("COMMANDS_SOFTWAREUPDATE", false) || allowAll { + allowList = append(allowList, + "/command/schedule_software_update", + "/command/cancel_software_update") + } + + // if allowList is empty, read COMMANDS_ALLOWLIST and append to allowList + commandAllowListLocation := getEnv("COMMANDS_ALLOWLIST", "allow_list.json") + if len(allowList) == 0 { + var allowListFile []string + commandAllowListFile, err := os.Open(commandAllowListLocation) + defer commandAllowListFile.Close() + if err != nil { + log.Println("[error] getAllowList error with COMMANDS_ALLOWLIST: " + commandAllowListLocation + " not found and will be ignored") + } else { + byteValue, err := ioutil.ReadAll(commandAllowListFile) + if err != nil { + log.Println("[error] getAllowList error while reading COMMANDS_ALLOWLIST: " + commandAllowListLocation + " it will be ignored") + } else { + err = json.Unmarshal(byteValue, &allowListFile) + if err != nil { + log.Println("[error] getAllowList error while parsing JSON.. COMMANDS_ALLOWLIST: " + commandAllowListLocation + " it will be ignored") + } else { + allowList = append(allowList, allowListFile...) + } + } + } + } else { + log.Print("[info] getAllowList COMMANDS from environment variables set, " + commandAllowListLocation + " will be ignored.") + } + + if gin.IsDebugging() { + log.Println("[info] initCommandAllowList - generated following list of allowed commands: " + strings.Join(allowList, ", ")) + } +} diff --git a/src/v1_TeslaMateAPICarsCommand.go b/src/v1_TeslaMateAPICarsCommand.go new file mode 100644 index 0000000..4193500 --- /dev/null +++ b/src/v1_TeslaMateAPICarsCommand.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" +) + +// TeslaMateAPICarsCommandV1 func +func TeslaMateAPICarsCommandV1(c *gin.Context) { + + // creating required vars + var TeslaAccessToken, TeslaVehicleID string + var jsonData map[string]interface{} + var err error + + // check if commands are enabled.. if not we need to abort + if getEnvAsBool("ENABLE_COMMANDS", false) == false { + log.Println("[warning] TeslaMateAPICarsCommandV1 ENABLE_COMMANDS is not true.. returning 403 forbidden.") + c.JSON(http.StatusForbidden, gin.H{"error": "You are not allowed to access commands"}) + return + } + + // if request method is GET return list of commands + if c.Request.Method == http.MethodGet { + c.JSON(http.StatusOK, gin.H{"enabled_commands": allowList}) + return + } + + // authentication for the endpoint + validToken, errorMessage := validateAuthToken(c) + if !validToken { + c.JSON(http.StatusUnauthorized, gin.H{"error": errorMessage}) + return + } + + // getting CarID param from URL + ParamCarID := c.Param("CarID") + var CarID int + if ParamCarID != "" { + CarID = convertStringToInteger(ParamCarID) + } + + // validating that CarID is not zero + if CarID == 0 { + log.Println("[error] TeslaMateAPICarsCommandV1 CarID is invalid (zero)!") + c.JSON(http.StatusBadRequest, gin.H{"error": "CarID invalid"}) + return + } + + // getting request body to pass to Tesla + reqBody, err := ioutil.ReadAll(c.Request.Body) + if err != nil { + log.Println("[error] TeslaMateAPICarsCommandV1 error in first ioutil.ReadAll", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal"}) + return + } + + // getting :Command + command := ("/command/" + c.Param("Command")) + // if command is /command/ or /command/wake_up, set to /wake_up only + if command == "/command/" || command == "/command/wake_up" { + command = "/wake_up" + } + log.Println("[debug] TeslaMateAPICarsCommandV1 command received:", command) + + if !checkArrayContainsString(allowList, command) { + log.Print("[warning] TeslaMateAPICarsCommandV1 command: " + command + " not allowed") + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // get TeslaVehicleID and TeslaAccessToken + query := ` + SELECT + eid as TeslaVehicleID, + (SELECT access FROM tokens LIMIT 1) as TeslaAccessToken + FROM cars + WHERE id = $1 + LIMIT 1;` + rows, err := db.Query(query, CarID) + + // checking for errors in query + if err != nil { + log.Fatal(err) + } + + // defer closing rows + defer rows.Close() + + // looping through all results (even if it's only one..) + for rows.Next() { + // scanning row and putting values into the drive + err = rows.Scan( + &TeslaVehicleID, + &TeslaAccessToken, + ) + } + + // checking for errors in query when doing scan action + if err != nil { + log.Println("[error] TeslaMateAPICarsCommandV1 error in sql query:", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal"}) + return + } + + client := &http.Client{} + req, _ := http.NewRequest(http.MethodPost, "https://owner-api.teslamotors.com/api/1/vehicles/"+TeslaVehicleID+command, strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer "+TeslaAccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "TeslaMateApi/"+apiVersion+" (+https://github.com/tobiasehlert/teslamateapi)") + resp, err := client.Do(req) + + // check response error + if err != nil { + log.Println("[error] TeslaMateAPICarsCommandV1 error in http request to https://owner-api.teslamotors.com:", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal"}) + return + } + + defer resp.Body.Close() + defer client.CloseIdleConnections() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Println("[error] TeslaMateAPICarsCommandV1 error in second ioutil.ReadAll:", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal"}) + return + } + json.Unmarshal([]byte(respBody), &jsonData) + + // print to log about request + if gin.IsDebugging() { + log.Println("[debug] TeslaMateAPICarsCommandV1 " + c.Request.RequestURI + " returned data:") + js, _ := json.Marshal(jsonData) + log.Printf("[debug] %s\n", js) + } + + if resp.StatusCode == http.StatusOK { + log.Println("[info] TeslaMateAPICarsCommandV1 " + c.Request.RequestURI + " executed successful.") + } else { + log.Println("[error] TeslaMateAPICarsCommandV1 " + c.Request.RequestURI + " error in execution!") + } + c.JSON(resp.StatusCode, jsonData) +} diff --git a/src/webserver.go b/src/webserver.go index 66d2ec7..99e37ef 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -14,9 +14,19 @@ import ( _ "github.com/lib/pq" ) +// setting TeslaMateApi version number +// TODO: get the value from git-tag later.. +var apiVersion = "1.4.0" + // defining db var var db *sql.DB +// defining envToken that contains API_TOKEN value +var envToken string + +// list of allowed commands +var allowList []string + // main function func main() { @@ -38,6 +48,11 @@ func main() { initDBconnection() defer db.Close() + // run initAuthToken to validate environment vars + initAuthToken() + // initialize allowList stored for /command section + initCommandAllowList() + // Connect to the MQTT broker statusCache, err := startMQTT() if err != nil { @@ -68,14 +83,33 @@ func main() { c.JSON(http.StatusOK, gin.H{"message": "TeslaMateApi v1 runnnig..", "path": "/api/v1"}) }) + // v1 /api/v1/cars endpoints v1.GET("/cars", TeslaMateAPICarsV1) v1.GET("/cars/:CarID", TeslaMateAPICarsV1) + + // v1 /api/v1/cars/:CarID/charges endpoints v1.GET("/cars/:CarID/charges", TeslaMateAPICarsChargesV1) v1.GET("/cars/:CarID/charges/:ChargeID", TeslaMateAPICarsChargesDetailsV1) + + // v1 /api/v1/cars/:CarID/command endpoints + v1.GET("/cars/:CarID/command", TeslaMateAPICarsCommandV1) + v1.GET("/cars/:CarID/commands", TeslaMateAPICarsCommandV1) + v1.POST("/cars/:CarID/command/:Command", TeslaMateAPICarsCommandV1) + + // v1 /api/v1/cars/:CarID/drives endpoints v1.GET("/cars/:CarID/drives", TeslaMateAPICarsDrivesV1) v1.GET("/cars/:CarID/drives/:DriveID", TeslaMateAPICarsDrivesDetailsV1) + + // v1 /api/v1/cars/:CarID/status endpoints v1.GET("/cars/:CarID/status", statusCache.TeslaMateAPICarsStatusV1) + + // v1 /api/v1/cars/:CarID/updates endpoints v1.GET("/cars/:CarID/updates", TeslaMateAPICarsUpdatesV1) + + // v1 /api/v1/cars/:CarID/wake_up endpoints + v1.POST("/cars/:CarID/wake_up", TeslaMateAPICarsCommandV1) + + // v1 /api/v1/globalsettings endpoints v1.GET("/globalsettings", TeslaMateAPIGlobalsettingsV1) } @@ -83,7 +117,7 @@ func main() { api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) } - // TeslaMateApi endpoints (bofore versioning) + // TeslaMateApi endpoints (before versioning) r.GET("/cars", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/api/v1"+c.Request.RequestURI) }) r.GET("/cars/:CarID", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/api/v1"+c.Request.RequestURI) }) r.GET("/cars/:CarID/charges", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/api/v1"+c.Request.RequestURI) }) @@ -282,3 +316,13 @@ func fahrenheitToCelsiusNilSupport(f NullFloat64) NullFloat64 { f.Float64 = ((f.Float64 - 32) * 5 / 9) return (f) } + +// checkArrayContainsString func - check if string is inside stringarray +func checkArrayContainsString(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +}