· 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.