From b35505a201d64624e1a122d7c2955e98fa204972 Mon Sep 17 00:00:00 2001 From: Nian Tang Date: Mon, 23 Sep 2024 10:43:49 +0800 Subject: [PATCH] Add custom header to specify fleetapi base url --- README.md | 15 +++++ cmd/tesla-http-proxy/main.go | 21 ++++-- cmd/tesla-http-proxy/main_test.go | 5 +- examples/unlock/unlock.go | 2 +- pkg/account/account.go | 13 ++-- pkg/account/account_test.go | 27 +++++++- pkg/cli/config.go | 2 +- pkg/proxy/proxy.go | 29 +++++---- pkg/proxy/proxy_test.go | 103 ++++++++++++++++++++++++++++++ 9 files changed, 189 insertions(+), 28 deletions(-) create mode 100644 pkg/proxy/proxy_test.go diff --git a/README.md b/README.md index 019738ef..7ef3032f 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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= +export 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). diff --git a/cmd/tesla-http-proxy/main.go b/cmd/tesla-http-proxy/main.go index a0b69bd2..49458330 100644 --- a/cmd/tesla-http-proxy/main.go +++ b/cmd/tesla-http-proxy/main.go @@ -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 = ` @@ -42,6 +43,7 @@ type HttpProxyConfig struct { host string port int timeout time.Duration + fleetApiHost string } var ( @@ -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() { @@ -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 } @@ -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 { diff --git a/cmd/tesla-http-proxy/main_test.go b/cmd/tesla-http-proxy/main_test.go index 0d0ed736..000374f9 100644 --- a/cmd/tesla-http-proxy/main_test.go +++ b/cmd/tesla-http-proxy/main_test.go @@ -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 { @@ -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() @@ -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") }) } diff --git a/examples/unlock/unlock.go b/examples/unlock/unlock.go index 13656da7..e47bc441 100644 --- a/examples/unlock/unlock.go +++ b/examples/unlock/unlock.go @@ -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 diff --git a/pkg/account/account.go b/pkg/account/account.go index e038a49f..37a6cd45 100644 --- a/pkg/account/account.go +++ b/pkg/account/account.go @@ -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") @@ -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), diff --git a/pkg/account/account_test.go b/pkg/account/account_test.go index 9a99d4ad..f441651e 100644 --- a/pkg/account/account_test.go +++ b/pkg/account/account_test.go @@ -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) } @@ -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) } @@ -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) } @@ -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) diff --git a/pkg/cli/config.go b/pkg/cli/config.go index 0a64ea0a..b4741338 100644 --- a/pkg/cli/config.go +++ b/pkg/cli/config.go @@ -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 diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index bae76c7b..dd967c3d 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -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) { @@ -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 } @@ -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 diff --git a/pkg/proxy/proxy_test.go b/pkg/proxy/proxy_test.go new file mode 100644 index 00000000..9473b4ee --- /dev/null +++ b/pkg/proxy/proxy_test.go @@ -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) + } +}