Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jobs:
done
cat status.json
python3 -c "import json; s = json.load(open('status.json')); assert s['allow_live'] is False and s['running'] is False, s"
# The image's health check is defined and passes
docker inspect --format '{{json .Config.Healthcheck}}' tesla_mtc:ci | grep -q healthcheck.py
docker exec tesla_mtc_ci python web/healthcheck.py
- name: Container log
if: always()
run: docker logs tesla_mtc_ci || true
31 changes: 27 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,39 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]
## [1.2.1] - Unreleased

### Security
- MTC's certificate is verified again. Verification used to be switched off because MTC's
server presents its `*.mtc.nl` certificate under another host name. The certificate chain is
now checked against the trusted certificate authorities and the certificate must be valid
for `mtc.nl`; if MTC installs a certificate for its real host name, the normal check is used.
Your MTC password and IBAN are no longer sent over an unverified connection.
- The MTC session token (CSRF token) and session cookie values are no longer written to the log.

### Fixed
- Every call to Tesla and MTC has a timeout (10 s to connect, 60 s to answer), so a server that
stops answering ends the run with an error instead of leaving it running forever. When MTC does
not answer after a claim was sent, that claim is never sent again in the same run (it may have
arrived); the session is reported as failed, and the next run's duplicate check finds it if it did.
- The web interface stops a run that has not finished after `RUNNER_RUN_TIMEOUT` seconds
(default 900) and shows it as failed, so the page and Home Assistant never stay on *Running*.

### Added
- Docker health check on the web interface (*healthy*/*unhealthy* in Docker and Synology
Container Manager) and image labels with source and license.
- Automatic checks on every push and pull request: lint, unit tests with fictional data
(duplicate check, safety limits, the claim sent to MTC, Tesla session handling, and the
web interface's log reading and safety checks) and a Docker build with a smoke test.
Run them locally with `pip install -r requirements-dev.txt`, `ruff check .` and `pytest`.
(duplicate check, safety limits, the claim sent to MTC, Tesla session handling, certificate
checks and timeouts, and the web interface's log reading and safety checks) and a Docker build
with a smoke test. Run them locally with `pip install -r requirements-dev.txt`, `ruff check .`
and `pytest`.
- Issue templates that remind you to remove tokens, passwords, IBAN and VIN from logs.
- Monthly grouped dependency updates (Dependabot) and status badges in the README.

### Changed
- `tqdm` is pinned (4.70.1) like the other dependencies, so Dependabot can keep it up to date.
- The startup warning about switched-off certificate verification is gone.

## [1.2.0] - 2026-10-14

### Added
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
FROM python:3.12-slim

LABEL org.opencontainers.image.title="tesla_mtc" \
org.opencontainers.image.description="Submit Tesla Supercharger sessions to MultiTankCard (MTC) for reimbursement" \
org.opencontainers.image.source="https://github.com/WesSec/tesla_mtc" \
org.opencontainers.image.licenses="MIT"

ENV PYTHONUNBUFFERED=1 \
TZ=Europe/Amsterdam

Expand All @@ -18,5 +23,8 @@ RUN useradd --create-home --uid 1000 tesla_mtc
USER tesla_mtc

EXPOSE 8080
# Shown as healthy/unhealthy in Docker and Synology Container Manager (checks the web interface)
HEALTHCHECK --interval=60s --timeout=10s --start-period=20s --retries=3 \
CMD ["python", "web/healthcheck.py"]
# Web interface by default; run once without it with: docker compose run --rm tesla_mtc python main.py
CMD ["python", "-u", "web/runner.py"]
80 changes: 59 additions & 21 deletions MTC.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from dotenv import load_dotenv
from datetime import datetime, timedelta, timezone
import sys # Required for logging to stdout in test_mtc_client
from util import Colors
from requests.adapters import HTTPAdapter
from util import Colors, HTTP_TIMEOUT

# For debugging purposes, can be removed or commented out in production
# from requests_toolbelt.utils import dump
Expand Down Expand Up @@ -67,6 +68,30 @@

DEFAULT_MAX_RECORDS: int = 400

# MTC's server (mtc.outsystemsenterprise.com) presents MTC's own *.mtc.nl certificate, which
# fails the normal host name check. Instead of switching verification off, the certificate
# chain is still verified against the trusted CAs, and the certificate must be valid for this name.
MTC_CERTIFICATE_NAME: str = "mtc.nl"


class MTCAdapter(HTTPAdapter):
"""Transport for all MTC calls: verifies MTC's certificate for `certificate_name`
(None: the normal check against the host name) and applies HTTP_TIMEOUT by default."""

def __init__(self, certificate_name: Optional[str] = MTC_CERTIFICATE_NAME) -> None:
self.certificate_name = certificate_name
super().__init__()

def init_poolmanager(self, *args: Any, **kwargs: Any) -> None:
if self.certificate_name:
kwargs["assert_hostname"] = self.certificate_name
super().init_poolmanager(*args, **kwargs)

def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response:
if kwargs.get("timeout") is None:
kwargs["timeout"] = HTTP_TIMEOUT
return super().send(request, **kwargs)


def _oldest_transaction_date(transactions: list) -> Optional[datetime]:
"""Returns the oldest `DateTransaction` in the list, or None if none can be read."""
Expand Down Expand Up @@ -181,7 +206,7 @@ def _initialize_session_headers(self) -> requests.Session:
requests.Session: The configured session object.
"""
session = requests.Session()
session.verify = False # Disable SSL verification for MTC's known issues
session.mount(f"{self.base_url}/", MTCAdapter()) # see MTC_CERTIFICATE_NAME
session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
Expand Down Expand Up @@ -235,7 +260,6 @@ def _get_api_version(self, endpoint_key: str) -> str:
"Sec-Fetch-Mode": "no-cors",
"Referer": f"{self.base_url}/MultiTankcard/"
},
verify=False # It's good practice to verify SSL. Set to False only if server has known cert issues.
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
Expand All @@ -255,6 +279,21 @@ def _get_api_version(self, endpoint_key: str) -> str:
logger.debug(f"Fetched and cached API version for '{endpoint_key}': {api_version}")
return api_version

def _first_request(self, url: str) -> requests.Response:
"""
The first request of a session, which carries no credentials. If MTC's certificate is
not valid for MTC_CERTIFICATE_NAME (for example because MTC installed a certificate for
its real host name), the rest of the session uses the normal certificate check instead.
The certificate is always verified: when neither check passes, the run stops.
"""
try:
return self.session.get(url)
except requests.exceptions.SSLError as e:
logger.info(f"MTC's certificate is not valid for {MTC_CERTIFICATE_NAME} ({e}); "
f"retrying with the normal certificate check.")
self.session.mount(f"{self.base_url}/", MTCAdapter(certificate_name=None))
return self.session.get(url)

def _perform_pre_login_calls(self) -> str:
"""
Performs the initial sequence of HTTP calls required before attempting the main login.
Expand All @@ -275,16 +314,7 @@ def _perform_pre_login_calls(self) -> str:
try:
current_epoch_ms = int(time.time() * 1000)
module_version_url = f"{self.base_url}/MultiTankcard/moduleservices/moduleversioninfo?{current_epoch_ms}"

# Note: verify=False was in original user code due to "Outsystem/MTC Sucks".
# This implies potential SSL certificate issues on the MTC server.
# For security, verify=True is preferred. If False is necessary, it's a known risk.
# Based on later success, verify=True might work for this call or it was changed to True.
# Let's assume verify=True is the goal, but acknowledge the original note.
# The provided code had verify=False for this specific call, then verify=True later.
# For consistency and security, let's try True here as well, if it breaks, it indicates server issue.
# Reverting to verify=False for this specific call as per context of it working before.
response = self.session.get(module_version_url, verify=False)
response = self._first_request(module_version_url)
response.raise_for_status()

self.visit_id = self.session.cookies.get("osVisit", "")
Expand All @@ -293,7 +323,7 @@ def _perform_pre_login_calls(self) -> str:
logger.error("Failed to obtain 'osVisit' or 'osVisitor' cookies during pre-login.")
raise ValueError("'osVisit'/'osVisitor' cookies are missing after moduleversioninfo call.")

logger.debug(f"Pre-login: osVisit='{self.visit_id}', osVisitor='{self.visitor_id}'")
logger.debug("Pre-login: osVisit and osVisitor cookies received.")

module_version_data = response.json()
module_version = module_version_data.get("versionToken")
Expand All @@ -317,7 +347,7 @@ def _perform_pre_login_calls(self) -> str:
)
app_store_response.raise_for_status()
logger.debug("Pre-login: ActionGetAppStoreUrls call successful.")
logger.debug(f"Cookies after GetAppStoreUrls: {self.session.cookies.get_dict()}")
logger.debug(f"Cookies after GetAppStoreUrls: {sorted(self.session.cookies.keys())}")
return module_version

except requests.exceptions.RequestException as e:
Expand Down Expand Up @@ -388,7 +418,7 @@ def login(self) -> bool:
self.csrf_token = unquote(encoded_csrf_value)

self.session.headers.update({"X-CSRFToken": self.csrf_token})
logger.info(f"New dynamic CSRF token configured for session: {self.csrf_token}")
logger.debug("New dynamic CSRF token configured for session.")

return True

Expand Down Expand Up @@ -587,11 +617,19 @@ def submit_reimbursement(self, claim_data: Dict[str, Any], max_attempts: int = 3

logger.info(f"Submitting claim with transaction date {date_transaction_for_api}")
logger.debug(f"Claim submission payload snippet: viewName='{claim_payload['viewName']}', DateTransaction='{date_transaction_for_api}'")
response = self.session.post(
f"{self.base_url}/MultiTankcard/screenservices/OtmTrx_Transactions/Claim/ClaimForm/ActionClaim_Create",
json=claim_payload,
headers={"Referer": f"{self.base_url}/MultiTankcard/NewClaim"} # Critical: From successful HAR
)
try:
response = self.session.post(
f"{self.base_url}/MultiTankcard/screenservices/OtmTrx_Transactions/Claim/ClaimForm/ActionClaim_Create",
json=claim_payload,
headers={"Referer": f"{self.base_url}/MultiTankcard/NewClaim"} # Critical: From successful HAR
)
except requests.exceptions.ReadTimeout:
# The claim was sent but MTC did not answer: it may have been submitted, and
# claims cannot be withdrawn, so never send it again in this run.
msg = (f"{Colors.FAIL}Claim submission failed: MTC did not answer in time after the "
f"claim for session {session_id} was sent, so it may have been submitted. "
f"Check MTC before running again.{Colors.ENDC}")
return False, msg
response.raise_for_status()
result = response.json()

Expand Down
3 changes: 3 additions & 0 deletions docs/web-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ Web interface settings, set under `environment:` in `docker-compose.yml`:
| `TZ` / `RUNNER_TZ` | `Europe/Amsterdam` | Time zone for times on the page. |
| `RUNNER_CMD` | `python -u main.py` | Command for a run. Only change this to wrap tesla_mtc in your own script. |
| `RUNNER_APP_DIR` | the repository folder | Folder `RUNNER_CMD` runs in. |
| `RUNNER_RUN_TIMEOUT` | `900` | Seconds after which a run that has not finished is stopped and shown as failed, so the page and Home Assistant never stay on *Running*. `0` disables the limit. |

The Docker image has a health check on the web interface, so Docker and Synology Container Manager show the container as *healthy* or *unhealthy*. It also works with a `RUNNER_TOKEN`.

tesla_mtc's own settings in `.env` keep working. The most relevant ones here:

Expand Down
18 changes: 7 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,11 @@
import logging
import time
from MTC import MTCClient
from util import Colors
from util import Colors, HTTP_TIMEOUT
from version import __version__
import urllib3
from urllib3.exceptions import InsecureRequestWarning
from tqdm import tqdm


# Suppress only the single warning from urllib3 about insecure requests
urllib3.disable_warnings(InsecureRequestWarning)


# MTC country mapping
MTC_COUNTRY_MAPPING = {
'DE': 'D', # Germany
Expand Down Expand Up @@ -167,7 +161,8 @@ def get_new_access_token(self):

response = requests.post(
f"{self.auth_url}/token",
data=data
data=data,
timeout=HTTP_TIMEOUT
)
response.raise_for_status()

Expand Down Expand Up @@ -247,7 +242,8 @@ def get_charging_history(self):
f"{self.base_url}/graphql",
headers=self.get_headers(),
params=params,
json=payload
json=payload,
timeout=HTTP_TIMEOUT
)

if response.status_code != 200:
Expand All @@ -272,7 +268,8 @@ def get_invoice_pdf(self, invoice_id):
response = requests.get(
f"{self.invoice_url}/{invoice_id}",
headers=self.get_headers(),
params=params
params=params,
timeout=HTTP_TIMEOUT
)
response.raise_for_status()
return response.content
Expand Down Expand Up @@ -456,7 +453,6 @@ def main():
)

try:
logging.warning(f"{Colors.WARNING}SSL certificate verification is disabled for MTC API requests due to a certificate issue on their end. This is insecure and should be fixed if MTC updates their infrastructure.{Colors.ENDC}")
logging.info(f"Starting Tesla charging session processing (tesla_mtc {__version__})")
api = TeslaChargingAPI()
sessions = api.process_charging_sessions()
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ python-dotenv==1.2.2
requests==2.33.0
pdf2image==1.17.0
urllib3==2.8.0
tqdm
tqdm==4.70.1
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ def post(self, url, json=None, headers=None):
if url.endswith("/DataActionGetTransactions"):
return FakeResponse({"data": {"Transactions": {"List": self.transactions}}})
if url.endswith("/ActionClaim_Create"):
return FakeResponse({"data": self.submit_results.pop(0)})
result = self.submit_results.pop(0)
if isinstance(result, Exception):
raise result
return FakeResponse({"data": result})
raise AssertionError(f"Unexpected MTC call: {url}")

def payloads(self, endpoint):
Expand Down
Loading
Loading