Skip to content

Commit

Permalink
Add custom header to specify fleetapi base url
Browse files Browse the repository at this point in the history
  • Loading branch information
tnsetting2023 committed Sep 23, 2024
1 parent e8634aa commit b35505a
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 28 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ The following environment variables can used in lieu of command-line flags.
* `TESLA_HTTP_PROXY_PORT` specifies the port for the HTTP proxy.
* `TESLA_HTTP_PROXY_TIMEOUT` specifies the timeout for the HTTP proxy to use when
contacting Tesla servers.
* `TESLA_HTTP_PROXY_FLEETAPI_HOST` specifies which Fleetapi host the HTTP proxy will forward the requests to.
* `TESLA_VERBOSE` enables verbose logging. Supported by `tesla-control` and
`tesla-http-proxy`.

Expand Down Expand Up @@ -298,6 +299,20 @@ A command's flow through the system:

![](./doc/request_diagram.png)

The proxy server now supports specifying fleetapi base url. You can add a custom header `Fleetapi-Host` to the requests to indicate which Fleetapi host you would like the request to be forwarded to. The custom header `Fleetapi-Host` will override the FleetApi host configured by Environment Variable `TESLA_HTTP_PROXY_FLEETAPI_HOST`.

```bash
export TESLA_AUTH_TOKEN=<access-token>
export VIN=<vin>
export FLEETAPI_HOST='fleet-api.prd.na.vn.cloud.tesla.com'
curl --cacert cert.pem \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $TESLA_AUTH_TOKEN" \
--header "Fleetapi-Host: $FLEETAPI_HOST" \
--data '{}' \
"https://localhost:4443/api/1/vehicles/$VIN/command/flash_lights"
```

### REST API documentation

The HTTP proxy implements the [Tesla Fleet API vehicle command endpoints](https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-commands).
Expand Down
21 changes: 14 additions & 7 deletions cmd/tesla-http-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ const (
)

const (
EnvTlsCert = "TESLA_HTTP_PROXY_TLS_CERT"
EnvTlsKey = "TESLA_HTTP_PROXY_TLS_KEY"
EnvHost = "TESLA_HTTP_PROXY_HOST"
EnvPort = "TESLA_HTTP_PROXY_PORT"
EnvTimeout = "TESLA_HTTP_PROXY_TIMEOUT"
EnvVerbose = "TESLA_VERBOSE"
EnvTlsCert = "TESLA_HTTP_PROXY_TLS_CERT"
EnvTlsKey = "TESLA_HTTP_PROXY_TLS_KEY"
EnvHost = "TESLA_HTTP_PROXY_HOST"
EnvPort = "TESLA_HTTP_PROXY_PORT"
EnvTimeout = "TESLA_HTTP_PROXY_TIMEOUT"
EnvVerbose = "TESLA_VERBOSE"
EnvFleetapiHost = "TESLA_HTTP_PROXY_FLEETAPI_HOST"
)

const nonLocalhostWarning = `
Expand All @@ -42,6 +43,7 @@ type HttpProxyConfig struct {
host string
port int
timeout time.Duration
fleetApiHost string
}

var (
Expand All @@ -55,6 +57,7 @@ func init() {
flag.StringVar(&httpConfig.host, "host", "localhost", "Proxy server `hostname`")
flag.IntVar(&httpConfig.port, "port", defaultPort, "`Port` to listen on")
flag.DurationVar(&httpConfig.timeout, "timeout", proxy.DefaultTimeout, "Timeout interval when sending commands")
flag.StringVar(&httpConfig.fleetApiHost, "fleetapi-host", "", "Fleetapi host")
}

func Usage() {
Expand Down Expand Up @@ -113,7 +116,7 @@ func main() {
}

log.Debug("Creating proxy")
p, err := proxy.New(context.Background(), skey, cacheSize)
p, err := proxy.New(context.Background(), skey, cacheSize, httpConfig.fleetApiHost)
if err != nil {
return
}
Expand Down Expand Up @@ -153,6 +156,10 @@ func readFromEnvironment() error {
}
}

if httpConfig.fleetApiHost == "" {
httpConfig.fleetApiHost = os.Getenv(EnvFleetapiHost)
}

var err error
if httpConfig.port == defaultPort {
if port, ok := os.LookupEnv(EnvPort); ok {
Expand Down
5 changes: 4 additions & 1 deletion cmd/tesla-http-proxy/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func TestParseConfig(t *testing.T) {
os.Setenv(EnvPort, "8443")
os.Setenv(EnvVerbose, "true")
os.Setenv(EnvTimeout, "30s")
os.Setenv(EnvFleetapiHost, "fleet-api.prd.na.vn.cloud.tesla.com")

err := readFromEnvironment()
if err != nil {
Expand All @@ -68,10 +69,11 @@ func TestParseConfig(t *testing.T) {
assertEquals(t, 8443, httpConfig.port, "port")
assertEquals(t, 30*time.Second, httpConfig.timeout, "timeout")
assertEquals(t, true, httpConfig.verbose, "verbose")
assertEquals(t, "fleet-api.prd.na.vn.cloud.tesla.com", httpConfig.fleetApiHost, "fleetApiHost")
})

t.Run("flags override environment variables", func(t *testing.T) {
os.Args = []string{"cmd", "-cert", "/flag/cert.pem", "-tls-key", "/flag/key.pem", "-host", "flaghost", "-port", "9090", "-timeout", "60s"}
os.Args = []string{"cmd", "-cert", "/flag/cert.pem", "-tls-key", "/flag/key.pem", "-host", "flaghost", "-port", "9090", "-timeout", "60s", "-fleetapi-host", "fleet-api.prd.na.vn.cloud.tesla.com"}

flag.Parse()
err := readFromEnvironment()
Expand All @@ -84,5 +86,6 @@ func TestParseConfig(t *testing.T) {
assertEquals(t, "flaghost", httpConfig.host, "host")
assertEquals(t, 9090, httpConfig.port, "port")
assertEquals(t, 60*time.Second, httpConfig.timeout, "timeout")
assertEquals(t, "fleet-api.prd.na.vn.cloud.tesla.com", httpConfig.fleetApiHost, "fleetApiHost")
})
}
2 changes: 1 addition & 1 deletion examples/unlock/unlock.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func main() {

// This example program sends commands over the Internet, which requires a Tesla account login
// token. The protocol can also work over BLE; see other programs in the example directory.
acct, err := account.New(string(oauthToken), userAgent)
acct, err := account.New(string(oauthToken), userAgent, "")
if err != nil {
logger.Printf("Authentication error: %s", err)
return
Expand Down
13 changes: 9 additions & 4 deletions pkg/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func (p *oauthPayload) domain() string {

// New returns an [Account] that can be used to fetch a [vehicle.Vehicle].
// Optional userAgent can be passed in - otherwise it will be generated from code
func New(oauthToken, userAgent string) (*Account, error) {
func New(oauthToken, userAgent, fleetApiHost string) (*Account, error) {
parts := strings.Split(oauthToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("client provided malformed OAuth token")
Expand All @@ -128,9 +128,14 @@ func New(oauthToken, userAgent string) (*Account, error) {
return nil, fmt.Errorf("client provided malformed OAuth token: %s", err)
}

domain := payload.domain()
if domain == "" {
return nil, fmt.Errorf("client provided OAuth token with invalid audiences")
domain := ""
if fleetApiHost != "" {
domain, _ = strings.CutPrefix(fleetApiHost, "https://")
} else {
domain = payload.domain()
if domain == "" {
return nil, fmt.Errorf("client provided OAuth token with invalid audiences")
}
}
return &Account{
UserAgent: buildUserAgent(userAgent),
Expand Down
27 changes: 24 additions & 3 deletions pkg/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestNewAccount(t *testing.T) {

for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
acct, err := New(test.jwt, "")
acct, err := New(test.jwt, "", "")
if (err != nil) != test.shouldError {
t.Errorf("Unexpected result: err = %v, shouldError = %v", err, test.shouldError)
}
Expand All @@ -50,7 +50,7 @@ func TestDomainDefault(t *testing.T) {
Audiences: []string{"https://auth.tesla.com/nts"},
}

acct, err := New(makeTestJWT(payload), "")
acct, err := New(makeTestJWT(payload), "", "")
if err != nil {
t.Fatalf("Returned error on valid JWT: %s", err)
}
Expand All @@ -70,7 +70,7 @@ func TestDomainExtraction(t *testing.T) {
OUCode: "EU",
}

acct, err := New(makeTestJWT(payload), "")
acct, err := New(makeTestJWT(payload), "", "")
if err != nil {
t.Fatalf("Returned error on valid JWT: %s", err)
}
Expand All @@ -80,6 +80,27 @@ func TestDomainExtraction(t *testing.T) {
}
}

// TestDomainExtraction tests the extraction of the correct domain based on OUCode.
func TestTargetHost(t *testing.T) {
payload := &oauthPayload{
Audiences: []string{
"https://auth.tesla.com/nts",
"https://fleet-api.prd.na.vn.cloud.tesla.com",
"https://fleet-api.prd.eu.vn.cloud.tesla.com",
},
OUCode: "EU",
}

acct, err := New(makeTestJWT(payload), "", "https://fleet-api.prd.na.vn.cloud.tesla.com")
if err != nil {
t.Fatalf("Returned error on valid JWT: %s", err)
}
expectedHost := "fleet-api.prd.na.vn.cloud.tesla.com"
if acct == nil || acct.Host != expectedHost {
t.Errorf("acct = %+v, expected Host = %s", acct, expectedHost)
}
}

// makeTestJWT creates a JWT string with the given payload.
func makeTestJWT(payload *oauthPayload) string {
jwtBody, _ := json.Marshal(payload)
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ func (c *Config) Account() (*account.Account, error) {
if err != nil {
return nil, err
}
return account.New(token, "")
return account.New(token, "", "")
}

// SavePrivateKey writes skey to the system keyring or file, depending on what options are
Expand Down
29 changes: 18 additions & 11 deletions pkg/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,28 @@ const (
proxyProtocolVersion = "tesla-http-proxy/1.1.0"
)

func getAccount(req *http.Request) (*account.Account, error) {
func getAccount(req *http.Request, fleetApiHost string) (*account.Account, error) {
token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ")
if !ok {
return nil, fmt.Errorf("client did not provide an OAuth token")
}
return account.New(token, proxyProtocolVersion)
fleetapiHostFromReq := req.Header.Get("Fleetapi-Host")
if fleetapiHostFromReq != "" {
fleetApiHost = fleetapiHostFromReq
}

return account.New(token, proxyProtocolVersion, fleetApiHost)
}

// Proxy exposes an HTTP API for sending vehicle commands.
type Proxy struct {
Timeout time.Duration

commandKey protocol.ECDHPrivateKey
sessions *cache.SessionCache
vinLock sync.Map
unsupported sync.Map
commandKey protocol.ECDHPrivateKey
sessions *cache.SessionCache
vinLock sync.Map
unsupported sync.Map
fleetApiHost string
}

func (p *Proxy) markUnsupportedVIN(vin string) {
Expand Down Expand Up @@ -92,11 +98,12 @@ func (p *Proxy) unlockVIN(vin string) {
//
// Vehicles must have the public part of skey enrolled on their keychains. (This is a
// command-authentication key, not a TLS key.)
func New(ctx context.Context, skey protocol.ECDHPrivateKey, cacheSize int) (*Proxy, error) {
func New(ctx context.Context, skey protocol.ECDHPrivateKey, cacheSize int, fleetApiHost string) (*Proxy, error) {
return &Proxy{
Timeout: DefaultTimeout,
commandKey: skey,
sessions: cache.New(cacheSize),
Timeout: DefaultTimeout,
commandKey: skey,
sessions: cache.New(cacheSize),
fleetApiHost: fleetApiHost,
}, nil
}

Expand Down Expand Up @@ -216,7 +223,7 @@ func (p *Proxy) forwardRequest(host string, w http.ResponseWriter, req *http.Req
func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
log.Info("Received %s request for %s", req.Method, req.URL.Path)

acct, err := getAccount(req)
acct, err := getAccount(req, p.fleetApiHost)
if err != nil {
writeJSONError(w, http.StatusForbidden, err)
return
Expand Down
103 changes: 103 additions & 0 deletions pkg/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package proxy

import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"testing"
)

// b64Encode encodes a string to base64 without padding.
func b64Encode(payload string) string {
return base64.RawStdEncoding.EncodeToString([]byte(payload))
}

type oauthPayload struct {
Audiences []string `json:"aud"`
OUCode string `json:"ou_code"`
}

// makeTestJWT creates a JWT string with the given payload.
func makeTestJWT(payload *oauthPayload) string {
jwtBody, _ := json.Marshal(payload)
return fmt.Sprintf("x.%s.y", b64Encode(string(jwtBody)))
}

func TestGetAccount(t *testing.T) {
payload := &oauthPayload{
Audiences: []string{
"https://auth.tesla.com/nts",
"https://fleet-api.prd.na.vn.cloud.tesla.com",
"https://fleet-api.prd.eu.vn.cloud.tesla.com",
},
OUCode: "EU",
}

Header := map[string][]string{
"Authorization": {"Bearer " + makeTestJWT(payload)},
}
req := &http.Request{Header: Header}

acct, _ := getAccount(req, "")
expectedHost := "fleet-api.prd.eu.vn.cloud.tesla.com"

if acct == nil || acct.Host != expectedHost {
t.Errorf("acct = %+v, expected Host = %s", acct, expectedHost)
}
}

var (
payload = &oauthPayload{
Audiences: []string{
"https://auth.tesla.com/nts",
"https://fleet-api.prd.na.vn.cloud.tesla.com",
"https://fleet-api.prd.eu.vn.cloud.tesla.com",
},
OUCode: "EU",
}
)

func TestGetAccountWithConfigOverride(t *testing.T) {
Header := map[string][]string{
"Authorization": {"Bearer " + makeTestJWT(payload)},
}
req := &http.Request{Header: Header}

acct, _ := getAccount(req, "fleet-api.prd.na.vn.cloud.tesla.com")
expectedHost := "fleet-api.prd.na.vn.cloud.tesla.com"

if acct == nil || acct.Host != expectedHost {
t.Errorf("acct = %+v, expected Host = %s", acct, expectedHost)
}
}

func TestGetAccountWithHeaderOverride(t *testing.T) {
Header := map[string][]string{
"Authorization": {"Bearer " + makeTestJWT(payload)},
"Fleetapi-Host": {"fleet-api.prd.na.vn.cloud.tesla.com"},
}
req := &http.Request{Header: Header}

acct, _ := getAccount(req, "")
expectedHost := "fleet-api.prd.na.vn.cloud.tesla.com"

if acct == nil || acct.Host != expectedHost {
t.Errorf("acct = %+v, expected Host = %s", acct, expectedHost)
}
}

func TestGetAccountWithConfigAndHeaderOverride(t *testing.T) {
Header := map[string][]string{
"Authorization": {"Bearer " + makeTestJWT(payload)},
"Fleetapi-Host": {"fleet-api.prd.na.vn.cloud.tesla.com"},
}
req := &http.Request{Header: Header}

acct, _ := getAccount(req, "fleet-api.prd.na.vn.cloud.tesla.com")
expectedHost := "fleet-api.prd.na.vn.cloud.tesla.com"

if acct == nil || acct.Host != expectedHost {
t.Errorf("acct = %+v, expected Host = %s", acct, expectedHost)
}
}

0 comments on commit b35505a

Please sign in to comment.