diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2d4fec61 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +*.pem +*.key +*.jwt +*.jws +config +.git +.github diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..4d407ec2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish Release + +on: + push: + branches: + - main + paths: + - 'pkg/account/version.txt' + +jobs: + release: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - id: get-version + name: Get Version + run: | + echo "version=$(cat pkg/account/version.txt | tr -d '\n')" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.get-version.outputs.version }} + name: v${{ steps.get-version.outputs.version }} + generate_release_notes: true + make_latest: true + + - name: Build and push + uses: docker/build-push-action@v4 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: tesla/vehicle-command:latest,tesla/vehicle-command:${{ steps.get-version.outputs.version }} diff --git a/.gitignore b/.gitignore index 66f77f96..00b5ed29 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ examples/unlock/unlock examples/ble/ble *.DS_Store *.key +config diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..50e4e828 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.20 AS build + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN mkdir build +RUN go build -o ./build ./... + +FROM gcr.io/distroless/base-debian12 AS runtime + +COPY --from=build /app/build /usr/local/bin + +ENTRYPOINT ["tesla-http-proxy"] diff --git a/README.md b/README.md index 6fdf84a2..1057b0da 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ curl --cacert cert.pem \ ## Installation and configuration +### Installing locally + Requirements: * You've [installed Golang](https://go.dev/doc/install). The package was @@ -80,10 +82,30 @@ The final command installs the following utilities: utility does not fetch tokens. Read the [Fleet API documentation](https://developer.tesla.com/docs/fleet-api/authentication/third-party-tokens) for information on fetching OAuth tokens. -Configure environment variables (optional): +### Installing with Docker + +A Docker image is available for running these tools. The image defaults to +running the HTTP proxy, but the `--entrypoint` flag changes the tool to be used. -For convenience, you can define the following environment variables to be used -in lieu of command-line flags when using the above applications: +Run the image from Docker hub: + +```bash +docker pull tesla/vehicle-command:latest +docker run tesla/vehicle-command:latest --help + +# running a different tool +docker run --entrypoint tesla-control tesla/vehicle-command:latest --help +``` + +An example [docker-compose.yml](./docker-compose.yml) file is also provided. + +```bash +docker compose up +``` + +### Configuration + +The following environment variables can used in lieu of command-line flags. * `TESLA_KEY_NAME` used to derive the entry name for your command authentication private key in your system keyring. @@ -102,6 +124,14 @@ in lieu of command-line flags when using the above applications: reduces both latency and the number of Fleet API calls a client makes when reconnecting to a vehicle after restarting. This is particularly helpful when using `tesla-control`, which restarts on each invocation. + * `TESLA_HTTP_PROXY_TLS_CERT` specifies a TLS certificate file for the HTTP proxy. + * `TESLA_HTTP_PROXY_TLS_KEY` specifies a TLS key file for the HTTP proxy. + * `TESLA_HTTP_PROXY_HOST` specifies the host for the HTTP proxy. + * `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_VERBOSE` enables verbose logging. Supported by `tesla-control` and + `tesla-http-proxy`. For example: @@ -191,23 +221,35 @@ purposes, you can create a self-signed localhost server certificate using OpenSSL: ``` +mkdir config openssl req -x509 -nodes -newkey ec \ -pkeyopt ec_paramgen_curve:secp521r1 \ -pkeyopt ec_param_enc:named_curve \ -subj '/CN=localhost' \ - -keyout key.pem -out cert.pem -sha256 -days 3650 \ + -keyout config/tls-key.pem -out config/tls-cert.pem -sha256 -days 3650 \ -addext "extendedKeyUsage = serverAuth" \ -addext "keyUsage = digitalSignature, keyCertSign, keyAgreement" ``` -This command creates an unencrypted private key, `key.pem`. +This command creates an unencrypted private key, `config/tls-key.pem`. ### Running the proxy server -You can start the proxy server using the following command: +The proxy server can be run using the following command: + +```bash +tesla-http-proxy -tls-key config/tls-key.pem -cert config/tls-cert.pem -key-file config/fleet-key.pem -port 4443 +``` + +It can also be run using Docker: ```bash -tesla-http-proxy -tls-key key.pem -cert cert.pem -port 4443 +# option 1: using docker run +docker pull tesla/vehicle-command:latest +docker run -v ./config:/config -p 127.0.0.1:4433:4433 tesla/vehicle-command:latest -tls-key /config/tls-key.pem -cert /config/tls-cert.pem -key-file /config/fleet-key.pem -host 0.0.0.0 -port 4443 + +# option 2: using docker compose +docker compose up ``` *Note:* In production, you'll likely want to omit the `-port 4443` and listen on diff --git a/cmd/tesla-control/main.go b/cmd/tesla-control/main.go index cb670ac9..6b26a37e 100644 --- a/cmd/tesla-control/main.go +++ b/cmd/tesla-control/main.go @@ -121,6 +121,11 @@ func main() { config.RegisterCommandLineFlags() flag.Parse() + if !debug { + if debugEnv, ok := os.LookupEnv("TESLA_VERBOSE"); ok { + debug = debugEnv != "false" && debugEnv != "0" + } + } if debug { log.SetLevel(log.LevelDebug) } diff --git a/cmd/tesla-http-proxy/main.go b/cmd/tesla-http-proxy/main.go index 9fbac93f..a0b69bd2 100644 --- a/cmd/tesla-http-proxy/main.go +++ b/cmd/tesla-http-proxy/main.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "strconv" "time" "github.com/teslamotors/vehicle-command/internal/log" @@ -20,33 +21,54 @@ const ( defaultPort = 443 ) -const warning = ` +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" +) + +const nonLocalhostWarning = ` Do not listen on a network interface without adding client authentication. Unauthorized clients may be used to create excessive traffic from your IP address to Tesla's servers, which Tesla may respond to by rate limiting or blocking your connections.` +type HttpProxyConfig struct { + keyFilename string + certFilename string + verbose bool + host string + port int + timeout time.Duration +} + +var ( + httpConfig = &HttpProxyConfig{} +) + +func init() { + flag.StringVar(&httpConfig.certFilename, "cert", "", "TLS certificate chain `file` with concatenated server, intermediate CA, and root CA certificates") + flag.StringVar(&httpConfig.keyFilename, "tls-key", "", "Server TLS private key `file`") + flag.BoolVar(&httpConfig.verbose, "verbose", false, "Enable verbose logging") + 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") +} + func Usage() { out := flag.CommandLine.Output() fmt.Fprintf(out, "Usage: %s [OPTION...]\n", os.Args[0]) fmt.Fprintf(out, "\nA server that exposes a REST API for sending commands to Tesla vehicles") fmt.Fprintln(out, "") - fmt.Fprintln(out, warning) + fmt.Fprintln(out, nonLocalhostWarning) fmt.Fprintln(out, "") fmt.Fprintln(out, "Options:") flag.PrintDefaults() } func main() { - // Command-line options - var ( - keyFilename string - certFilename string - verbose bool - host string - port int - timeout time.Duration - ) - config, err := cli.NewConfig(cli.FlagPrivateKey) if err != nil { @@ -61,23 +83,18 @@ func main() { } }() - flag.StringVar(&certFilename, "cert", "", "TLS certificate chain `file` with concatenated server, intermediate CA, and root CA certificates") - flag.StringVar(&keyFilename, "tls-key", "", "Server TLS private key `file`") - flag.BoolVar(&verbose, "verbose", false, "Enable verbose logging") - flag.StringVar(&host, "host", "localhost", "Proxy server `hostname`") - flag.IntVar(&port, "port", defaultPort, "`Port` to listen on") - flag.DurationVar(&timeout, "timeout", proxy.DefaultTimeout, "Timeout interval when sending commands") flag.Usage = Usage config.RegisterCommandLineFlags() flag.Parse() + readFromEnvironment() config.ReadFromEnvironment() - if verbose { + if httpConfig.verbose { log.SetLevel(log.LevelDebug) } - if host != "localhost" { - fmt.Fprintln(os.Stderr, warning) + if httpConfig.host != "localhost" { + fmt.Fprintln(os.Stderr, nonLocalhostWarning) } var skey protocol.ECDHPrivateKey @@ -86,7 +103,7 @@ func main() { return } - if tlsPublicKey, err := protocol.LoadPublicKey(keyFilename); err == nil { + if tlsPublicKey, err := protocol.LoadPublicKey(httpConfig.keyFilename); err == nil { if bytes.Equal(tlsPublicKey.Bytes(), skey.PublicBytes()) { fmt.Fprintln(os.Stderr, "It is unsafe to use the same private key for TLS and command authentication.") fmt.Fprintln(os.Stderr, "") @@ -100,8 +117,8 @@ func main() { if err != nil { return } - p.Timeout = timeout - addr := fmt.Sprintf("%s:%d", host, port) + p.Timeout = httpConfig.timeout + addr := fmt.Sprintf("%s:%d", httpConfig.host, httpConfig.port) log.Info("Listening on %s", addr) // To add more application logic requests, such as alternative client authentication, create @@ -109,5 +126,51 @@ func main() { // method of your implementation can perform your business logic and then, if the request is // authorized, invoke p.ServeHTTP. Finally, replace p in the below ListenAndServeTLS call with // an object of your newly created type. - log.Error("Server stopped: %s", http.ListenAndServeTLS(addr, certFilename, keyFilename, p)) + log.Error("Server stopped: %s", http.ListenAndServeTLS(addr, httpConfig.certFilename, httpConfig.keyFilename, p)) +} + +// readConfig applies configuration from environment variables. +// Values are not overwritten. +func readFromEnvironment() error { + if httpConfig.certFilename == "" { + httpConfig.certFilename = os.Getenv(EnvTlsCert) + } + + if httpConfig.keyFilename == "" { + httpConfig.keyFilename = os.Getenv(EnvTlsKey) + } + + if httpConfig.host == "localhost" { + host, ok := os.LookupEnv(EnvHost) + if ok { + httpConfig.host = host + } + } + + if !httpConfig.verbose { + if verbose, ok := os.LookupEnv(EnvVerbose); ok { + httpConfig.verbose = verbose != "false" && verbose != "0" + } + } + + var err error + if httpConfig.port == defaultPort { + if port, ok := os.LookupEnv(EnvPort); ok { + httpConfig.port, err = strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid port: %s", port) + } + } + } + + if httpConfig.timeout == proxy.DefaultTimeout { + if timeoutEnv, ok := os.LookupEnv(EnvTimeout); ok { + httpConfig.timeout, err = time.ParseDuration(timeoutEnv) + if err != nil { + return fmt.Errorf("invalid timeout: %s", timeoutEnv) + } + } + } + + return nil } diff --git a/cmd/tesla-http-proxy/main_test.go b/cmd/tesla-http-proxy/main_test.go new file mode 100644 index 00000000..0d0ed736 --- /dev/null +++ b/cmd/tesla-http-proxy/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "flag" + "os" + "testing" + "time" + + "github.com/teslamotors/vehicle-command/pkg/proxy" +) + +// assertEquals should be replaced with a real assertion library +func assertEquals(t *testing.T, expected, actual interface{}, message string) { + t.Helper() + if expected != actual { + t.Errorf("%s: expected %v, got %v", message, expected, actual) + } +} + +func TestParseConfig(t *testing.T) { + origCert := os.Getenv(EnvTlsCert) + origKey := os.Getenv(EnvTlsKey) + origHost := os.Getenv(EnvHost) + origPort := os.Getenv(EnvPort) + origVerbose := os.Getenv(EnvVerbose) + origTimeout := os.Getenv(EnvTimeout) + origArgs := os.Args + os.Args = []string{"cmd"} + + defer func() { + os.Setenv(EnvTlsCert, origCert) + os.Setenv(EnvTlsKey, origKey) + os.Setenv(EnvHost, origHost) + os.Setenv(EnvPort, origPort) + os.Setenv(EnvVerbose, origVerbose) + os.Setenv(EnvTimeout, origTimeout) + os.Args = origArgs + }() + + t.Run("default values", func(t *testing.T) { + err := readFromEnvironment() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + assertEquals(t, "localhost", httpConfig.host, "host") + assertEquals(t, defaultPort, httpConfig.port, "port") + assertEquals(t, proxy.DefaultTimeout, httpConfig.timeout, "timeout") + assertEquals(t, "", httpConfig.certFilename, "certFilename") + assertEquals(t, "", httpConfig.keyFilename, "keyFilename") + assertEquals(t, false, httpConfig.verbose, "verbose") + }) + + t.Run("environment variables", func(t *testing.T) { + os.Setenv(EnvTlsCert, "/env/cert.pem") + os.Setenv(EnvTlsKey, "/env/key.pem") + os.Setenv(EnvHost, "envhost") + os.Setenv(EnvPort, "8443") + os.Setenv(EnvVerbose, "true") + os.Setenv(EnvTimeout, "30s") + + err := readFromEnvironment() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + assertEquals(t, "/env/cert.pem", httpConfig.certFilename, "certFilename") + assertEquals(t, "/env/key.pem", httpConfig.keyFilename, "keyFilename") + assertEquals(t, "envhost", httpConfig.host, "host") + assertEquals(t, 8443, httpConfig.port, "port") + assertEquals(t, 30*time.Second, httpConfig.timeout, "timeout") + assertEquals(t, true, httpConfig.verbose, "verbose") + }) + + 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"} + + flag.Parse() + err := readFromEnvironment() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + assertEquals(t, "/flag/cert.pem", httpConfig.certFilename, "certFilename") + assertEquals(t, "/flag/key.pem", httpConfig.keyFilename, "keyFilename") + assertEquals(t, "flaghost", httpConfig.host, "host") + assertEquals(t, 9090, httpConfig.port, "port") + assertEquals(t, 60*time.Second, httpConfig.timeout, "timeout") + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8d56a043 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + tesla_http_proxy: + image: tesla/vehicle-command:latest + ports: + - "4443:4443" + environment: + - TESLA_HTTP_PROXY_TLS_CERT=/config/tls-cert.pem + - TESLA_HTTP_PROXY_TLS_KEY=/config/tls-key.pem + - TESLA_HTTP_PROXY_HOST=0.0.0.0 + - TESLA_HTTP_PROXY_PORT=4443 + - TESLA_HTTP_PROXY_TIMEOUT=10s + - TESLA_KEY_FILE=/config/fleet-key.pem + - TESLA_VERBOSE=true + volumes: + - ./config:/config