An open API service indexing awesome lists of open source software.

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

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