M-Bus Gateway
← Tilbage til blog
· Cloudflare· Tunnel· IoT· remote access· zero trust· Raspberry Pi· sikkerhed· MQTT· gateway· cloudflared

Cloudflare Tunnel til IoT remote access — on-demand uden åbne porte

Cloudflare Tunnel til sikker IoT remote adgang: on-demand sessions, zero trust, ingen åbne indgående porte, tunnel-daemon på Raspberry Pi, Python session-management og automatisk udløb.

Af M-Bus Gateway

M-Bus Gateway bruger Cloudflare Tunnel til remote tekniker-adgang — ingen åbne indgående porte, on-demand sessions på max 30 minutter. Her er implementeringen.


Hvorfor Cloudflare Tunnel frem for VPN/port forwarding?

Traditionel remote adgang:
  ✗ Port forwarding: Åbner port på router (sikkerhedsrisiko)
  ✗ VPN: Kræver VPN-server + klientinstallation hos tekniker
  ✗ SSH reverse tunnel: Kræver internet-tilgængelig SSH-server

Cloudflare Tunnel:
  ✅ Ingen åbne indgående porte på gateway (Zero Trust)
  ✅ Gateway initierer udgående forbindelse til Cloudflare
  ✅ Tekniker autentificeres via Cloudflare Access (MFA/SSO)
  ✅ Automatisk TLS — ingen certifikat-management
  ✅ Auditlog: Hvem tilgik hvad og hvornår

SIM-budget konsekvens:
  → Cloudflare Tunnel bruger data (estimeret ~5MB/session)
  → 1NCE SIM: 500MB/10 år → max 100 sessioner livstid
  → KRITISK: Tunnel KUN on-demand, MAX 30 minutter
  → Kør ALDRIG persistent Cloudflare Tunnel

cloudflared installation på Pi

# gateway/install.sh (udsnit)

# Download cloudflared binary (ARM64):
CLOUDFLARED_VERSION="2024.12.0"
curl -L "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-arm64" \
  -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared

# Verificér:
cloudflared version

# cloudflared konfigureres MED tunnel token (fra platformen):
# Token hentes ved on-demand session via MQTT-kommando
# INGEN persistent tunnel-konfiguration på Pi

Gateway tunnel.py: On-demand sessions

# gateway/src/tunnel.py
import subprocess
import asyncio
import time
import structlog

logger = structlog.get_logger()

MAX_SESSION_SECONDS = 1800  # 30 minutter
CLOUDFLARED_BIN = "/usr/local/bin/cloudflared"


class TunnelSession:
    def __init__(self, token: str, gateway_id: str):
        self.token = token
        self.gateway_id = gateway_id
        self._process: subprocess.Popen | None = None
        self._started_at: float | None = None

    async def start(self) -> str:
        """Start Cloudflare Tunnel med token. Returnerer public URL."""
        if self._process:
            raise RuntimeError("Tunnel allerede aktiv")

        self._process = subprocess.Popen(
            [
                CLOUDFLARED_BIN, "tunnel",
                "--no-autoupdate",
                "run",
                "--token", self.token,
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )
        self._started_at = time.time()

        logger.info("tunnel_started", gateway_id=self.gateway_id)

        # Planlæg automatisk stop efter MAX_SESSION_SECONDS:
        asyncio.create_task(self._auto_stop())

        return f"https://{self.gateway_id}.tunnel.mbus-gateway.dk"

    async def stop(self) -> None:
        """Stop tunnelen (manuel eller automatisk)."""
        if self._process:
            self._process.terminate()
            try:
                self._process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                self._process.kill()
            self._process = None
            logger.info("tunnel_stopped", gateway_id=self.gateway_id)

    async def _auto_stop(self) -> None:
        """Automatisk stop efter MAX_SESSION_SECONDS."""
        await asyncio.sleep(MAX_SESSION_SECONDS)
        if self._process:
            logger.warning("tunnel_auto_expired", gateway_id=self.gateway_id)
            await self.stop()

    @property
    def elapsed_seconds(self) -> int:
        if not self._started_at:
            return 0
        return int(time.time() - self._started_at)

    @property
    def remaining_seconds(self) -> int:
        return max(0, MAX_SESSION_SECONDS - self.elapsed_seconds)


# Singleton session manager:
_active_session: TunnelSession | None = None

async def handle_tunnel_command(cmd: dict) -> dict:
    """MQTT-kommandohandler for tunnel start/stop."""
    global _active_session

    action = cmd.get("action")

    if action == "start":
        if _active_session and _active_session._process:
            return {"status": "already_active",
                    "remaining": _active_session.remaining_seconds}
        token = cmd.get("token")
        if not token:
            return {"status": "error", "message": "token mangler"}
        _active_session = TunnelSession(token, cmd["gateway_id"])
        url = await _active_session.start()
        return {"status": "started", "url": url,
                "max_seconds": MAX_SESSION_SECONDS}

    elif action == "stop":
        if _active_session:
            await _active_session.stop()
            _active_session = None
        return {"status": "stopped"}

    elif action == "status":
        if _active_session and _active_session._process:
            return {"status": "active",
                    "remaining": _active_session.remaining_seconds}
        return {"status": "inactive"}

Server: Session-management endpoint

# server/src/gateway/router.py

@router.post("/{gateway_id}/tunnel/start")
async def start_tunnel_session(
    gateway_id: str,
    session: AsyncSession = Depends(get_session),
    user: TokenPayload = Depends(require_role("technician", "super_admin")),
):
    """
    Start Cloudflare Tunnel session for gateway.
    Sender MQTT-kommando til Pi → Pi starter cloudflared.
    """
    # Generer kortlivet Cloudflare tunnel token via CF API:
    cf_token = await create_cloudflare_tunnel_token(
        gateway_id=gateway_id,
        expires_seconds=MAX_TUNNEL_SECONDS + 60,  # Lidt buffer
    )

    # Send kommando til gateway via MQTT:
    cmd_payload = {
        "action": "start",
        "token": cf_token,
        "gateway_id": gateway_id,
    }
    await publish_mqtt(
        topic=f"meters/{gateway_id}/cmd",
        payload=cmd_payload,
        qos=1,
    )

    # Log session start (audit):
    await create_audit_log(
        user_id=user.sub,
        tenant_id=user.tenant_id,
        action="tunnel_start",
        after={"gateway_id": gateway_id},
    )

    return {
        "status": "requested",
        "gateway_id": gateway_id,
        "max_seconds": MAX_TUNNEL_SECONDS,
    }

Konklusion

Cloudflare Tunnel giver sikker remote adgang til Pi-gateways uden åbne indgående porte. On-demand sessions på max 30 minutter minimerer SIM-dataforbrug og angrebsflade. Tunnelen startes via MQTT-kommando fra serveren — kun ved eksplicit tekniker-request. Automatisk udløb sikrer at tunnelen aldrig forbliver åben ved glemt stop.

Se IoT netværkssikkerhed guide eller MQTT QoS guide.