Merge branch 'main' into bug-8

This commit is contained in:
Tobias Lindberg
2021-03-25 10:38:40 +01:00
7 changed files with 529 additions and 2 deletions
+14 -1
View File
@@ -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
+66
View File
@@ -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 <token>` to your request. (recommended option)
2. Adding URI parameter `?token=<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!
+1
View File
@@ -0,0 +1 @@
["/wake_up","/command/flash_lights"]
+94
View File
@@ -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 <token>
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=<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
}
+159
View File
@@ -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, ", "))
}
}
+150
View File
@@ -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)
}
+45 -1
View File
@@ -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
}