Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom header to specify fleetapi base url #314

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
94 changes: 94 additions & 0 deletions pkg/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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)))
}

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 TestGetAccount(t *testing.T) {
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)
}
}

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)
}
}
Loading