Hass.IO - LetsEncrypt, renewal and reboot

Hi guys, I tried to search the forum but it is not clear to me if anyone has the same problem and has found a solution.
The problem is that even after the LetsEncrypt SSL certificate has expired and has been successfully renewed by automation, a host reboot is still required. Only after the reboot is the host reachable via https from the internet again. Anyone have the same problem, can you explain to me how it solved?

1 Like

No, you’re not alone. I have automated my LetsEncrypt (DNS auth method) but even though I have the certificate being renewed, it still isn’t picked up by HA.

I even added these lines at the end of the LetsEncrypt renewal cleanup script:
systemctl stop home-assistant
sleep 10
systemctl start home-assistant

Interestingly, if the above command is run once by certbot, HA doesn’t recognise the updated certificate. But if I SSH in after the renewal and run the exact commands again, HA picks it up.

I’m going to keep working on this. I’m going to try a scripted host reboot next, but that’s not my preferred outcome.

1 Like

Did you happen to find a better way than a host reboot?

A workaround will be to use nginx proxy (as add-on, for example) for SSL and not to use SSL in HA. Then after cert renew only nginx should be restarted.

Finally, I solved it by creating a value template that counts the seconds that are missing until the certificate expires:

ssl_cert_expiry_seconds:
        entity_id: sensor.cert_expiry_timestamp_xxxxxxxx
        friendly_name: 'friendly name'
        value_template: '{{ ((as_timestamp(states("sensor.cert_expiry_timestamp_xxxxxxxx")[:16].replace("T"," ")) - as_timestamp(now())))  }}'
        unit_of_measurement: seconds

After, automation that in the order performs the renewal of the certificate and a reboot of the host within a delay to allow the correct renewal of the certificate before the reboot.

- id: NotifyCerticateExpired
  alias: Notify Certicate Expired
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.ssl_cert_expiry_seconds
  condition:
  - condition: numeric_state
    entity_id: sensor.ssl_cert_expiry_seconds
    below: '0'
  action:         
  - device_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    domain: mobile_app
    type: notify
    message: Certificato SSL Scaduto,  necessario RESTART dell'host
    title: HOME - Let's Encrypt
  - service: hassio.addon_restart
    data:
      addon: core_letsencrypt
  - delay:
      hours: 0
      minutes: 1
      seconds: 0
      milliseconds: 0
  - service: homeassistant.restart

There is probably a better way, but at the moment I don’t see it around and even the one suggested to go through a proxy is not clear to me.

1 Like

I have achieved this by modifying the source code of home assistant http component.
I’m running an older version of home assistant, so the code probably won’t quite match what’s in the github repository.
I modified the components/http/__init__.py file:

async def start(self) -> None:
        """Start the aiohttp server."""
        self.ssl_context = await self.create_ssl_context()

        # Aiohttp freezes apps after start so that no changes can be made.
        # However in Home Assistant components can be discovered after boot.
        # This will now raise a RunTimeError.
        # To work around this we now prevent the router from getting frozen
        # pylint: disable=protected-access
        self.app._router.freeze = lambda: None  # type: ignore[assignment]

        self.runner = web.AppRunner(self.app)
        await self.runner.setup()

        self.site = HomeAssistantTCPSite(
            self.runner, self.server_host, self.server_port, ssl_context=self.ssl_context
        )
        try:
            if(self.ssl_context):
                loop = asyncio.get_event_loop()
                task = loop.create_task(self.monitor_ssl_certificate())
            await self.site.start()
        except OSError as error:
            _LOGGER.error(
                "Failed to create HTTP server at port %d: %s", self.server_port, error
            )

        _LOGGER.info("Now listening on port %d", self.server_port)

    async def create_ssl_context(self) -> ssl.SSLContext | None:
        context: ssl.SSLContext | None
        if self.ssl_certificate:
            try:
                if self.ssl_profile == SSL_INTERMEDIATE:
                    context = ssl_util.server_context_intermediate()
                else:
                    context = ssl_util.server_context_modern()
                await self.load_ssl_certificate(context)
            except OSError as error:
                _LOGGER.error(
                    "Could not read SSL certificate from %s: %s",
                    self.ssl_certificate,
                    error,
                )
                return
        else:
            context = None
        return context
    
    async def load_ssl_certificate(self, context: ssl.SSLContext) -> None:
        try:
            await self.hass.async_add_executor_job(
                        context.load_cert_chain, self.ssl_certificate, self.ssl_key
                    )
        except OSError as error:
            _LOGGER.error(
                    "Could not read SSL certificate from %s: %s",
                    self.ssl_certificate,
                    error,
                )

        if self.ssl_peer_certificate:
                    context.verify_mode = ssl.CERT_REQUIRED
                    await self.hass.async_add_executor_job(
                        context.load_verify_locations, self.ssl_peer_certificate
                    )
        return

    async def monitor_ssl_certificate(self) -> None:
        _LOGGER.info("Starting SSL Certificate monitoring")
        if(self.ssl_context):
            prevHash: str
            with open(self.ssl_certificate, "rb") as file:
                prevHash = hashlib.md5(file.read()).hexdigest()
            while True:
                try:
                    with open(self.ssl_certificate, "rb") as file:
                        currentHash = hashlib.md5(file.read()).hexdigest()
                    if(prevHash != currentHash):
                        prevHash = currentHash
                        _LOGGER.info("Reloading SSL Certificate")
                        await self.load_ssl_certificate(self.ssl_context)
                except Exception as error:
                    _LOGGER.error(
                        "Failed to reload SSL certificate from %s: %s",
                        self.ssl_certificate,
                        error
                    )
                await asyncio.sleep(60)

This checks if the certificate has changed every 60 seconds and the loads certificate into the SSLContext object that was used to start the http server.
I’m not sure if some other components require reloading the certificate chain, but this works for my needs.

4 Likes

Have you thought about contributing that to the official codebase?

1 Like