<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Blog | Dr. Matthias Lohr</title>
    <subtitle>Technical articles and how-tos on Kubernetes, GitLab CI/CD, Docker, Linux networking, GPG and self-hosted infrastructure — written from hands-on freelance platform engineering work.</subtitle>
    <id>https://mlohr.com/feed.xml</id>
    <link rel="self" type="application/atom+xml" href="https://mlohr.com/feed.xml" />
    <link rel="alternate" type="text/html" href="https://mlohr.com/blog/" />
    <updated>2026-10-01T00:00:00Z</updated>
    <author>
        <name>Dr. Matthias Lohr</name>
        <email>mail@mlohr.com</email>
        <uri>https://mlohr.com/</uri>
    </author>
    <icon>https://mlohr.com/static/img/favicon.png</icon>
    <logo>https://mlohr.com/static/img/matthiaslohr_profile.jpg</logo>
    <rights>Copyright (c) 2026 Matthias Lohr</rights>
    <entry>
        <title>Kubernetes Networking on Hetzner with Kubespray</title>
        <id>https://mlohr.com/blog/2026/09/kubespray-kubernetes-clusters-hetzner/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2026/09/kubespray-kubernetes-clusters-hetzner/" />
        <published>2026-09-25T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="kubernetes" label="Kubernetes" />
        <category term="hetzner" label="Hetzner" />
        <category term="kubespray" label="Kubespray" />
        <summary type="text">My recommendation of network configuration for Kubernetes clusters on Hetzner Cloud and bare metal servers set up using Kubespray.</summary>
        <content type="html">&lt;p&gt;Over the past years, I have set up and operated a good number of Kubernetes clusters on Hetzner infrastructure for various customers —
on Hetzner Cloud servers, on bare metal servers, and on mixtures of both.
Along the way, I have gathered a lot of experience with what works well on Hetzner and where the pitfalls are:
MTU values that differ between cloud networks and vSwitches, bare metal nodes that don&#39;t report their private IPs,
or a cloud controller manager that cannot handle vSwitch routes yet.&lt;/p&gt;
&lt;p&gt;With every new cluster, the same networking questions come up again:
which IP ranges to use, how to get bare metal servers into the cloud network,
and which of the many &lt;a href=&#34;https://kubespray.io/&#34;&gt;kubespray&lt;/a&gt; variables actually matter.
This article is my personal reference for exactly that — a network layout and a kubespray configuration for a Kubernetes cluster running on Hetzner Cloud servers,
on Hetzner bare metal servers, or on both.&lt;/p&gt;
&lt;p&gt;Two goals and one assumption guided the decisions below:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The configuration should work for cloud servers as well as for bare metal servers.&lt;/li&gt;
&lt;li&gt;It should be prepared for native routing.
Hetzner currently does not support routing pod networks to bare metal servers, so mixed clusters still need an overlay network.
But as soon as that support arrives, switching over should be a matter of changing only a few variables.&lt;/li&gt;
&lt;li&gt;Even if Hetzner bare metal servers are used, we assume that a Hetzner Load Balancer is used for ingress.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Network Configuration&lt;/h2&gt;
&lt;p&gt;Everything starts in the Hetzner Cloud Console:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create a Hetzner Cloud project.&lt;/li&gt;
&lt;li&gt;Add a network with an IP range, e.g., &lt;code&gt;10.0.0.0/16&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a subnet for the load balancer(s), e.g., &lt;code&gt;10.0.0.0/24&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a subnet for the cloud servers, e.g., &lt;code&gt;10.0.1.0/24&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If bare metal servers are involved:&lt;ul&gt;
&lt;li&gt;Create a vSwitch and add the bare metal servers to it.&lt;/li&gt;
&lt;li&gt;Add a subnet for the bare metal servers, e.g., &lt;code&gt;10.0.2.0/24&lt;/code&gt;, and connect it to the vSwitch you just created.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Kubespray Configuration&lt;/h2&gt;
&lt;h3&gt;IP Ranges&lt;/h3&gt;
&lt;p&gt;The pod and service networks are placed inside the IP range of the Hetzner network.
That is not required for an overlay network, but it is a precondition for native routing later on, since Hetzner can only route IP ranges that belong to the network itself.&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- k8s_cluster/k8s-cluster.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;kube_service_addresses&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;10.0.64.0/18&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;kube_pods_subnet&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;10.0.128.0/18&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;h3&gt;Cloud Controller Manager&lt;/h3&gt;
&lt;p&gt;To integrate the cluster with the Hetzner API, a cloud controller manager is needed.
I prefer to install it myself using the upstream Helm chart rather than relying on the version bundled with kubespray, so kubespray is told to prepare the cluster for an external cloud controller manager without deploying one:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- all/all.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;cloud_provider&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;external&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;external_cloud_provider&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;manual&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;h3&gt;Container Network Interface&lt;/h3&gt;
&lt;p&gt;Cilium is my default choice.
It is a safe long-term bet: Cilium &lt;a href=&#34;https://www.cncf.io/announcements/2023/10/11/cloud-native-computing-foundation-announces-cilium-graduation/&#34;&gt;graduated within the CNCF&lt;/a&gt; in 2023, at that time the second most active CNCF project in terms of commits.
And it implements the whole data path in eBPF instead of iptables, which is what makes it possible to drop kube-proxy entirely further down (&lt;a href=&#34;https://docs.cilium.io/en/stable/network/kubernetes/kubeproxy-free/&#34;&gt;Kubernetes Without kube-proxy&lt;/a&gt;):&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- k8s_cluster/k8s-cluster.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;kube_network_plugin&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;cilium&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;kube_owner&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;root&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Cilium&#39;s init containers run as UID 0, but with all capabilities dropped, including &lt;code&gt;CAP_DAC_OVERRIDE&lt;/code&gt; — which means that even root is subject to the regular file permission checks.
Therefore, &lt;code&gt;kube_owner&lt;/code&gt; must be set to &lt;code&gt;root&lt;/code&gt; for Cilium.&lt;/p&gt;
&lt;h3&gt;Network MTU&lt;/h3&gt;
&lt;p&gt;Cilium needs to know the MTU of the underlying network, and Hetzner uses two different values here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1450 bytes &lt;a href=&#34;https://docs.hetzner.com/de/networking/networks/troubleshooting/mtu/&#34;&gt;inside a Hetzner cloud network&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;1400 bytes &lt;a href=&#34;https://docs.hetzner.com/de/networking/networks/connect-dedi-vswitch&#34;&gt;for Hetzner vSwitches&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two things are worth knowing about &lt;code&gt;cilium_mtu&lt;/code&gt;. First, it refers to the MTU of the &lt;em&gt;underlying&lt;/em&gt; network, not to the one the pods will end up with — Cilium subtracts the encapsulation overhead (50 bytes for VXLAN) on its own.
Second, it is a cluster-wide setting, which means the smallest MTU on any path wins:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cloud servers only: &lt;code&gt;1450&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;bare metal servers involved: &lt;code&gt;1400&lt;/code&gt;, for every node, including the cloud ones&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Routing&lt;/h3&gt;
&lt;p&gt;As long as Hetzner does not route pod networks to bare metal servers, pod traffic has to be encapsulated, so Cilium runs in VXLAN mode.
Hetzner states this requirement themselves: &lt;em&gt;&#34;Using the routing feature of private networks is not supported, so this requires a CNI plugin with encapsulation methods, such as Cilium with routing mode &lt;code&gt;tunnel&lt;/code&gt;&#34;&lt;/em&gt; (&lt;a href=&#34;https://github.com/hetznercloud/hcloud-cloud-controller-manager/blob/main/docs/guides/robot/private-networks.md&#34;&gt;Attach Load Balancers to Robot Private IPs&lt;/a&gt;).
The native routing CIDR is prepared already, but only takes effect once the tunnel is switched off:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- k8s_cluster/k8s-net-cilium.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;cilium_mtu&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;1400&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;cilium_tunnel_mode&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;vxlan&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;cilium_native_routing_cidr&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;{%&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;cilium_tunnel_mode&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;==&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#39;disabled&#39;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;%}{{&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;kube_pods_subnet&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;}}{%&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;endif&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;%}&#34;&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Once Hetzner supports routing pod networks to bare metal servers, setting &lt;code&gt;cilium_tunnel_mode: disabled&lt;/code&gt; and re-enabling the route controller of the cloud controller manager (see below) is all it takes — the routes inside the Hetzner network are then maintained for you.
&lt;code&gt;cilium_mtu&lt;/code&gt; stays at 1400, as the vSwitch remains the limiting factor; dropping the VXLAN header simply gives the pods the full 1400 bytes instead of 1350.&lt;/p&gt;
&lt;h3&gt;kube-proxy&lt;/h3&gt;
&lt;p&gt;Cilium can take over the work of kube-proxy and implement Kubernetes services in eBPF instead of iptables or IPVS, so there is no reason to keep kube-proxy around as well:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- k8s_cluster/k8s-net-cilium.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;cilium_kube_proxy_replacement&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;true&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;## --- k8s_cluster/k8s-cluster.yml&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;kube_proxy_remove&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;true&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;h2&gt;Cluster Applications&lt;/h2&gt;
&lt;p&gt;Two Hetzner components complete the setup and are installed into the cluster afterwards:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/hetznercloud/hcloud-cloud-controller-manager&#34;&gt;hcloud-cloud-controller-manager&lt;/a&gt; — the cloud controller manager kubespray deliberately left out above&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/hetznercloud/csi-driver&#34;&gt;csi-driver&lt;/a&gt; — for provisioning Hetzner Cloud volumes as persistent volumes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a cluster containing bare metal servers, the cloud controller manager needs two adjustments.
Give it the network (&lt;code&gt;HCLOUD_NETWORK&lt;/code&gt;), so that it can add bare metal nodes to a Load Balancer by their private vSwitch IP, but switch off its route controller (&lt;code&gt;HCLOUD_NETWORK_ROUTES_ENABLED=false&lt;/code&gt;, plus &lt;code&gt;networking.enabled: false&lt;/code&gt; in the Helm chart), as it cannot deal with vSwitches yet.
This is also the switch to flip once Hetzner supports routing pod networks to bare metal servers.&lt;/p&gt;
&lt;p&gt;Note that the bare metal nodes need an InternalIP for this to work: Hetzner does not report vSwitch IPs automatically, so the address has to be passed to the kubelet via &lt;code&gt;--node-ip&lt;/code&gt; (in kubespray, that is the &lt;code&gt;ip&lt;/code&gt; variable of the according inventory host).&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Solar Energy Management with Home Assistant: Pool Heating</title>
        <id>https://mlohr.com/blog/2026/06/home-assistant-solar-energy-management-pool-heating/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2026/06/home-assistant-solar-energy-management-pool-heating/" />
        <published>2026-06-15T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="home-assistant" label="Home Assistant" />
        <summary type="text">How to run TeddyCloud on a public server using nginx SNI routing to handle both LetsEncrypt HTTPS for the web UI and TonieBox client certificate auth on port 443.</summary>
        <content type="html">&lt;p&gt;We have a solar power system with a battery, and a pool heated by a heat pump.
The obvious goal: use excess solar energy to heat the pool for free.
The naive solution — turn the heat pump on when there is surplus, turn it off when there isn&#39;t — sounds right but creates a problem in practice.
Heat pumps are not fans of short cycles.
Starting and stopping repeatedly throughout the day causes excessive wear and reduces efficiency significantly.&lt;/p&gt;
&lt;p&gt;What I really wanted was for the heat pump to run once a day, for as long as conditions allow, in the window where solar production is highest.
This article describes how I built exactly that in Home Assistant, using template sensors and a small automation.&lt;/p&gt;
&lt;h2&gt;The Scenario&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Solar panels feed into a SolarEdge inverter with a battery&lt;/li&gt;
&lt;li&gt;A heat pump heats the pool and draws up to &lt;strong&gt;2500 W&lt;/strong&gt; at full load&lt;/li&gt;
&lt;li&gt;Home Assistant has access to live power sensors and a solar forecast via &lt;a href=&#34;https://github.com/BJReplay/ha-solcast-solar&#34;&gt;Solcast&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The pool filter pump runs on a schedule (&lt;code&gt;schedule.pool_filter_pump_schedule&lt;/code&gt;) — the heat pump should only operate within this window&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The key insight: the battery should act as an energy buffer, not a competitor.
If PV is producing 4 kW and the house only needs 1 kW, those 3 kW are currently going into the battery.
The heat pump can take 2.5 kW of that instead — and as long as the remaining solar forecast for the day is sufficient, the battery will still be fully charged by evening.&lt;/p&gt;
&lt;h2&gt;The Logic&lt;/h2&gt;
&lt;p&gt;The heat pump should start when &lt;strong&gt;two conditions&lt;/strong&gt; are simultaneously true:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Current surplus ≥ 2500 W&lt;/strong&gt; — enough PV power above house consumption to run the heat pump right now, without drawing from the battery or grid.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;End-of-day energy balance is positive&lt;/strong&gt; — the remaining PV forecast for today is enough to cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the projected house load for the rest of the schedule window,&lt;/li&gt;
&lt;li&gt;the heat pump running for the entire remaining schedule window,&lt;/li&gt;
&lt;li&gt;and whatever energy the battery still needs to reach 100% charge.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The heat pump should stop when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The filter pump schedule ends, or&lt;/li&gt;
&lt;li&gt;The projected energy balance turns negative — meaning the remaining PV forecast is no longer sufficient to cover house load, continued heat pump operation, and a full battery charge by end of day.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once stopped, the heat pump does not restart until the next day, regardless of whether conditions improve.
This enforces exactly one heating cycle per day.&lt;/p&gt;
&lt;h3&gt;Surplus Calculation&lt;/h3&gt;
&lt;p&gt;There is no direct &#34;house consumption&#34; sensor available.
Instead, surplus is derived from the sensors that are available:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;surplus = battery_charging − battery_discharging + grid_export − grid_import
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives the net power currently flowing &lt;em&gt;out&lt;/em&gt; of the house system — into the battery or onto the grid.
That is exactly the power that could be redirected to the heat pump instead.&lt;/p&gt;
&lt;p&gt;When the heat pump is already running, this value stays positive as long as PV covers everything.
If it goes negative the heat pump is drawing from storage or the grid, which will also be reflected in the energy balance.&lt;/p&gt;
&lt;h3&gt;Energy Balance Calculation&lt;/h3&gt;
&lt;p&gt;All values are in Wh:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;energy_balance =
    remaining_pv_forecast × 1000
  − battery_deficit
  − house_power × remaining_daylight_hours
  − heat_pump_power × remaining_schedule_hours
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;remaining_pv_forecast&lt;/code&gt; comes from &lt;code&gt;sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute&lt;/code&gt; (kWh, multiplied by 1000 to get Wh)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;battery_deficit&lt;/code&gt; = battery capacity × (1 − state of charge) in Wh&lt;/li&gt;
&lt;li&gt;&lt;code&gt;house_power&lt;/code&gt; = current house consumption in W, estimated from the live power sensors&lt;/li&gt;
&lt;li&gt;&lt;code&gt;remaining_daylight_hours&lt;/code&gt; = hours until sunset (&lt;code&gt;sun.sun&lt;/code&gt; next_setting) — house consumption continues after the schedule ends, competing for the same remaining PV energy&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heat_pump_power&lt;/code&gt; = 2500 W if the heat pump is currently off, or 0 W if it is already running (its load is already included in &lt;code&gt;house_power&lt;/code&gt; and would be double-counted otherwise)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;remaining_schedule_hours&lt;/code&gt; = hours until the filter pump schedule turns off&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A positive energy balance means: even if the heat pump runs for the full remaining schedule window, there will still be enough solar energy to cover house consumption until sunset and fill the battery completely by end of day.&lt;/p&gt;
&lt;h2&gt;Implementation&lt;/h2&gt;
&lt;p&gt;Everything lives in a single Home Assistant &lt;a href=&#34;https://www.home-assistant.io/docs/configuration/packages/&#34;&gt;package file&lt;/a&gt;, which keeps the helper, sensors, and automations together and makes the setup self-contained.&lt;/p&gt;
&lt;h3&gt;Entities Used&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.power_solar_generation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live PV production (W)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.power_grid_import&lt;/code&gt; / &lt;code&gt;sensor.power_grid_export&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Grid power flow (W)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.power_battery_charging&lt;/code&gt; / &lt;code&gt;sensor.power_battery_discharging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Battery power flow (W)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.solaredge_battery1_size_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Battery capacity (Wh)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.solaredge_battery1_state_of_charge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Battery SoC (%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remaining PV forecast today (kWh)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sun.sun&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sunset time (for remaining daylight)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;schedule.pool_filter_pump_schedule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowed operating window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;climate.pool_heat_pump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Heat pump control (mode &lt;code&gt;heat&lt;/code&gt; / &lt;code&gt;off&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;Template Sensors&lt;/h3&gt;
&lt;p&gt;Five template sensors expose the intermediate values, making it easy to see what the automation is &#34;thinking&#34; in the Home Assistant UI:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sensor.pool_heating_surplus_power&lt;/code&gt;&lt;/strong&gt; — current available surplus in watts:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set bat_charge  = states(&#39;sensor.power_battery_charging&#39;)    | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set bat_disch   = states(&#39;sensor.power_battery_discharging&#39;) | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set grid_export = states(&#39;sensor.power_grid_export&#39;)         | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set grid_import = states(&#39;sensor.power_grid_import&#39;)         | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{{ (bat_charge - bat_disch + grid_export - grid_import) | round(0) | int }}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;sensor.pool_heating_energy_balance&lt;/code&gt;&lt;/strong&gt; — net Wh available if the heat pump runs for the rest of the schedule window:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set remaining_pv      = states(&#39;sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute&#39;) | float * 1000 %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set battery_deficit   = states(&#39;sensor.pool_heating_battery_deficit&#39;)          | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set remaining_sched_h = states(&#39;sensor.pool_heating_remaining_schedule_hours&#39;) | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set house_w           = states(&#39;sensor.pool_heating_house_power&#39;)              | float %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set heat_pump_w       = 0 if not is_state(&#39;climate.pool_heat_pump&#39;, &#39;off&#39;) else 2500 %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% if is_state(&#39;sun.sun&#39;, &#39;below_horizon&#39;) %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set remaining_daylight_h = 0 %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% else %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set next_setting = state_attr(&#39;sun.sun&#39;, &#39;next_setting&#39;) | as_datetime %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% set remaining_daylight_h = [(next_setting - now()).total_seconds() / 3600, 0] | max %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{% endif %}&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;no&#34;&gt;{{ (remaining_pv - battery_deficit - house_w * remaining_daylight_h - heat_pump_w * remaining_sched_h) | round(0) | int }}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The other three sensors (&lt;code&gt;pool_heating_house_power&lt;/code&gt;, &lt;code&gt;pool_heating_battery_deficit&lt;/code&gt;, &lt;code&gt;pool_heating_remaining_schedule_hours&lt;/code&gt;) feed into the energy balance calculation and are also useful for debugging.&lt;/p&gt;
&lt;h3&gt;Automations&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Start&lt;/strong&gt; — triggered every 5 minutes and immediately when the schedule activates:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;schedule.pool_filter_pump_schedule&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;on&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;input_boolean.pool_heat_pump_ran_today&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;off&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;climate.pool_heat_pump&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;off&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;numeric_state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;sensor.pool_heating_surplus_power&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;above&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;2500&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;numeric_state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;sensor.pool_heating_energy_balance&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;above&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;0&lt;/span&gt;
&lt;span class=&#34;nt&#34;&gt;action&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;action&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;climate.set_hvac_mode&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;target&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;climate.pool_heat_pump&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;data&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;hvac_mode&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;heat&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;action&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;input_boolean.turn_on&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;target&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;input_boolean.pool_heat_pump_ran_today&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Stop&lt;/strong&gt; — triggered on schedule end and every 5 minutes (to catch the energy balance turning negative):&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;not&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;conditions&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;climate.pool_heat_pump&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;off&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;or&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;conditions&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;schedule.pool_filter_pump_schedule&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;state&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&#34;off&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;      &lt;/span&gt;&lt;span class=&#34;p p-Indicator&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;condition&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;numeric_state&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;entity_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;sensor.pool_heating_energy_balance&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;below&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;0&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Using the energy balance as the stop condition (rather than instantaneous surplus) means brief cloud shadows do not trigger a stop: a passing cloud does not change the Solcast day forecast, so the energy balance stays positive and the heat pump keeps running. A stop only fires when the overall day forecast genuinely deteriorates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Daily reset&lt;/strong&gt; — clears the &lt;code&gt;input_boolean.pool_heat_pump_ran_today&lt;/code&gt; flag at midnight.&lt;/p&gt;
&lt;h3&gt;Setup&lt;/h3&gt;
&lt;p&gt;Add the package to &lt;code&gt;configuration.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;homeassistant&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;packages&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;pool_heating&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kt&#34;&gt;!include&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l l-Scalar l-Scalar-Plain&#34;&gt;packages/pool_heating.yaml&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That&#39;s it — no manual helper creation required, as the &lt;code&gt;input_boolean&lt;/code&gt; is declared inside the package file itself.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>TeddyCloud on a Public Server IP with LetsEncrypt</title>
        <id>https://mlohr.com/blog/2025/01/teddycloud-on-a-public-server-ip-with-letsencrypt/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2025/01/teddycloud-on-a-public-server-ip-with-letsencrypt/" />
        <published>2025-01-10T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="docker" label="Docker" />
        <summary type="text">How to run TeddyCloud on a public server using nginx SNI routing to handle both LetsEncrypt HTTPS for the web UI and TonieBox client certificate auth on port 443.</summary>
        <content type="html">&lt;p&gt;&lt;a href=&#34;https://amzn.to/4jdil9s&#34;&gt;TonieBoxes&lt;/a&gt; are a great kind of toy for &lt;del&gt;fathers of&lt;/del&gt; young children. It allows them to play music or audio books on their own, just by placing funny little figures on it. It&#39;s cute, cuddly, extremely intuitive, and not that technically complex. However, there is a major disadvantage: the device is tied to the manufacturer, who has of course invested a lot of time and money in the development of the Toniebox, and it is legitimate for the manufacturer to make money from it. However, there are enough examples of closed, cloud-based systems whose manufacturers discontinue the platform at some point because it is no longer sufficiently profitable to operate. Luckily, &lt;a href=&#34;https://github.com/toniebox-reverse-engineering/&#34;&gt;some people&lt;/a&gt; successfully reverse engineered the box and were able to modify it in a way, which allows to operate your own content server for this system.&lt;/p&gt;
&lt;p&gt;When I first came into contact with their software, &lt;a href=&#34;https://github.com/toniebox-reverse-engineering/teddycloud&#34;&gt;TeddyCloud&lt;/a&gt;, the first question that arose was whether I should install TeddyCloud locally or on a public server. The decision to go with a public server was an easy one: I wanted my kids to be able to use their TonieBox with all its content (both original and custom) while on vacation, for example. After that, however, the question arose as to how I could secure access. Typically, this includes a properly set up SSL and user authentication. For self-hosted services, LetsEncrypt is typically the first choice for SSL certificates. However, TeddyCloud also sets up its own CA to secure communication with the TonieBox, which uses Client Certificate Authentication.&lt;/p&gt;
&lt;p&gt;In this context, the TeddyCloud documentation contains the following statement:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Please beware that port 443 cannot be remapped and you cannot use a reverse proxy like nginx or traffik without passing through the TLS (complex, not recommended).
The client certificate authentication needs to be done by teddyCloud.
Also, there is no SNI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Challenge Accepted!&lt;/h2&gt;
&lt;p&gt;Actually, writing this article, I realized that I never checked if custom SSL certificates (e.g., issued by LetsEncrypt) are supported for the TeddyCloud web interface. Anyway, I also couldn&#39;t find any documentation considering authentication for TeddyCloud. So I really wanted to have a reverse proxy, dealing with both, SSL encryption for the web interface with a valid (LetsEncrypt) certificate, and authentication (I decided to just use plain old Basic Auth).&lt;/p&gt;
&lt;p&gt;Usually, a reverse proxy either just forwards TCP, or it actually does SSL Offloading by decrypting the encrypted HTTPS traffic and only forwarding the HTTP part to the upstream application. However, since TonieBoxes do client certificate authentication and the TeddyCloud wants to do it by itself to identify the TonieBox, SSL Offloading by the reverse proxy was no option. On the other hand, I somehow had to be able to set a switch if a connection (from a TonieBox) should be forwarded to the TeddyCloud port 443, or if it (from a browser) should end up with the web interface, encrypted with a LetsEncrypt certificate.&lt;/p&gt;
&lt;p&gt;Luckily, nginx offers a nice option for this: With a stream block, I can basically do TCP forwarding, but with the ssl_preread directive I&#39;m able to somehow peek into the connection and to check if, and which server name is indicated for the SSL connection.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Also, there is no SNI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Perfect! This saved my day! So, if it&#39;s a request with SNI, I do the SSL Offloading stuff with my LetsEncrypt certificate. If there is no SNI, I just forward it to TeddyCloud as is. Works for me :)&lt;/p&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;In case you want to use it by yourself, &lt;a href=&#34;https://gitlab.com/-/snippets/4792857&#34;&gt;here is the relevant config!&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;To get things running, do the following steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set up a server with an OS of your choice, install Docker engine and configure your desired domain name to the IP(s) of that server. I also would suggest setting up a firewall and only allowing ports 22 (SSH), 80 (HTTP) and 443 (HTTPS).&lt;/li&gt;
&lt;li&gt;Copy the configs to your server (docker-compose.yaml and nginx.conf should be in the same directory) and adjust all occurences of the domain name teddycloud.example.com to your domain.&lt;/li&gt;
&lt;li&gt;Comment out the HTTPS server section in nginx.conf, as we don&#39;t have the LetsEncrypt certificates yet.&lt;/li&gt;
&lt;li&gt;Start the Docker containers, e.g., using docker compose up -d.&lt;/li&gt;
&lt;li&gt;Request a LetsEncrypt certificate by running docker compose exec -it certbot certbot certonly --webroot. When you&#39;re asked for the webroot directory, please provide /var/www/certbot.&lt;/li&gt;
&lt;li&gt;Uncomment the HTTPS server configuration in nginx.conf.&lt;/li&gt;
&lt;li&gt;Restart nginx, e.g., using docker compose restart nginx.&lt;/li&gt;
&lt;/ul&gt;
</content>
    </entry>
    <entry>
        <title>Sync Zotero Library to Nextcloud</title>
        <id>https://mlohr.com/blog/2022/07/sync-zotero-library-to-nextcloud/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2022/07/sync-zotero-library-to-nextcloud/" />
        <published>2022-07-18T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="zotero" label="Zotero" />
        <summary type="text">How to configure Zotero to sync bibliography attachments to a self-hosted Nextcloud instance via WebDAV using a public shared folder link.</summary>
        <content type="html">&lt;p&gt;For my &lt;a href=&#34;https://wpdev.mlohr.com/publications/&#34;&gt;research&lt;/a&gt;, I&#39;m using &lt;a href=&#34;https://www.zotero.org/&#34;&gt;Zotero&lt;/a&gt; for bibliography management. It&#39;s free, it&#39;s great, and it fits perfectly for my needs. In this blog post, I show how to configure Zotero to synchronize your Zotero Library to Nextcloud.&lt;/p&gt;
&lt;p&gt;For some of the research papers I&#39;ve read during my research activities, there are several (mostly, but not always, similar) versions, for some other papers it is very difficult to find the document. Therefore, I decided to always keep a digital copy of the document I&#39;ve just read, just to ensure to be able to access the exact same version I have accessed before. Zotero allows for attaching files to an entry, and furthermore allows for synchronizing the library as all as the attachments. While synchronizing the bibliography entries meta data (authors, title, ...) seems to be free and unlimited, only 300MB of document storage are for free per account. Zotero offers paid plans to increase the storage limit, or to use own, WebDAV based, storage.&lt;/p&gt;
&lt;p&gt;Since I have a running Nextcloud instance with WebDAV support, I decided to use my Nextcloud for the synchronization. Actually, it is quite easy to configure it accordingly, however, I spent some time on finding that out and there are also some open posts in the Zotero forums, therefore I&#39;m going to document my solution here.&lt;/p&gt;
&lt;h2&gt;Configuring the Synchronization of your Zotero Library to Nextcloud&lt;/h2&gt;
&lt;p&gt;First, we need to create a folder in Nextcloud. Please note that Zotero requires the path to end with zotero. Also consider if you want to use your global Nextcloud credentials (which I don&#39;t recommend to do) or to create a dedicated shared folder for this, which will provide you with extra credentials just for this purpose. Since the name of the folder configured to be shared does not show up in the URL, within the shared folder there has to be the zotero folder containing the actual synchronized attachments.&lt;/p&gt;
&lt;p&gt;In my Nextcloud instance, I created a folder PhD/Zotero/zotero and configured and configured the directory PhD/Zotero to be accessible and editable using a link. The link then should look like this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://nextcloud.example.com/s/1337R4nd0mSh4r3S3cret&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Now, in Zotero client, configure Sync (Edit -&amp;gt; Preferences -&amp;gt; Sync) as follows: Set File Syncing mode to WebDAV, as URL put nextcloud.example.com/public.php/webdav, and as username as well as password use the sharing secret (the last part of the URL). That should be it.&lt;/p&gt;
&lt;h2&gt;Update&lt;/h2&gt;
&lt;p&gt;The URL nextcloud.example.com/public.php/webdav is correct when using a sharing secret for the credentials. When using the actual account username and password, the URL is nextcloud.example.com/remote.php/webdav.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Controlling a TOLO Steam Bath with Home Assistant</title>
        <id>https://mlohr.com/blog/2021/12/home-assistant-tolo-steam-bath-integration/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2021/12/home-assistant-tolo-steam-bath-integration/" />
        <published>2021-12-15T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="home-assistant" label="Home Assistant" />
        <category term="python" label="Python" />
        <summary type="text">How I reverse-engineered the network protocol of TOLO steam generators, wrote tololib (a Python library and CLI for them), and turned it into an official Home Assistant integration.</summary>
        <content type="html">&lt;p&gt;This one started, as many of my side projects do, with a piece of hardware sitting in front of me and the nagging question: &lt;em&gt;why can&#39;t I control this from Home Assistant?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A while ago I built a steam bath together with a member of my family.
The steam generator we used came from &lt;a href=&#34;https://www.tolosauna.com/&#34;&gt;TOLO&lt;/a&gt; (a brand of Steamtec), and it shipped with a so-called &lt;em&gt;App Control Box&lt;/em&gt; — a little network module that lets you operate the steam bath from a smartphone app instead of just the wall panel.
Temperature, humidity, the lamp, the fan, aroma therapy, the salt bath nebulizer: all of it controllable from your phone.
Nice.
But of course I didn&#39;t want &lt;em&gt;yet another app&lt;/em&gt; on my phone — I wanted it in &lt;a href=&#34;https://www.home-assistant.io/&#34;&gt;Home Assistant&lt;/a&gt;, next to everything else in the house.
And later, when I built my own house and put a steam bath into it for my own family, having proper home automation control was no longer a nice-to-have, it was the whole point.&lt;/p&gt;
&lt;p&gt;There was just one problem: there is no public API, no documentation, and no official integration. So I had to build one.&lt;/p&gt;
&lt;h2&gt;Reverse Engineering the Protocol&lt;/h2&gt;
&lt;p&gt;The App Control Box talks to the smartphone app over the local network, so the obvious first step was to find out &lt;em&gt;how&lt;/em&gt;.
I put the box and my phone on the same network, captured the traffic while clicking through the app, and started staring at packets.&lt;/p&gt;
&lt;p&gt;A few things became clear quickly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The communication is based on &lt;strong&gt;UDP datagrams&lt;/strong&gt;, not TCP. The app sends a small request packet, the device answers with a response packet.&lt;/li&gt;
&lt;li&gt;Everything happens on a fixed port (&lt;strong&gt;51500&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;The protocol is refreshingly simple: each message is a short, fixed-structure byte sequence — a command byte, a couple of arguments, and (for status/settings responses) a payload that encodes the current state of the device.&lt;/li&gt;
&lt;li&gt;There is a tiny keep-alive mechanism (a single byte) to let the device know someone is still listening.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From there it was the usual reverse-engineering grind: toggle one setting in the app, watch which byte in the packet changes, write it down, repeat. Bit by bit the meaning of every field fell into place — target temperature (35–60 °C), target humidity (60–99 %), the various timers (power, fan, salt bath), lamp mode, aroma therapy slot, and a whole set of read-only status values like the current water tank temperature, water level and valve state.&lt;/p&gt;
&lt;h2&gt;tololib&lt;/h2&gt;
&lt;p&gt;Once I understood the protocol, I wrapped it in a proper Python library so I&#39;d never have to think about raw bytes again: &lt;a href=&#34;https://gitlab.com/MatthiasLohr/tololib&#34;&gt;&lt;strong&gt;tololib&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;tololib gives you a clean client object that hides all the datagram juggling. Reading the current state of the device is as simple as:&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;kn&#34;&gt;from&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nn&#34;&gt;tololib&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kn&#34;&gt;import&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;ToloClient&lt;/span&gt;

&lt;span class=&#34;n&#34;&gt;client&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;ToloClient&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;192.168.1.100&#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;

&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;client&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;get_status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;
&lt;span class=&#34;nb&#34;&gt;print&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;power_on&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;current_temperature&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;current_humidity&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;

&lt;span class=&#34;n&#34;&gt;settings&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;client&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;get_settings&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;
&lt;span class=&#34;nb&#34;&gt;print&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;settings&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;target_temperature&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;settings&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;target_humidity&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;

&lt;span class=&#34;c1&#34;&gt;# turn the steam bath on&lt;/span&gt;
&lt;span class=&#34;n&#34;&gt;client&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;set_power_on&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;True&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The library also ships with a &lt;strong&gt;command line interface&lt;/strong&gt;, so you can poke at a device straight from the terminal without writing a single line of Python — handy for debugging and for figuring out whether a problem is in the hardware, the network, or the software on top.&lt;/p&gt;
&lt;p&gt;One detail I&#39;m particularly happy with: tololib includes a &lt;strong&gt;device simulator&lt;/strong&gt;. Since the protocol is just UDP request/response, I could implement a fake TOLO device that speaks the same protocol. That meant I could write and run the entire test suite — and later develop the Home Assistant integration — &lt;em&gt;without&lt;/em&gt; needing the actual steam bath powered up and reachable. Continuous integration doesn&#39;t have a steam bath in the rack, after all.&lt;/p&gt;
&lt;p&gt;The library is published on &lt;a href=&#34;https://pypi.org/project/tololib/&#34;&gt;PyPI&lt;/a&gt; under the MIT license, and the API documentation lives &lt;a href=&#34;https://matthiaslohr.gitlab.io/tololib/&#34;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Legal note:&lt;/strong&gt; this is a community project. Neither tololib nor I have any professional affiliation with the companies behind TOLO. I just wanted to control my own steam bath.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The Home Assistant Integration&lt;/h2&gt;
&lt;p&gt;With a stable library doing the heavy lifting, building the &lt;a href=&#34;https://www.home-assistant.io/integrations/tolo/&#34;&gt;&lt;strong&gt;TOLO integration for Home Assistant&lt;/strong&gt;&lt;/a&gt; became the fun part. tololib is the core; the integration is a relatively thin layer mapping the device&#39;s capabilities onto Home Assistant entities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;climate&lt;/strong&gt; entity for the steam bath itself, with the &lt;code&gt;heat&lt;/code&gt; and &lt;code&gt;dry&lt;/code&gt; operating modes, target temperature and target humidity;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;light&lt;/strong&gt; entity for the RGB lamp, including its automatic colour-fading mode;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;fan&lt;/strong&gt; entity for the ventilation, with its timer;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;switches&lt;/strong&gt; for aroma therapy and the salt bath nebulizer;&lt;/li&gt;
&lt;li&gt;and a set of &lt;strong&gt;sensors&lt;/strong&gt; and binary sensors exposing the diagnostics — tank temperature, water level, timers and valve state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the integration communicates with the device entirely over the local network, it runs as a &lt;strong&gt;local polling&lt;/strong&gt; integration: no cloud, no account, no external dependency. Discovery makes setup painless — in most cases Home Assistant finds the App Control Box on the network and you just confirm.&lt;/p&gt;
&lt;p&gt;I contributed the integration to Home Assistant core, and after the usual (very thorough, and very helpful) review process, it shipped with the &lt;strong&gt;Home Assistant 2021.12&lt;/strong&gt; release. Seeing your own little reverse-engineering hobby project become a one-click integration that anyone with the same hardware can use is a genuinely good feeling.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;If you happen to own a TOLO / Steamtec steam bath with an App Control Box, you can add it straight from the Home Assistant integrations UI — no extra setup required. And if you just want to talk to one of these devices from your own Python code or from the command line, grab &lt;a href=&#34;https://gitlab.com/MatthiasLohr/tololib&#34;&gt;tololib&lt;/a&gt; from PyPI.&lt;/p&gt;
&lt;p&gt;Found a bug, or missing a feature? &lt;a href=&#34;https://gitlab.com/MatthiasLohr/tololib/-/issues&#34;&gt;Open an issue&lt;/a&gt; — contributions are very welcome.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>GPG Agent for SSH Authentication (Update)</title>
        <id>https://mlohr.com/blog/2021/06/gpg-agent-for-ssh-authentication-update/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2021/06/gpg-agent-for-ssh-authentication-update/" />
        <published>2021-06-14T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="gpg" label="GPG" />
        <category term="ubuntu" label="Ubuntu" />
        <category term="yubikey" label="YubiKey" />
        <summary type="text">Simplified three-step guide to configure GPG agent as SSH agent on Ubuntu 21.04, replacing the Gnome Keyring with your GPG/YubiKey key.</summary>
        <content type="html">&lt;p&gt;In my last post regarding the usage of GPG Agent for SSH Authentication (&lt;a href=&#34;https://mlohr.com/blog/2018/06/gpg-agent-for-ssh-in-gnome/&#34;&gt;read here&lt;/a&gt;) I presented my first solution to replace the default OpenSSH Agent with GPG&#39;s SSH Agent support. With the update to Ubuntu 21.04 I had to reconfigure this, since the current way stopped working.&lt;/p&gt;
&lt;h2&gt;Prequisites&lt;/h2&gt;
&lt;p&gt;The prequisites are almost the same as in my last article. For this article, I assume that a GPG key pair is available and working (e.g. a &lt;a href=&#34;https://mlohr.com/tags/yubikey/&#34;&gt;YubiKey&lt;/a&gt; or a file based key pair) for signing and encryption. Furthermore, since I&#39;m still using Ubuntu (now in version 21.04), this tutorial is most probably specific to systems running Ubuntu 21.04. However, I guess the most parts of this tutorial can be transported to other Linux operating systems as well.&lt;/p&gt;
&lt;h2&gt;Setup GPG Agent for SSH Authentication&lt;/h2&gt;
&lt;p&gt;Actually, after I upgraded to Ubuntu 21.04, I found that there is a much simpler way to get GPG Agent for SSH Authentication running as I have desribed in my last article related to this topic. This time, just three simple steps are enough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First, we need to enable the GnuPG agent:
&lt;code&gt;echo &#34;use-agent&#34; &amp;gt;&amp;gt; ~/.gnupg/gpg.conf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Second, we need to tell the GnuPG agent also to enable support for SSH:
&lt;code&gt;echo &#34;enable-ssh-support&#34; &amp;gt;&amp;gt; ~/.gnupg/gpg-agent.conf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Third, we need to inform SSH to use the GnuPG provided SSH agent by manually configuring the IdentityAgent (&lt;a href=&#34;https://man.openbsd.org/ssh_config.5#IdentityAgent&#34;&gt;OpenSSH documentation&lt;/a&gt;). In my case (with Ubuntu 21.04), the SSH auth socket created by GnuPG agent was located at /run/user/1000/gnupg/S.gpg-agent.ssh:
&lt;code&gt;echo &#34;IdentityAgent /run/user/1000/gnupg/S.gpg-agent.ssh&#34; &amp;gt;&amp;gt; ~/.ssh/config&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Optional Steps&lt;/h2&gt;
&lt;p&gt;Currently, by default, also the Gnome Keyring based SSH agent is starting (at least on my system). In order to disable, I had to uncheck &lt;em&gt;SSH Key Agent&lt;/em&gt; in the &lt;em&gt;gnome-session-properties&lt;/em&gt; applet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2021/06/gpg-agent-for-ssh-authentication-update/gnome-session-properties-ssh-agent.png&#34; alt=&#34;Gnome&#34; class=&#34;img-fluid&#34;&gt;&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Helm Charts for Hetzner Cloud</title>
        <id>https://mlohr.com/blog/2020/08/helm-charts-for-hetzner-cloud/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2020/08/helm-charts-for-hetzner-cloud/" />
        <published>2020-08-23T00:00:00Z</published>
        <updated>2026-10-01T00:00:00Z</updated>
        <category term="kubernetes" label="Kubernetes" />
        <category term="hetzner" label="Hetzner" />
        <summary type="text">Helm charts for the Hetzner Cloud Controller Manager and the Hetzner Cloud CSI driver, published at a time when Hetzner only provided plain manifest files.</summary>
        <content type="html">&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; The Helm charts presented in this article are superseded by the official Helm charts provided by Hetzner,
available from the repository &lt;code&gt;https://charts.hetzner.cloud&lt;/code&gt;
(&lt;a href=&#34;https://github.com/hetznercloud/hcloud-cloud-controller-manager&#34;&gt;hcloud-cloud-controller-manager&lt;/a&gt;,
&lt;a href=&#34;https://github.com/hetznercloud/csi-driver&#34;&gt;hcloud-csi&lt;/a&gt;).
Please use those for new clusters.
For an up-to-date network setup of Kubernetes clusters on Hetzner, see &lt;a href=&#34;https://mlohr.com/blog/2026/09/kubespray-kubernetes-clusters-hetzner/&#34;&gt;Kubernetes Networking on Hetzner with Kubespray&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Recently, Hetzner announced the launch of load balancers for the Hetzner Cloud.
Thanks to this, it is now possible to realize highly available Kubernetes clusters without MetalLB and custom scripting for Floating IP assignment.
For automatic cloud management, Hetzner provides the &lt;a href=&#34;https://github.com/hetznercloud/hcloud-cloud-controller-manager/&#34;&gt;hcloud-cloud-controller-manager&lt;/a&gt;,
which is also able to create and manage these load balancers,
and the &lt;a href=&#34;https://github.com/hetznercloud/csi-driver&#34;&gt;hcloud-csi-driver&lt;/a&gt;, which enables cloud volume integration into your Hetzner Cloud Kubernetes cluster.&lt;/p&gt;
&lt;p&gt;Unfortunately, Hetzner only provides manifest files for installing these into your cluster.
Since, in my opinion, Helm is the most convenient way of installing and managing applications in Kubernetes,
I created Helm charts for both of them.&lt;/p&gt;
&lt;h2&gt;hcloud-cloud-controller-manager&lt;/h2&gt;
&lt;p&gt;The cloud controller manager integrates the Kubernetes cluster with the Hetzner Cloud API:
it initializes nodes with their Hetzner Cloud metadata, removes nodes whose servers have been deleted,
manages routes in Hetzner Cloud networks, and creates Hetzner Cloud Load Balancers for services of type &lt;code&gt;LoadBalancer&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The Helm chart for hcloud-cloud-controller-manager is available at &lt;a href=&#34;https://gitlab.com/MatthiasLohr/hcloud-cloud-controller-manager-helm-chart&#34;&gt;https://gitlab.com/MatthiasLohr/hcloud-cloud-controller-manager-helm-chart&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;hcloud-csi-driver&lt;/h2&gt;
&lt;p&gt;The CSI driver allows Kubernetes to provision Hetzner Cloud volumes as persistent volumes,
and to attach them to the cloud server the pod is scheduled on.&lt;/p&gt;
&lt;p&gt;The Helm chart for hcloud-csi-driver is available at &lt;a href=&#34;https://gitlab.com/MatthiasLohr/hcloud-csi-driver-helm-chart&#34;&gt;https://gitlab.com/MatthiasLohr/hcloud-csi-driver-helm-chart&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Legal Notice&lt;/strong&gt;: I&#39;m not affiliated with Hetzner in any way.
In this blog, I just present my experiences and learnings working with different technologies and providers,
such as Kubernetes clusters in the Hetzner environment.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Kubernetes Cluster on Hetzner Bare Metal Servers</title>
        <id>https://mlohr.com/blog/2020/06/kubernetes-cluster-on-hetzner-bare-metal-servers/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2020/06/kubernetes-cluster-on-hetzner-bare-metal-servers/" />
        <published>2020-06-29T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="kubernetes" label="Kubernetes" />
        <category term="hetzner" label="Hetzner" />
        <summary type="text">How to set up a production-ready, highly available Kubernetes cluster on Hetzner bare metal using vSwitches, MetalLB, and Kubespray.</summary>
        <content type="html">&lt;p&gt;If you want to run your own Kubernetes Cluster, you have plenty of possibilities: You can set up a single node cluster using minikube locally or on a remote machine. You can also set up a multi node cluster on VPS or using managed cloud providers such as AWS or GCE. Alternatively, you can use hardware, e.g. &lt;a href=&#34;https://mlohr.com/blog/2018/09/raspberry-pi-kubernetes-cluster/&#34;&gt;Raspberry Pis&lt;/a&gt; or bare metal servers. However, without the functionality provided by a managed cloud provider, it is difficult to take full advantage of the complete high availability capabilities of Kubernetes. We have tried - and present here the instructions for a highly available Kubernetes cluster on Hetzner bare metal servers.&lt;/p&gt;
&lt;h2&gt;Why Bare Metal?&lt;/h2&gt;
&lt;p&gt;GCE and AWS are very expensive, especially when your cluster is growing. Raspberry Pis are cheap, but also quite limited regarding resources. Usual VPS providers lack support of High Availability, and a single node cluster is per definition a whole Single-Point-of-Failure - nice for testing, but not for production.&lt;/p&gt;
&lt;p&gt;It is also possible to set up a Kubernetes cluster on bare metal. In this case, implementation of High Availability depends on the features the colocation provider is offering. Somehow, you have to create a machine-independend load balancer, which redirects traffic to working nodes and ignores broken nodes.&lt;/p&gt;
&lt;p&gt;Dorian Cantzen (&lt;a href=&#34;https://extrument.com/&#34;&gt;extrument.com&lt;/a&gt;) and I have taken up that challenge. In this article, we will describe the steps to set up a production ready Kubernetes cluster on Hetzner bare metal servers using the Hetzner vSwitch feature.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important notice&lt;/strong&gt;: Neither the author of this article (Matthias Lohr) nor Dorian Cantzen are affiliated with Hetzner nor paid for writing this article. This article is a technical report, initiated by Matthias Lohr, summarizing the findings when trying to find a feasible solution for a highly available Kubernetes cluster on bare metal machines. We found Hetzner as part of one possible solution, most probably there are more eligible providers out there for a similar solution.&lt;/p&gt;
&lt;h2&gt;The High Availability Challenge&lt;/h2&gt;
&lt;p&gt;The basic goal of a server cluster is reliability in terms of high availability and fault-tolerance. If one component of a cluster fails, cluster logic will automatically use another component for the task.&lt;/p&gt;
&lt;p&gt;Generally, you can devide cluster components in three categories: computational resources, storage resources and networking. The core component of Kubernetes is a scheduler for computational loads (Pods), which provide services such as web portals etc. &lt;a href=&#34;https://ceph.io/&#34;&gt;Ceph&lt;/a&gt; clusters or Kubernetes-based solutions like &lt;a href=&#34;https://rook.io/&#34;&gt;Rook&lt;/a&gt; provide redundant storage. High Availability for the remaining part, the networking, requires special support by the colocation provider. Usually, when using mainstream hosters like Hetzner, a server has a single NIC with a single IP (ok, one IPv4 and one IPv6) address. Typically, a DNS record points to one or multiple IP addresses. However, when pointing to multiple IPs, and the server behind this IP is not available, the user will get connection errors. So, DNS can&#39;t help to provide a solution here. What we need is an IP address which can be shared between multiple servers.&lt;/p&gt;
&lt;p&gt;We found that using &lt;em&gt;Hetzner vSwitches&lt;/em&gt;, it is possible to route IPs or IP subnets into a VLAN (&lt;a href=&#34;https://en.wikipedia.org/wiki/IEEE_802.1Q&#34;&gt;IEEE 802.1Q&lt;/a&gt;) where each server can be connected. It’s then up to the servers to decide which one should reply to incoming traffic for this/these IP(s). IP migrations can be done completely within the cluster servers, without notifying an external API.&lt;/p&gt;
&lt;h2&gt;Setting up a Kubernetes Cluster on Hetzner Bare Metal Servers&lt;/h2&gt;
&lt;h3&gt;Create VLAN (Hetzner vSwitch)&lt;/h3&gt;
&lt;p&gt;First, we have to create the VLAN, which connects the servers. You can do that in the Hetzner vSwitch configuration area&lt;/p&gt;
&lt;p&gt;After the vSwitch is created, you have to assign the servers you want to add to your Kubernetes cluster to the vSwitch:&lt;/p&gt;
&lt;div class=&#34;text-center&#34;&gt;
  &lt;img src=&#34;https://mlohr.com/blog/2020/06/kubernetes-cluster-on-hetzner-bare-metal-servers/vSwitch.png&#34; alt=&#34;Hetzner vSwitch configuration&#34; class=&#34;img-fluid&#34;&gt;
&lt;/div&gt;&lt;p&gt;On the &lt;em&gt;IPs&lt;/em&gt; tab, you can order additional IPs or IP subnets, which are routed to the vSwitch and therefore not assigned to a single server. We will use MetalLB to manage these IP address(es).&lt;/p&gt;
&lt;p&gt;Now, you should set up the servers with your favorite OS capable of running Kubernetes. After the standard setup has finished, we need to configure the VLAN and the additional IPs. According to the &lt;a href=&#34;https://wiki.hetzner.de/index.php/Vswitch/en#Server_configuration_.28Linux.29&#34;&gt;official Hetzner documentation&lt;/a&gt;, you have to create a virtual network interface with VLAN taggings. But since you want to use the IPs within the Kubernetes cluster, you have to add some additional ip rules.&lt;/p&gt;
&lt;p&gt;Below you will find an example for a working netplan configuration. This configuration uses 10.233.255.0/24 as internal network range for cluster internal communication and 321.321.321.32/28 as subnet assigned to the vSwitch. 10.233.0.0/18 is the default service IP range used by kubespray, 10.233.64.0/18 the according default Pod IP range.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;network:
  version: 2
  vlans:
    # Configure vSwitch public
    enp4s0.4000:
      id: 4000
      link: enp4s0
      mtu: 1400
      addresses:
        - 10.233.255.1/24
      routes:
        - to: 0.0.0.0/0
          via: 321.321.321.33
          table: 1
          on-link: true
      routing-policy:
        - from: 321.321.321.32/28
          to: 10.233.0.0/18
          table: 254
          priority: 0
        - from: 321.321.321.32/28
          to: 10.233.64.0/18
          table: 254
          priority: 0
        - from: 321.321.321.32/28
          table: 1
          priority: 10
        - to: 321.321.321.32/28
          table: 1
          priority: 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alternatively, you can use our &lt;a href=&#34;https://github.com/MatthiasLohr/ansible-role-hvswitch-k8s&#34;&gt;Hetzner vSwitch ansible role&lt;/a&gt; which we developed during our experiments.&lt;/p&gt;
&lt;p&gt;Test if the VLAN works properly by try to ping all nodes using their private IP addresses (10.233.255.1, 10.233.255.2, ...).&lt;/p&gt;
&lt;h3&gt;Setup Kubernetes&lt;/h3&gt;
&lt;p&gt;Now, since the networking stuff is up and running, you are ready to install Kubernetes. We did that using &lt;a href=&#34;https://www.ansible.org/&#34;&gt;Ansible&lt;/a&gt;/&lt;a href=&#34;https://kubespray.io/&#34;&gt;kubespray&lt;/a&gt;, which offers a quite convenient and production ready solution for managing bare metal Kubernetes clusters. Use the server&#39;s internal IP addresses in your inventory.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# example inventory
[all]
node1 ip=10.233.255.1 etcd_member_name=etcd1
node2 ip=10.233.255.2 etcd_member_name=etcd2
node3 ip=10.233.255.3 etcd_member_name=etcd3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before installing the network plugin, ensure that you set the right MTU for your Kubernetes networking plugin. Hetzner vSwitch interfaces have a MTU of 1400. When using e.g. Calico, which has a 20 bytes overhead, you need to set the MTU for Calico to 1380.&lt;/p&gt;
&lt;p&gt;Install and configure MetalLB to use the IP/subnet assigned to the Hetzner vSwitch. Please ensure, that you do &lt;strong&gt;not&lt;/strong&gt; configure the whole subnet, but exclude the two first and the last IP address.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;subnet assigned: &lt;em&gt;321.321.321.32/28&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;IPs in subnet: &lt;em&gt;321.321.321.32 - 321.321.321.47&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;subnet address (not usable): &lt;em&gt;321.321.321.32&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;subnet gateway address (used by Hetzner): &lt;em&gt;321.321.321.33&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;subnet broadcast address (not usable): &lt;em&gt;321.321.321.47&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;remaining usable IPs/MetalLB range: &lt;em&gt;321.321.321.34 - 321.321.321.46&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That&#39;s it! Now you can start using Kubernetes Services with type &lt;em&gt;LoadBalancer&lt;/em&gt; and one of the usable IP addresses to get traffic into your cluster. MetalLB will care about assigning these IP addresses to working nodes. If one node goes down, MetalLB will reassign the IP address to a working node.&lt;/p&gt;
&lt;p&gt;The only bottleneck we didn&#39;t figure out yet: Does Hetzner have a redundant (highly available) setup for the vSwitches...?&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Maintenance of Long-Living Smart Contracts</title>
        <id>https://mlohr.com/blog/2020/03/maintenance-of-long-living-smart-contracts/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2020/03/maintenance-of-long-living-smart-contracts/" />
        <published>2020-03-02T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="blockchain" label="Blockchain" />
        <summary type="text">Research paper on the tension between Ethereum smart contract immutability and the need for security patches and ongoing software maintenance.</summary>
        <content type="html">&lt;p&gt;Last week at the 7th Collaborative Workshop on Evolution and Maintenance of Long-Living Systems, together with Sven Peldszus, I presented a paper regarding the Maintenance of Long-Living Smart Contracts.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Lohr, M., &amp;amp; Peldszus, S. (2020). Maintenance of long-living smart contracts. CEUR Workshop Proceedings, 2581.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Take-Aways&lt;/h2&gt;
&lt;p&gt;After the presentation, we had a very interesting discussion about Software Security (as everybody desires to have) and Software Immutability (as the Ethereum blockchain promotes). The essence of the discussion was that there is a need of further investigation about this conflict and how it can be solved.&lt;/p&gt;
&lt;p&gt;As outcome of the presentation and the discussion, my best idea would be some kind of smart contract update support provided by Ethereum. For example, this could be done by introducing a successor field when a smart contract selfdestruct method is called. The successor field should then contain the address of the updated, succeeding smart contract.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Gitlab on a Diskstation</title>
        <id>https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/" />
        <published>2019-12-29T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="docker" label="Docker" />
        <category term="gitlab" label="GitLab" />
        <summary type="text">How to install and configure the latest GitLab CE on a Synology NAS using Docker, covering volumes, port bindings, SSL, and reverse proxy setup.</summary>
        <content type="html">&lt;p&gt;Sometimes, regardless of the possibilities offered by &#34;the cloud&#34;, you want to host important services yourself. For me as a software and DevOp engineer, this applies to my source code. For this reason, I host my GitLab instance myself. Since the GitLab package for DSM provided by Synology is outdated, I will explain here how to install the latest version of GitLab on a DiskStation using Docker.&lt;/p&gt;
&lt;h2&gt;Preparations&lt;/h2&gt;
&lt;p&gt;Before we can install GitLab on a DiskStation, we need to make some preparations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install the &lt;a href=&#34;https://www.synology.com/en-global/dsm/packages/Docker&#34;&gt;Docker package&lt;/a&gt; from the &lt;a href=&#34;https://www.synology.com/en-global/knowledgebase/DSM/help/DSM/PkgManApp/PackageCenter_desc&#34;&gt;Synology Package Center&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Create a Shared Folder for GitLab. For this article, I assume that the Shared Folder is named &lt;em&gt;gitlab&lt;/em&gt; and is created on the first volume, so its path on the filesystem will be &lt;em&gt;/volume1/gitlab&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.synology.com/en-global/knowledgebase/DSM/tutorial/General_Setup/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet&#34;&gt;Activate DSM SSH access&lt;/a&gt;. You can also activate Public Key Authentication for DiskStation SSH access &lt;a href=&#34;https://www.synology.com/en-us/knowledgebase/DSM/tutorial/Management/How_to_log_in_to_DSM_with_key_pairs_as_admin_or_root_permission_via_SSH_on_computers&#34;&gt;following this tutorial&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Setup GitLab on a DiskStation&lt;/h2&gt;
&lt;p&gt;Now we are going to install GitLab. To do so, we need to download the latest docker image of GitLab first. Open the Docker App in DSM, select &lt;em&gt;Registry&lt;/em&gt; in the left menu and download the latest &lt;em&gt;gitlab/gitlab-ce&lt;/em&gt; image.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/gitlab-ce.png&#34; alt=&#34;Download GitLab image from Docker Hub&#34;&gt;&lt;/p&gt;
&lt;p&gt;You can watch the download progress in the menu on the left in &lt;em&gt;Image&lt;/em&gt;. As soon as the download is complete, a new container can be created with &lt;em&gt;Launch&lt;/em&gt; in the upper left corner.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/general-settings.png&#34; alt=&#34;Launch GitLab container&#34;&gt;&lt;/p&gt;
&lt;p&gt;Before we click on &lt;em&gt;Next&lt;/em&gt;, we have to do some configuration in the &lt;em&gt;Advanced Settings&lt;/em&gt; dialog.&lt;/p&gt;
&lt;h3&gt;Volumes&lt;/h3&gt;
&lt;p&gt;We need to persist three directories of GitLab:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;/etc/gitlab&lt;/em&gt; - configuration directory&lt;/li&gt;
&lt;li&gt;&lt;em&gt;/var/opt/gitlab&lt;/em&gt; - user-generated content (repositories, database, ...)&lt;/li&gt;
&lt;li&gt;&lt;em&gt;/var/log/gitlab&lt;/em&gt; - logfiles&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We do this by defining directory volumes. This is done by mapping subdirectories of the previously created shared directory into the container by defining a mount path.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/advanced-settings.png&#34; alt=&#34;Configure GitLab volumes&#34;&gt;&lt;/p&gt;
&lt;h3&gt;Port Settings&lt;/h3&gt;
&lt;p&gt;To allow access to the GitLab instance from outside, we need to define port bindings. These port bindings will forward DiskStation host ports to the GitLab Docker container.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/port-bindings.png&#34; alt=&#34;Configure GitLab port bindings&#34;&gt;&lt;/p&gt;
&lt;p&gt;After applying all changes and starting the container, it will take a couple of minutes for GitLab to bootstrap the instance. After a while, you should be able to access your new GitLab instance at &lt;em&gt;http://&lt;NAS IP&gt;:8080&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/gitlab.png&#34; alt=&#34;Access GitLab instance&#34;&gt;&lt;/p&gt;
&lt;p&gt;On this screen, you can set the password for the &lt;em&gt;root&lt;/em&gt; user of GitLab.&lt;/p&gt;
&lt;h2&gt;Final Configuration&lt;/h2&gt;
&lt;p&gt;Before your GitLab instance is ready for usage, we have to finalize the configuration. Use the DSM Docker application for shutting down the container.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/12/gitlab-on-a-diskstation/final-config.png&#34; alt=&#34;Shut down GitLab container&#34;&gt;&lt;/p&gt;
&lt;p&gt;When the container is off, connect to your DiskStation via SSH. Open the GitLab configuration file &lt;em&gt;/volume1/gitlab/config/gitlab.rb&lt;/em&gt; and adjust (at least) the following configuration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;external_url&lt;/em&gt; - Set to the URL you want to use for accessing GitLab.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;gitlab_rails[&#39;gitlab_shell_ssh_port&#39;] = 7999&lt;/em&gt; - Use port 7999 for Git via SSH, since port 22 is already taken by your DiskStation.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Further Considerations&lt;/h3&gt;
&lt;h4&gt;External Access&lt;/h4&gt;
&lt;p&gt;You have to decide how to configure external access to your GitLab instance. The simplest option might be to configure a port forwarding from your router to the GitLab ports on your DiskStation. Another possibility is to use DSM&#39;s reverse proxy.&lt;/p&gt;
&lt;h4&gt;HTTPS Encryption&lt;/h4&gt;
&lt;p&gt;Both, DSM and GitLab have built-in support for requesting LetsEncrypt certificates for HTTPS. If you&#39;re not using DSM&#39;s reverse proxy, you should configure LetsEncrypt in your &lt;em&gt;gitlab.rb&lt;/em&gt; configuration.&lt;/p&gt;
&lt;p&gt;If you&#39;re using the DSM reverse proxy, you can still use GitLab&#39;s capabilities for getting LetsEncrypt certificates or configure HTTPS offloading (the reverse proxy terminates the HTTPS connection and forwards requests internally using HTTP) and use DSM&#39;s capabilities for getting LetsEncrypt certificates. When configuring the hosts, please be sure to enable &lt;a href=&#34;https://mlohr.com/blog/2019/01/websockets-for-synology-dsm/&#34;&gt;Websocket Support&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The End&lt;/h2&gt;
&lt;p&gt;After you finished configuration, use the DSM Docker GUI to start your GitLab container again.&lt;/p&gt;
&lt;p&gt;Congratulation! Your GitLab on a DiskStation is now up and running!&lt;/p&gt;
&lt;p&gt;For questions, don&#39;t hesitate to leave a comment below. For personal support, you can also &lt;a href=&#34;https://mlohr.com/contact-me/&#34;&gt;contact me directly&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>2nd IEE International Conference on Blockchain - Paper on Data Genuineness</title>
        <id>https://mlohr.com/blog/2019/07/2nd-ieee-international-conference-on-blockchain-paper-on-data-genuineness/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/07/2nd-ieee-international-conference-on-blockchain-paper-on-data-genuineness/" />
        <published>2019-07-18T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="blockchain" label="Blockchain" />
        <summary type="text">A brief report from the 2nd IEEE International Conference on Blockchain in Atlanta, where I presented a paper on data genuineness verification.</summary>
        <content type="html">&lt;p&gt;Yesterday I attended the 2nd IEEE International Conference on Blockchain conference in Atlanta, Georgia. Besides many interesting and exciting lectures, I also presented my first paper there:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://mlohr.com/publications/&#34;&gt;https://mlohr.com/publications/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I would also like to say thanks for the interesting conversations I had at the conference.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Check for Docker image updates</title>
        <id>https://mlohr.com/blog/2019/02/check-for-docker-image-updates/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/02/check-for-docker-image-updates/" />
        <published>2019-02-08T00:00:00Z</published>
        <updated>2026-07-06T00:00:00Z</updated>
        <category term="docker" label="Docker" />
        <summary type="text">A bash script to periodically query Docker Hub via cron and check whether newer versions of your running Docker images are available.</summary>
        <content type="html">&lt;p&gt;For certain docker-driven services, I would like to check regularly for new versions. One way to do this is to query Docker Hub via Cronjob. Here I provide a little bash script to check for Docker image updates on Docker Hub.&lt;/p&gt;
&lt;div class=&#34;hll&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;ch&#34;&gt;#!/bin/bash&lt;/span&gt;
&lt;span class=&#34;c1&#34;&gt;# Example usage:&lt;/span&gt;
&lt;span class=&#34;c1&#34;&gt;# ./docker-image-update-check.sh gitlab/gitlab-ce update-gitlab.sh&lt;/span&gt;

&lt;span class=&#34;nv&#34;&gt;IMAGE&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$1&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;
&lt;span class=&#34;nv&#34;&gt;COMMAND&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$2&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;

&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Fetching Docker Hub token...&#34;&lt;/span&gt;
&lt;span class=&#34;nv&#34;&gt;token&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;$(&lt;/span&gt;curl&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;--silent&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;https://auth.docker.io/token?scope=repository:&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$IMAGE&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;:pull&amp;amp;service=registry.docker.io&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;|&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;jq&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-r&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&#39;.token&#39;&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;)&lt;/span&gt;

&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-n&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Fetching remote digest... &#34;&lt;/span&gt;
&lt;span class=&#34;nv&#34;&gt;digest&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;$(&lt;/span&gt;curl&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;--silent&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-H&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Accept: application/vnd.docker.distribution.manifest.v2+json&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;se&#34;&gt;\&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;-H&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Authorization: Bearer &lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$token&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;se&#34;&gt;\&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;https://registry.hub.docker.com/v2/&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$IMAGE&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;/manifests/latest&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;|&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;jq&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-r&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&#39;.config.digest&#39;&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;)&lt;/span&gt;
&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$digest&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;

&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-n&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Fetching local digest...  &#34;&lt;/span&gt;
&lt;span class=&#34;nv&#34;&gt;local_digest&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;$(&lt;/span&gt;docker&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;images&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;-q&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;--no-trunc&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$IMAGE&lt;/span&gt;:latest&lt;span class=&#34;k&#34;&gt;)&lt;/span&gt;
&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$local_digest&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;

&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$digest&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;!&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$local_digest&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;]&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;then&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Update available. Executing update command...&#34;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$COMMAND&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;)&lt;/span&gt;
&lt;span class=&#34;k&#34;&gt;else&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;echo&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&#34;Already up to date. Nothing to do.&#34;&lt;/span&gt;
&lt;span class=&#34;k&#34;&gt;fi&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;em&gt;The script is also available as a &lt;a href=&#34;https://gist.github.com/MatthiasLohr/df1ab0ea4fe97d52b9427adc2086f365&#34;&gt;GitHub Gist&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here is a repository with this and some more scripts: &lt;a href=&#34;https://gitlab.com/MatthiasLohr/omnibus-gitlab-management-scripts&#34;&gt;https://gitlab.com/MatthiasLohr/omnibus-gitlab-management-scripts&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you have questions or problems, &lt;a href=&#34;https://gitlab.com/MatthiasLohr/omnibus-gitlab-management-scripts/-/issues&#34;&gt;please create a ticket here&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>FritzBox LAN 2 LAN VPN with pfSense</title>
        <id>https://mlohr.com/blog/2019/02/fritzbox-lan-2-lan-vpn-with-pfsense/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/02/fritzbox-lan-2-lan-vpn-with-pfsense/" />
        <published>2019-02-04T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <summary type="text">How to set up a FritzBox LAN-to-LAN VPN with pfSense using default FritzBox encryption algorithms — no config file import needed.</summary>
        <content type="html">&lt;p&gt;In &lt;a href=&#34;https://mlohr.com/blog/2019/01/fritzbox-lan-2-lan-vpn-with-strongswan/&#34;&gt;this&lt;/a&gt; article I described how to set up a &lt;a href=&#34;https://mlohr.com/blog/2019/01/fritzbox-lan-2-lan-vpn-with-strongswan/&#34;&gt;FritzBox LAN 2 LAN VPN with StrongSwan&lt;/a&gt;. Meanwhile I replaced Ubuntu on the server with pfSense. Of course I have set up my FritzBOX VPN connections again. So here&#39;s a tutorial on how to set up a FritzBox LAN 2 LAN VPN with pfSense.&lt;/p&gt;
&lt;p&gt;The prerequisites remain the same in comparison to the StrongSwan instructions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Register your FritzBox with a DynDNS service (e.g. &lt;a href=&#34;https://myfritz.net&#34;&gt;https://myfritz.net&lt;/a&gt;) and find your FritzBox domain name (e.g. &lt;em&gt;myfb.myfritz.net&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Find your FritzBox’ private subnet, typically &lt;em&gt;192.168.178.0/24&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Find (or define) the subnet on the remote site, e.g. &lt;em&gt;192.168.42.0/24&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Find the hostname of the remote site, e.g &lt;em&gt;remote.example.com&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Define a secret secret, e.g &lt;em&gt;S3cret123!&lt;/em&gt;(no, please do not use that, that’s my secret secret!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configure your FritzBox&lt;/h2&gt;
&lt;p&gt;Last time I presented a large configuration file that had to be imported into the FritzBox to set up the VPN connection. In the meantime I have found which encryption and hashing algorithms the FritzBox uses by default, so that we can simply use the default settings of the FritzBox and therefore the web interface built into FritzOS 7.x:&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/02/fritzbox-lan-2-lan-vpn-with-pfsense/config-strongswan.png&#34; alt=&#34;FritzBox LAN 2 LAN VPN with pfSense&#34;&gt;&lt;/p&gt;
&lt;p&gt;The pfSense configuration is similarly simple:&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/02/fritzbox-lan-2-lan-vpn-with-pfsense/config-pfsense.png&#34; alt=&#34;pfSense LAN 2 LAN VPN with FritzBox&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://mlohr.com/blog/2019/02/fritzbox-lan-2-lan-vpn-with-pfsense/config-pfsense-2.png&#34; alt=&#34;pfSense LAN 2 LAN VPN with FritzBox Step 2&#34;&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;In my opinion, it&#39;s pretty easy to set up a FritzBox LAN 2 LAN VPN with pfSense. The only hard thing is to figure out the preferred encryption and hashing algorithms supported by the FritzBox.&lt;/p&gt;
&lt;p&gt;I have this running now with pfSense 2.4.4 with both a FritzBox 7490 and a FritzBox 7590.&lt;/p&gt;
&lt;p&gt;An additional note: Sometimes does a Dual Stack connection not seem to be completely stable. In this case it helps to set &lt;em&gt;Internet Protocol&lt;/em&gt; to IPv4 in phase 1.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Ansible Role for tinc VPN</title>
        <id>https://mlohr.com/blog/2019/01/ansible-role-for-tinc-vpn/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/01/ansible-role-for-tinc-vpn/" />
        <published>2019-01-28T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="kubernetes" label="Kubernetes" />
        <summary type="text">An Ansible role for setting up a tinc mesh VPN across Kubernetes cluster nodes on bare metal providers like Hetzner that lack native private networking.</summary>
        <content type="html">&lt;p&gt;When setting up Kubernetes clusters, it makes sense for the individual nodes of Kubernetes to live in the same private network. If Kubernetes is set up on bare metal machines from suppliers such as Hetzner, it may not necessarily be possible to set up a common network of this kind natively. This is where &lt;a href=&#34;https://www.tinc-vpn.org/&#34;&gt;tinc&lt;/a&gt; comes in: it makes it very easy to set up a virtual network across all participating nodes. To keep the configuration of tinc parallel to that of Kubernetes (I use &lt;a href=&#34;https://github.com/kubernetes-sigs/kubespray&#34;&gt;Kubespray&lt;/a&gt; for my Kubernetes setup), I developed an Ansible Role for tinc VPN and made it available on &lt;a href=&#34;https://github.com/MatthiasLohr/ansible-role-tincvpn&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Installing and setting up tinc VPN service&lt;/li&gt;
&lt;li&gt;In-place private key generation (private keys are never copied)&lt;/li&gt;
&lt;li&gt;Support for additional nodes where host machines are not covered by the playbook&lt;/li&gt;
&lt;li&gt;Support for custom routes for the VPN interface&lt;/li&gt;
&lt;li&gt;Support for joining existing bridge interfaces on the host machine&lt;/li&gt;
&lt;li&gt;Custom scripting for up/down hook scripts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For setup instructions or a tutorial how to use my Ansible Role for tinc VPN please check the &lt;a href=&#34;https://github.com/MatthiasLohr/ansible-role-tincvpn/blob/master/README.md&#34;&gt;README&lt;/a&gt;. It always contains the up-to-date instructions for using this role and will be updated, if new features come up.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>FritzBox LAN 2 LAN VPN with StrongSwan</title>
        <id>https://mlohr.com/blog/2019/01/fritzbox-lan-2-lan-vpn-with-strongswan/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/01/fritzbox-lan-2-lan-vpn-with-strongswan/" />
        <published>2019-01-19T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <summary type="text">Working configuration for a FritzBox LAN-to-LAN site-to-site VPN with StrongSwan, including both the FritzBox import config and ipsec.conf.</summary>
        <content type="html">&lt;p&gt;There are a lot of instructions available on how to connect your FritzBox to a server via VPN. But since it took me a long time to find a working tutorial myself, here again a post describing how to set up a FritzBox LAN 2 LAN VPN with StrongSwan (based on the site &lt;a href=&#34;https://seffner-schlesier.de/news/ipsec-zwischen-avm-fritzbox-und-strongswan/&#34;&gt;https://seffner-schlesier.de/news/ipsec-zwischen-avm-fritzbox-und-strongswan/&lt;/a&gt;).&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Register your FritzBox with a DynDNS service (e.g. &lt;a href=&#34;https://myfritz.net&#34;&gt;https://myfritz.net&lt;/a&gt;) and find your FritzBox domain name (e.g. &lt;em&gt;myfb.myfritz.net&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Find your FritzBox’ private subnet, typically &lt;em&gt;192.168.178.0/24&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Find (or define) the subnet on the remote site, e.g. &lt;em&gt;192.168.42.0/24&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Find the hostname of the remote site, e.g &lt;em&gt;remote.example.com&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Define a secret secret, e.g &lt;em&gt;S3cret123!&lt;/em&gt;(no, please do not use that, that’s my secret secret!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configure your FritzBox&lt;/h2&gt;
&lt;p&gt;You can configure FritzBox VPN connections via the web interface, but some parameters seem to be set there, which are not easily accepted on the remote side. Therefore you have to create the following configuration file locally and import it into your FritzBox (replace the example values):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vpncfg {
  connections {
    enabled = yes;
    editable = no;
    conn_type = conntype_lan;
    name = &#34;remote.example.com&#34;;
    boxuser_id = 0;
    always_renew = yes;
    reject_not_encrypted = no;
    dont_filter_netbios = yes;
    localip = 0.0.0.0;
    local_virtualip = 0.0.0.0;
    remoteip = 0.0.0.0;
    remote_virtualip = 0.0.0.0;
    remotehostname = &#34;remote.example.com&#34;;
    keepalive_ip = 0.0.0.0;
    localid {
      fqdn = &#34;myfb.myfritz.net&#34;;
    }
    remoteid {
      fqdn = &#34;remote.example.com&#34;;
    }
    mode = phase1_mode_idp;
    phase1ss = &#34;all/all/all&#34;;
    keytype = connkeytype_pre_shared;
    key = &#34;S3cret123!&#34;;
    cert_do_server_auth = no;
    use_nat_t = yes;
    use_xauth = no;
    use_cfgmode = no;
    phase2localid {
      ipnet {
        ipaddr = 192.168.178.0;
        mask = 255.255.255.0;
      }
    }
    phase2remoteid {
      ipnet {
        ipaddr = 192.168.42.0;
        mask = 255.255.255.0;
      }
    }
    phase2ss = &#34;esp-all-all/ah-none/comp-all/pfs&#34;;
    accesslist = &#34;permit ip any 192.168.42.0 255.255.255.0&#34;;
  }
  ike_forward_rules = &#34;udp 0.0.0.0:500 0.0.0.0:500&#34;,
  &#34;udp 0.0.0.0:4500 0.0.0.0:4500&#34;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Configure StrongSwan&lt;/h2&gt;
&lt;p&gt;/etc/ipsec.conf:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;config setup
conn %default
 left=remote.example.com
 leftsubnet=192.168.42.0/24
 authby=secret
 auto=start

conn fb
 ike=aes256-sha-modp1024
 esp=aes256-sha1-modp1024
 right=myfb.myfritz.net
 rightid=@myfb.myfritz.net
 rightsubnet=192.168.178.0/24
 ikelifetime=3600s
 keylife=3600s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;/etc/ipsec.secrets:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@remote.example.com @myfb.myfritz.net : PSK &#34;S3cret123!&#34;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Software/Hardware versions&lt;/h2&gt;
&lt;p&gt;I have successfully connected a FritzBox 7430 as well as a FritzBox 7590 with FritzOS 7.01.&lt;/p&gt;
&lt;p&gt;On the server side Ubuntu 18.04 is running with StrongSwan 5.6.2.&lt;/p&gt;
&lt;p&gt;I hope you will also successfully set up your FritzBox LAN 2 LAN VPN with StrongSwan! Good luck!&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Websockets for Synology DSM</title>
        <id>https://mlohr.com/blog/2019/01/websockets-for-synology-dsm/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2019/01/websockets-for-synology-dsm/" />
        <published>2019-01-15T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <summary type="text">How to enable WebSocket support in the Synology DSM built-in reverse proxy by adding Upgrade and Connection custom headers to a proxy rule.</summary>
        <content type="html">&lt;p&gt;It&#39;s happened to me several times now that an application I run on my DS 1817+ has problems with websockets. This is because I use the reverse proxy built into DSM, which does not support websockets by default. For this reason, here&#39;s a little tutorial on how to enable Websockets for Synology DSM reverse proxy.&lt;/p&gt;
&lt;h2&gt;Enable Websockets in DSM Reverse Proxy&lt;/h2&gt;
&lt;p&gt;Actually, it is extremely easy to enable Websockets for Synology DSM reverse proxy:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open &lt;em&gt;Control Panel &amp;gt; Application Portal&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Change to the &lt;em&gt;Reverse Proxy&lt;/em&gt; tab&lt;/li&gt;
&lt;li&gt;Select the proxy rule for which you want to enable Websockets and click on &lt;em&gt;Edit&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;Change to the &lt;em&gt;Custom Headers&lt;/em&gt; tab&lt;/li&gt;
&lt;li&gt;Add two entries in the list:&lt;ul&gt;
&lt;li&gt;Name: &#34;Upgrade&#34;, Value: &#34;$http_upgrade&#34;&lt;/li&gt;
&lt;li&gt;Name: &#34;Connection&#34;, Value: &#34;$connection_upgrade&#34;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class=&#34;text-center&#34;&gt;
  &lt;img src=&#34;https://mlohr.com/blog/2019/01/websockets-for-synology-dsm/reverse-proxy-rules.png&#34; alt=&#34;Reverse Procy Rules&#34; class=&#34;img-fluid&#34;&gt;
&lt;/div&gt;&lt;p&gt;Repeat these steps for every rule where you want to enable Websockets.&lt;/p&gt;
&lt;p&gt;In my local setup, I need this for &lt;a href=&#34;https://docs.gitlab.com/omnibus/gitlab-mattermost/&#34;&gt;GitLab Mattermost&lt;/a&gt; (running within a docker container) and &lt;a href=&#34;https://www.synology.com/en-global/knowledgebase/DSM/help/Virtualization/virtual_machine&#34;&gt;DSM Virtual Machine Manager Console&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Signing PGP Keys</title>
        <id>https://mlohr.com/blog/2018/11/signing-pgp-keys/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2018/11/signing-pgp-keys/" />
        <published>2018-11-11T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="gpg" label="GPG" />
        <summary type="text">How to sign PGP keys with GnuPG correctly, including setting certification levels and expiry dates for meaningful and interoperable key signatures.</summary>
        <content type="html">&lt;p&gt;An essential part of PGP is the mutual validation of key pairs. This confirms that the information about the owner stored in the key corresponds to reality (e.g. ownership of this key). This post describes the process of signing PGP keys with GnuPG.&lt;/p&gt;
&lt;p&gt;With GnuPG it is very easy to sign foreign public keys. GnuPG offers a selection of options to configure the creation of the signature. I will introduce the most important ones here.&lt;/p&gt;
&lt;h2&gt;Copy&amp;amp;Paste&lt;/h2&gt;
&lt;p&gt;Here the Copy&amp;amp;Paste command (because people usually just take the first code they see):&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gpg --ask-cert-level --ask-cert-expire --sign-key &amp;lt;fingerprint of key to be signed&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Please read below to understand what you are doing!&lt;/p&gt;
&lt;h2&gt;Simple Signing&lt;/h2&gt;
&lt;p&gt;The fastest way to generate a signature for a key is the following command (&lt;strong&gt;please do not use that!&lt;/strong&gt;):&lt;/p&gt;
&lt;p&gt;This command creates a signature that says nothing about whether and how you verified the identity of the owner of the key. In practical terms, the signature is therefore worthless. In addition, the expiration date of the signature is set to the same date as the key. This is not wrong for now, but there may be cases where you want to explicitly define a different date.&lt;/p&gt;
&lt;h2&gt;Define your key pairs to be used&lt;/h2&gt;
&lt;p&gt;First of all, if you have several keys, it is important to define exactly which key you want to use for the signature. You can do this in GnuPG with the parameter &lt;em&gt;-u &lt;key&gt;&lt;/em&gt;. To avoid mistakes, you should use the fingerprint of the matching key pair.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gpg -u &amp;lt;your key fingerprint&amp;gt; --sign-key &amp;lt;fingerprint of key to be signed&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Define certification level&lt;/h2&gt;
&lt;p&gt;The certification level indicates how it was verified that the key actually belongs to the registered owner (name and e-mail address). The following levels are available:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(0) I will not answer. (default)&lt;/code&gt;
&lt;code&gt;(1) I have not checked at all.&lt;/code&gt;
&lt;code&gt;(2) I have done casual checking.&lt;/code&gt;
&lt;code&gt;(3) I have done very careful checking.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Level 3 describes a thorough check with personal meeting and ID, Level 2 e.g. a check by telephone (if you know the voice). To specify the level of the check, you need to add the --ask-cert-level parameter:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gpg -u &amp;lt;your key fingerprint&amp;gt; --ask-cert-level --sign-key &amp;lt;fingerprint of key to be signed&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Define certification expiry time&lt;/h2&gt;
&lt;p&gt;A PGP key pair can (should) have an expiration date. This ensures that old keys that are no longer in use become unusable. Furthermore, it is possible to change the expiration time of the key pair if necessary, i.e. to extend it (please read &lt;a href=&#34;https://riseup.net/ru/security/message-security/openpgp/gpg-best-practices#use-an-expiration-date-less-than-two-years&#34;&gt;OpenGPG Best Practices&lt;/a&gt; for reasons why you should do that). If you sign a key, the expiration date of the signature is set to the current expiration date of the key. If you now change the expiration date of the key, the date of the signature is not adjusted (a new signature would have to be created for this). In order to save the need for a new signature (and the corresponding effort for validation), the signature can be given a different expiration time than the key when it is created. GnuPG provides the parameter &lt;em&gt;--ask-cert-expire&lt;/em&gt; for this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gpg -u &amp;lt;your key fingerprint&amp;gt; --ask-cert-level --ask-cert-expire --sign-key &amp;lt;fingerprint of key to be signed&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;However, even with signatures, you should consider carefully whether the expiration date is &#34;never&#34; really justified.&lt;/p&gt;
&lt;h2&gt;Get started!&lt;/h2&gt;
&lt;p&gt;Now you can start signing! Meet your friends, acquaintances and colleagues and sign each other your keys.&lt;/p&gt;
&lt;p&gt;By the way, you can find my &lt;a href=&#34;https://mlohr.com/contact-me/&#34;&gt;Publiy Key here&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Docker MTU issues and solutions</title>
        <id>https://mlohr.com/blog/2018/10/docker-mtu/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2018/10/docker-mtu/" />
        <published>2018-10-16T00:00:00Z</published>
        <updated>2026-05-28T00:00:00Z</updated>
        <category term="docker" label="Docker" />
        <summary type="text">How to detect and fix Docker MTU mismatches in cloud and virtualized environments, with separate solutions for the Docker daemon and docker-compose.</summary>
        <content type="html">&lt;p&gt;If you want to use Docker on servers or virtual machines, technical limitations can sometimes lead to a situation in which - even without intentional limitation - it is not possible to access the outer world from a docker container.&lt;/p&gt;
&lt;h2&gt;Docker MTU configuration&lt;/h2&gt;
&lt;p&gt;A common problem when operating dockers within a virtualization infrastructure is that the network cards provided to virtual machines do not have the default &lt;a href=&#34;https://en.wikipedia.org/wiki/Maximum_transmission_unit&#34;&gt;MTU&lt;/a&gt; of 1500. This is often the case, for example, when working in a cloud infrastructure (e.g. OpenStack). The Docker Daemon does not check the MTU of the outgoing connection at startup. Therefore, the value of the Docker MTU is set to 1500.&lt;/p&gt;
&lt;h3&gt;Detecting the problem&lt;/h3&gt;
&lt;p&gt;With the command &lt;em&gt;ip link&lt;/em&gt; you can display the locally configured network cards and their MTU:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1: lo: &amp;lt;LOOPBACK,UP,LOWER_UP&amp;gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
  link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens3: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt; mtu 1454 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
  link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff
3: docker0: &amp;lt;NO-CARRIER,BROADCAST,MULTICAST,UP&amp;gt; mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default
  link/ether uu:vv:ww:xx:yy:zz brd ff:ff:ff:ff:ff:ff
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the outgoing interface (in this case ens3) has an MTU smaller than 1500, some action is required. If it is greater than or equal to 1500, this problem does not apply to you.&lt;/p&gt;
&lt;h3&gt;Solving the problem (docker daemon)&lt;/h3&gt;
&lt;p&gt;To solve the problem, you need to configure the Docker daemon in such a way that the virtual network card of newly created containers gets an MTU that is smaller than or equal to that of the outgoing network card. For this purpose create the file &lt;em&gt;/etc/docker/daemon.json&lt;/em&gt; with the following content:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
 &#34;mtu&#34;: 1454
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this example, I chose 1454 as the value, as this corresponds to the value of the outgoing network card (ens3). After restarting the Docker daemon, the MTU of new containers should be adapted accordingly. However, docker-compose create a new (bridge) network for every docker-compose environment by default.&lt;/p&gt;
&lt;h3&gt;Solving the problem (docker-compose)&lt;/h3&gt;
&lt;p&gt;If you work with docker-compose, you will notice that in containers created by docker-compose, the MTU of the daemon is not inherited. This happens because the &lt;em&gt;mtu&lt;/em&gt; entry in &lt;em&gt;/etc/docker/daemon.json&lt;/em&gt; file only affects the default bridge. Therefore you have to specify the MTU explicitly in the &lt;em&gt;docker-compose.yml&lt;/em&gt; for the newly created network:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...
networks:
 default:
  driver: bridge
  driver_opts:
   com.docker.network.driver.mtu: 1454
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After rebuilding the docker-compose environment (&lt;em&gt;docker-compose down; docker-compose up&lt;/em&gt;), the containers should use the modified MTU.&lt;/p&gt;
&lt;p&gt;I personally don&#39;t like this solution, because the docker-compose files have to be specially adapted to their environment and therefore lose their portability. Unfortunately, I am not aware of any other solution to this problem at the moment.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Raspberry Pi Kubernetes Cluster</title>
        <id>https://mlohr.com/blog/2018/09/raspberry-pi-kubernetes-cluster/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2018/09/raspberry-pi-kubernetes-cluster/" />
        <published>2018-09-25T00:00:00Z</published>
        <updated>2026-06-02T00:00:00Z</updated>
        <category term="kubernetes" label="Kubernetes" />
        <category term="raspberry-pi" label="Raspberry Pi" />
        <summary type="text">Shopping list and setup guide for a cost-effective 4-node Raspberry Pi 3 Kubernetes cluster for learning and experimenting with Kubernetes.</summary>
        <content type="html">&lt;div class=&#34;img-text&#34;&gt;
  &lt;img src=&#34;https://mlohr.com/blog/2018/09/raspberry-pi-kubernetes-cluster/rpicluster.jpg&#34; alt&#34;rasperry-pi&#34;&gt;
  &lt;div&gt;
    In order to gain experience with a &lt;a href=&#34;https://mlohr.com/tags/kubernetes/&#34;&gt;Kubernetes&lt;/a&gt; cluster or to be able to experiment with it, a functioning cluster is required. Since most conceptual challenges do not require a high performance test cluster, it is also sufficient to build a smaller and therefore more cost-effective one. For this reason I decided to set up a &lt;a href=&#34;https://mlohr.com/tags/raspberry-pi/&#34;&gt;Raspberry Pi&lt;/a&gt; Kubernetes Cluster for testing purposes.
  &lt;/div&gt;
&lt;/div&gt;&lt;h2&gt;Shopping List&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;4x &lt;a href=&#34;https://amzn.to/2OST4D0&#34;&gt;Raspberry Pi 3 Model B+&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;4x microSD Card (&lt;a href=&#34;https://amzn.to/2DtASyQ&#34;&gt;I&#39;m using SanDisk Ultra 64GB&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://amzn.to/2DttG5H&#34;&gt;Power supply unit&lt;/a&gt; for the Raspberry Pi devices. You can also use any other 5V power source which provides enough current.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://amzn.to/2OMk338&#34;&gt;4 Micro USB&lt;/a&gt; cables for connecting the power suppy unit&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://amzn.to/2OR69wO&#34;&gt;4 Layer Acrylic Cluster Case&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the Raspberry Pis are not to be connected via WLAN but cable, the corresponding network components are also required:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://amzn.to/2OSgUyG&#34;&gt;5 Port Switch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;4 short ethernet cables&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The website of &lt;a href=&#34;https://blog.hypriot.com/&#34;&gt;Hypriot&lt;/a&gt; has a very good tutorial how to set up a Kubernetes cluster with Raspberry Pi boards: &lt;a href=&#34;https://blog.hypriot.com/post/setup-kubernetes-raspberry-pi-cluster/&#34;&gt;https://blog.hypriot.com/post/setup-kubernetes-raspberry-pi-cluster/&lt;/a&gt;. If you need some configuration examples (executable on a Raspberry Pi Kubernetes Cluster) please check out my GitHub repository with configuration examples: &lt;a href=&#34;https://github.com/MatthiasLohr/kubernetes-rpi-examples&#34;&gt;https://github.com/MatthiasLohr/kubernetes-rpi-examples&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>GPG Agent for SSH in Gnome</title>
        <id>https://mlohr.com/blog/2018/06/gpg-agent-for-ssh-in-gnome/</id>
        <link rel="alternate" type="text/html" href="https://mlohr.com/blog/2018/06/gpg-agent-for-ssh-in-gnome/" />
        <published>2018-06-13T00:00:00Z</published>
        <updated>2026-06-02T00:00:00Z</updated>
        <category term="gpg" label="GPG" />
        <category term="ubuntu" label="Ubuntu" />
        <category term="yubikey" label="YubiKey" />
        <summary type="text">How to replace the Gnome Keyring SSH agent with GPG agent to use a YubiKey or GPG key for SSH authentication on Ubuntu 18.04.</summary>
        <content type="html">&lt;p&gt;In &lt;a href=&#34;https://mlohr.com/blog/2017/08/how-to-set-up-your-yubikey-neo/&#34;&gt;How to set up your YubiKey NEO&lt;/a&gt; I already mentioned that you can also use your &lt;a href=&#34;https://mlohr.com/tags/yubikey/&#34;&gt;YubiKey&lt;/a&gt; as SSH key. In &lt;a href=&#34;https://mlohr.com/blog/2018/06/gpg-agent-forwarding/&#34;&gt;GPG Agent Forwarding&lt;/a&gt; I show how to forward your GPG agent to remote machines for decryption/signing. What&#39;s missing is a tutorial on how to make it all work together, how to use your GPG Agent for SSH in Gnome.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;This manual refers to combining a YubiKey (as GPG smart card) with GPG agent with SSH support as ssh-agent replacement in Ubuntu 18.04 with Gnome. I assume you have already read the article &lt;a href=&#34;https://mlohr.com/blog/2017/08/how-to-set-up-your-yubikey-neo/&#34;&gt;How to set up your YubiKey NEO&lt;/a&gt; and set up your YubiKey (or any other smart card) and generated the SSH keys. I cannot exclude that the manual will also work for other distributions/versions, but I have tested it exclusively for Ubuntu 18.04 with Gnome. Experience reports or problems can be left in the comment field. If anyone has more useful information, I&#39;d be happy to update this article.&lt;/p&gt;
&lt;h2&gt;Setup&lt;/h2&gt;
&lt;p&gt;Actually the configuration is quite simple, but it took me a long time to figure out how it works. Here are the steps to take to use the GPG Agent for SSH in Gnome:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First we have to disable the &lt;a href=&#34;https://wiki.archlinux.org/index.php/GNOME/Keyring&#34;&gt;Gnome keyring&lt;/a&gt;. The problem is that the Gnome keyring itself starts an SSH agent and sets the variable &lt;em&gt;$SSH_AUTH_SOCK&lt;/em&gt; - overwriting the value of already running, other agents. I found the solution to this step here: &lt;a href=&#34;https://wiki.archlinux.org/index.php/GNOME/Keyring#Disable_keyring_daemon_components&#34;&gt;https://wiki.archlinux.org/index.php/GNOME/Keyring#Disable_keyring_daemon_components&lt;/a&gt;. However, deactivating the autostart was enough for me. I didn&#39;t have to set GSM_SKIP_SSH_AGENT_WORKAROUND.
&lt;code&gt;mkdir -p ~/.config/autostart
cp /etc/xdg/autostart/gnome-keyring-ssh.desktop ~/.config/autostart
echo &#34;Hidden=true&#34; &amp;gt;&amp;gt; ~/.config/autostart/gnome-keyring-ssh.desktop&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The next step is to activate the GPG agent:
&lt;code&gt;echo &#34;use-agent&#34; &amp;gt;&amp;gt; ~/.gnupg/gpg.conf&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Next, enable SSH support for the GPG agent:
&lt;code&gt;echo &#34;enable-ssh-support&#34; &amp;gt;&amp;gt; ~/.gnupg/gpg-agent.conf&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then log off (or restart) for security to stop all running services (and agents). Next time you login, $SSH_AUTH_SOCK should point to the GPG agent socket:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo $SSH_AUTH_SOCK
/run/user/1000/gnupg/S.gpg-agent.ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can test if it&#39;s working. Enjoy! :)&lt;/p&gt;
</content>
    </entry>
</feed>
