https://github.com/opencpo/opencpo-bastion
π₯§ Flashable Raspberry Pi site controller β zero-trust bridge, mTLS, sensors, cameras, 4G failover
https://github.com/opencpo/opencpo-bastion
edge-computing iot mtls raspberry-pi site-controller wireguard zero-trust
Last synced: 26 days ago
JSON representation
π₯§ Flashable Raspberry Pi site controller β zero-trust bridge, mTLS, sensors, cameras, 4G failover
- Host: GitHub
- URL: https://github.com/opencpo/opencpo-bastion
- Owner: opencpo
- License: apache-2.0
- Created: 2026-03-29T23:29:58.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-09T23:10:05.000Z (3 months ago)
- Last Synced: 2026-04-10T01:15:00.467Z (3 months ago)
- Topics: edge-computing, iot, mtls, raspberry-pi, site-controller, wireguard, zero-trust
- Language: Python
- Homepage: https://opencpo.io
- Size: 97.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# OpenCPO Bastion
A flashable Raspberry Pi image that turns a Pi into a secure, zero-trust EV charger gateway β bridging local OCPP chargers into the OpenCPO mesh network without touching the chargers themselves.
---
## The Problem
EV chargers speak OCPP (WebSocket). They can't run Tailscale, WireGuard, or mTLS themselves β they just connect to a URL and talk. But you don't want charger WebSockets exposed raw to the internet.
OpenCPO Bastion solves this by sitting between your chargers and the cloud:
- Chargers connect to the Pi on the local network (just a WebSocket URL)
- The Pi holds the mTLS certificates, joins the zero-trust mesh, and forwards everything securely
- Your chargers never need to know any of this exists
---
## Architecture
```
LOCAL NETWORK MESH / INTERNET
βββββββββββββββ OCPP WS ββββββββββββββββββββ WireGuard βββββββββββββββββββ
β EV Charger β ββββββββββββΊ β β βββββββββββββββΊ β β
β (any OCPP) β β Pi Gateway β (Tailscale) β OpenCPO Core β
βββββββββββββββ β β β β
β β’ OCPP Proxy β mTLS cert β β’ OCPP Backend β
βββββββββββββββ OCPP WS β β’ Key Vault β βββββββββββββββ β β’ PKI β
β EV Charger β ββββββββββββΊ β β’ Discovery β β β’ API β
β (any OCPP) β β β’ Monitor β β β
βββββββββββββββ β β’ Tap / Diag β βββββββββββββββββββ
ββββββββββββββββββββ
β
βΌ
Tailscale-only:
:9090 Prometheus
:8085 Message tap / SSE
:8086 Troubleshoot API
```
**Data flow:**
1. Charger connects to `ws://gateway-ip:9100` (OCPP 1.6) or `:9201` (OCPP 2.0.1)
2. Gateway looks up the upstream Core URL from config
3. Opens a mTLS-authenticated WebSocket to Core via Tailscale IP
4. Proxies all frames bidirectionally, logging to ring buffer
5. Tap + troubleshoot endpoints available to admins over Tailscale only
---
## First Boot Flow
```
1. Flash opencpo-bastion.img.gz to SD card (Balena Etcher or rpi-imager)
2. Mount the boot partition (FAT32, visible on any OS)
3. Copy your opencpo.yaml onto the boot partition
4. Eject and insert SD card into Pi, power on
5. First-boot.sh runs automatically:
- Reads opencpo.yaml
- Joins Tailscale mesh (auth key from config)
- Generates device keypair
- Requests signed cert from Core PKI
- Starts all gateway services
- Removes auth key from config (security)
6. Pi appears in your Tailscale admin panel as "opencpo-gw-"
7. Chargers on the local network can now connect
```
---
## Hardware Requirements
| Component | Minimum | Recommended | HA Pair |
|-----------|---------|-------------|---------|
| Board | Raspberry Pi Zero 2W | Raspberry Pi 4 (2GB+) | NanoPi R5S/R6S, Zimaboard, CM4 + dual-ETH carrier |
| RAM | 512MB | 2GB+ | 2GB+ each |
| Storage | SD card 8GB | SD card 16GB+ (A1/A2 rated) | 16GB+ each |
| Network | Wi-Fi (Zero 2W) | Ethernet (Pi 4) | **2x Ethernet** (dual-NIC SBC) |
| Power | 5V 2.5A | 5V 3A (USB-C on Pi 4) | 5V 3A each |
**Notes:**
- Ethernet strongly recommended for charger connectivity (reliability + latency)
- Pi 4 preferred for sites with many chargers (>4 simultaneous connections)
- Pi Zero 2W works well for small sites (1-3 chargers)
- Optional: Waveshare TPM HAT for hardware-backed key storage
- Read-only rootfs: SD card wear is minimal, but quality card still recommended
### Dual Ethernet (HA / Production)
For HA pairs and production sites, use an SBC with two Ethernet ports:
| Board | ETH ports | Notes |
|-------|-----------|-------|
| **NanoPi R5S** | 1Γ 2.5GbE + 2Γ 1GbE | Best all-around. RK3566, 4GB RAM. ~$60 |
| **NanoPi R6S** | 1Γ 2.5GbE + 2Γ 1GbE | Faster RK3588S, 8GB RAM. ~$80 |
| **Zimaboard 832** | 2Γ Intel i226 1GbE | x86-64, PCIe slot for 4G mPCIe card. ~$100 |
| **CM4 + dual-ETH carrier** | 2Γ 1GbE | Waveshare or similar CM4 IO board |
| **Raspberry Pi 5** | 1Γ GbE + USB-ETH | Works; USB-ETH is fine for charger LAN |
**Interface assignment (dual-NIC):**
```
ETH0 β WAN / uplink (Tailscale, Core API, internet β metric 100)
ETH1 β Charger LAN (isolated, DHCP server, VRRP virtual IP)
```
Single-ETH boards work fine in standalone mode. Dual-ETH is required for HA pairs.
---
## Components
| Module | Description |
|--------|-------------|
| `gateway/proxy.py` | OCPP WebSocket proxy β listens locally, forwards to Core via mTLS |
| `gateway/keyvault.py` | Certificate vault β device cert lifecycle, TPM support, auto-renewal |
| `gateway/discovery.py` | Charger discovery β mDNS, ARP scan, reports to Core API |
| `gateway/monitor.py` | Health monitoring β Prometheus metrics, heartbeat to Core, alerts |
| `gateway/tap.py` | OCPP message tap β ring buffer, SSE stream, query/export |
| `gateway/troubleshoot.py` | Remote diagnostics β network, chargers, speedtest, packet capture |
| `gateway/ha.py` | **High Availability** β peer discovery, VRRP/keepalived, state replication, failover |
| `gateway/connectivity.py` | **Multi-WAN** β 4G failover via ModemManager, bandwidth-aware mode |
| `gateway/config.py` | Config management β loads opencpo.yaml, env overrides, validation |
| `gateway/updater.py` | Auto-update β checks Core for updates, verifies, rolls back if broken |
| `gateway/main.py` | Entry point β asyncio orchestration, watchdog, graceful shutdown |
| `image/build.sh` | Pi image build script β pi-gen based, produces flashable .img.gz |
| `image/first-boot.sh` | First boot provisioning β Tailscale join, cert request, service start |
| `systemd/` | Systemd service + timer units for all components |
| `config/` | Example config, firewall rules, kernel hardening |
---
## Quick Start (Development)
```bash
# Clone and set up
git clone https://github.com/opencpo/opencpo-bastion
cd opencpo-bastion
# Install dependencies
pip install -r requirements.txt
# Copy and edit config
cp config/opencpo.yaml.example opencpo.yaml
# Edit: set core_api_url, tailscale_auth_key
# Run locally (no Pi hardware needed)
make dev
# Lint and test
make lint
make test
```
---
## Building the Pi Image
```bash
# Requires Docker
make build
# Output: image/opencpo-bastion-.img.gz
# Flash with Balena Etcher or:
gunzip -c image/opencpo-bastion-*.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
```
---
## Security Model
- **No SSH on LAN** β management is Tailscale-only
- **mTLS everywhere** β all Core communication uses device-specific client certs
- **Certs never leave the device** β private keys encrypted at rest, TPM-backed when available
- **Read-only rootfs** β overlayfs protects against SD corruption and tampering
- **OCPP proxy only** on 0.0.0.0 β all other endpoints bind to Tailscale IP only
- **Firewall** β iptables blocks everything except OCPP from local subnet, Tailscale for management
- **Auth key rotation** β Tailscale auth key removed from config after successful join
---
## Deployment Modes
### Standalone (default)
One gateway unit. No HA config needed. ETH0 for charger LAN or uplink, works on any Pi.
```yaml
# opencpo.yaml β minimum config
tailscale_auth_key: tskey-...
core_api_url: https://core.example.com
# ha.enabled defaults to "auto" β no peer found β standalone mode
```
### HA Pair (zero-config auto-discovery)
Two identical units. Flash the same image to both. They find each other automatically.
```yaml
# opencpo.yaml β same on both units (truly identical)
tailscale_auth_key: tskey-...
core_api_url: https://core.example.com
ha:
enabled: auto # default β find peer, negotiate roles
interface: eth1 # charger LAN interface
virtual_ip: "" # auto-derived from eth1 subnet
```
Boot both units. Within 10 seconds they discover each other via UDP broadcast, negotiate
active/standby roles (tunnel health β VRRP priority β hostname tiebreak), start keepalived,
and chargers connect to the shared VIP. Failover happens in <3 seconds.
### HA Pair (explicit roles)
Use when you want deterministic role assignment (e.g. unit A is always primary):
```yaml
# Unit A β opencpo-A.yaml
ha:
enabled: true
role: primary # always wants to be active
priority: 150
interface: eth1
virtual_ip: 192.168.10.100
# Unit B β opencpo-B.yaml
ha:
enabled: true
role: secondary # always standby unless A is down
priority: 100
interface: eth1
virtual_ip: 192.168.10.100
```
### API endpoints (HA)
- `GET /ha/status` β role, peer status, VIP owner, last sync, replication lag
- `POST /ha/failover` β graceful handoff (for planned maintenance / updates)
---
## 4G Failover
Plug in any supported USB 4G dongle or mPCIe modem. It's auto-detected on boot via ModemManager.
No config needed for most carriers (APN `internet` is the universal default).
```
Primary down? β 3 ping failures β switch to 4G β alert Core β bandwidth-aware mode
Primary back? β 3 ping successes β switch back β alert Core β normal mode
```
**Bandwidth-aware mode** (automatic when on 4G):
- CCTV: snapshot-only or reduced quality (no continuous MJPEG)
- Sensor sync: interval increased from 30s β 120s
- OCPP tap: only critical events forwarded
- HA state replication: continues normally (small payloads β <1KB per sync)
### Supported modems
| Modem | Interface | Notes |
|-------|-----------|-------|
| Huawei E3372 (HiLink) | `usb0` / `eth2` | Appears as USB Ethernet β no mmcli needed |
| Huawei E3372 (Stick) | `wwan0` | usb_modeswitch handles HiLinkβStick |
| Sierra Wireless MC7455 | `wwan0` | mPCIe β ideal for Zimaboard |
| Quectel EC25 / EC21 | `wwan0` | USB β widely available |
| Any ModemManager device | `wwan0` | If `mmcli -L` shows it, it works |
### SIM setup
1. Insert SIM into modem before powering on
2. If SIM has a PIN: set `connectivity.failover.pin` in config
3. Set APN if carrier doesn't use `internet`: `connectivity.failover.apn: your.apn`
4. That's it β ModemManager handles the rest
### Config
```yaml
connectivity:
primary:
interface: eth0
check_interval: 30 # ping Core every N seconds
check_target: "" # auto: Core API hostname. Or set explicit IP/host
failure_threshold: 3 # failures before failover activates
failover:
enabled: auto # "auto" (detect modem), "true", "false"
interface: wwan0 # or "usb0" for HiLink-mode Huawei
apn: internet
pin: "" # SIM PIN (leave blank if none)
bandwidth_mode: true # reduce non-essential traffic on 4G
max_monthly_gb: 5 # alert at 90% of this limit
wifi:
enabled: false # tertiary option β WiFi as backup
ssid: ""
password: ""
```
### API
- `GET /diag/connectivity` β mode, primary status, modem info, signal, carrier, data usage, last failover time
---
## Configuration Reference
See `config/opencpo.yaml.example` for full documentation.
**Required:**
- `tailscale_auth_key` β one-time Tailscale auth key (removed after first boot)
- `core_api_url` β your OpenCPO Core URL (e.g. `https://core.example.com`)
**Optional:**
- `proxy_ports.ocpp16` β OCPP 1.6 listen port (default: 9100)
- `proxy_ports.ocpp201` β OCPP 2.0.1 listen port (default: 9201)
- `log_level` β debug/info/warning/error (default: info)
- `metrics_port` β Prometheus port (default: 9090)
- `auto_update` β enable/disable auto-update (default: true)
- `update_time` β cron-style update schedule (default: "03:00")
- `ha.*` β see [HA Pair](#ha-pair-zero-config-auto-discovery) section above
- `connectivity.*` β see [4G Failover](#4g-failover) section above
---
## Site Intelligence
One Pi does more than networking. The gateway turns each charging site into a fully-instrumented, monitored location β all data flowing through the same zero-trust tunnel.
```
ONE PI β ONE TUNNEL
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenCPO Bastion β
β β
β π OCPP Proxy β chargers β core β
β π‘ BME280 β enclosure temp, humidity, pressureβ
β β‘ CT Clamp β real power draw (independent) β
β πͺ Reed Switch β enclosure tamper detection β
β π§ Flood Sensor β water ingress β
β π MEMS Mic β ambient dB (fan failure, arcing) β
β π‘ TSL2591 β ambient light / site lighting β
β π· UniFi Cameras β video, motion, LPR, face events β
β β
β All management over Tailscale β nothing on WAN β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
### Sensor Array
Plug-and-play I2C/GPIO sensors auto-detected on boot. No configuration needed if using default I2C addresses and GPIO pins.
| Sensor | Interface | What it tells you |
|--------|-----------|-------------------|
| BME280 | I2C 0x76/0x77 | Enclosure temperature, humidity, pressure |
| ADS1115 + SCT-013 | I2C 0x48 | True power draw at supply β independent check on charger reports |
| Reed switch | GPIO 17 | Enclosure door open / tamper detected |
| Flood sensor | GPIO 27 | Water ingress β critical for outdoor/underground installs |
| SPH0645 (I2S mic) | I2S | Ambient dB level only β detects fan failure, arcing, abnormal noise. **No audio recording.** |
| TSL2591 | I2C 0x29 | Ambient light β site lighting status, day/night detection |
All sensors publish to Prometheus and a 24-hour ring buffer (1-min resolution).
**API:**
- `GET /sensors` β current readings for all detected sensors
- `GET /sensors/{id}/history` β 24h time series for a single sensor
- `GET /diag/sensors` β raw hardware scan: I2C addresses found, which sensors detected
### CCTV Integration
Opt-in (`cctv.enabled: true`). Cameras stay on the local LAN β only the admin-panel-bound stream proxies through the tunnel.
**UniFi Protect** is the primary target. The `uiprotect` library (same as Home Assistant) handles auth, WebSocket, and reconnection.
**Smart detection events** from Protect AI are the real value:
| Feature | What it does |
|---------|-------------|
| **License Plate Recognition** | Reads plates from Protect β sends to Core β Core matches plate to fleet vehicle β auto-authorize charging session |
| **Motion / Person / Vehicle** | Real-time events forwarded to Core API + SSE stream |
| **Face Recognition** | Known faces logged as authorized access; unknown faces alert admin during configured hours |
| **ONVIF fallback** | Any standards-compliant camera works if UniFi isn't present |
**API:**
- `GET /cctv` β list discovered cameras with status
- `GET /cctv/{id}/snapshot` β JPEG frame on demand
- `GET /cctv/{id}/stream` β MJPEG stream for dashboard embedding
- `POST /cctv/{id}/ptz` β pan/tilt/zoom (if camera supports it)
- `GET /events` β SSE stream of smart detection events
- `GET /lpr/recent` β recent plate reads with thumbnails
- `GET /lpr/search?plate=XX-1` β search plate history
- `GET /faces/recent` β recent face events with known/unknown status
Local recording uses a circular buffer on the SD card or USB drive (configurable retention and size cap).
---
## License
Apache 2.0 β see LICENSE