{"id":50516273,"url":"https://github.com/opencpo/opencpo-bastion","last_synced_at":"2026-06-03T00:04:26.317Z","repository":{"id":347899688,"uuid":"1195655435","full_name":"opencpo/opencpo-bastion","owner":"opencpo","description":"🥧 Flashable Raspberry Pi site controller — zero-trust bridge, mTLS, sensors, cameras, 4G failover","archived":false,"fork":false,"pushed_at":"2026-04-09T23:10:05.000Z","size":100,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-10T01:15:00.467Z","etag":null,"topics":["edge-computing","iot","mtls","raspberry-pi","site-controller","wireguard","zero-trust"],"latest_commit_sha":null,"homepage":"https://opencpo.io","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/opencpo.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-29T23:29:58.000Z","updated_at":"2026-04-09T23:14:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/opencpo/opencpo-bastion","commit_stats":null,"previous_names":["opencpo/opencpo-bastion"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/opencpo/opencpo-bastion","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/opencpo%2Fopencpo-bastion","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/opencpo%2Fopencpo-bastion/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/opencpo%2Fopencpo-bastion/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/opencpo%2Fopencpo-bastion/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/opencpo","download_url":"https://codeload.github.com/opencpo/opencpo-bastion/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/opencpo%2Fopencpo-bastion/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33842020,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["edge-computing","iot","mtls","raspberry-pi","site-controller","wireguard","zero-trust"],"created_at":"2026-06-03T00:04:24.942Z","updated_at":"2026-06-03T00:04:26.310Z","avatar_url":"https://github.com/opencpo.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# OpenCPO Bastion\n\nA 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.\n\n---\n\n## The Problem\n\nEV 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.\n\nOpenCPO Bastion solves this by sitting between your chargers and the cloud:\n\n- Chargers connect to the Pi on the local network (just a WebSocket URL)\n- The Pi holds the mTLS certificates, joins the zero-trust mesh, and forwards everything securely\n- Your chargers never need to know any of this exists\n\n---\n\n## Architecture\n\n```\n                     LOCAL NETWORK                          MESH / INTERNET\n                                                     \n  ┌─────────────┐   OCPP WS    ┌──────────────────┐    WireGuard     ┌─────────────────┐\n  │  EV Charger │ ───────────► │                  │ ──────────────►  │                 │\n  │  (any OCPP) │              │  Pi Gateway      │    (Tailscale)   │  OpenCPO Core   │\n  └─────────────┘              │                  │                  │                 │\n                               │  • OCPP Proxy    │    mTLS cert     │  • OCPP Backend │\n  ┌─────────────┐   OCPP WS    │  • Key Vault     │ ◄──────────────  │  • PKI          │\n  │  EV Charger │ ───────────► │  • Discovery     │                  │  • API          │\n  │  (any OCPP) │              │  • Monitor       │                  │                 │\n  └─────────────┘              │  • Tap / Diag    │                  └─────────────────┘\n                               └──────────────────┘\n                                      │\n                                      ▼\n                               Tailscale-only:\n                               :9090  Prometheus\n                               :8085  Message tap / SSE\n                               :8086  Troubleshoot API\n```\n\n**Data flow:**\n1. Charger connects to `ws://gateway-ip:9100` (OCPP 1.6) or `:9201` (OCPP 2.0.1)\n2. Gateway looks up the upstream Core URL from config\n3. Opens a mTLS-authenticated WebSocket to Core via Tailscale IP\n4. Proxies all frames bidirectionally, logging to ring buffer\n5. Tap + troubleshoot endpoints available to admins over Tailscale only\n\n---\n\n## First Boot Flow\n\n```\n1. Flash opencpo-bastion.img.gz to SD card (Balena Etcher or rpi-imager)\n2. Mount the boot partition (FAT32, visible on any OS)\n3. Copy your opencpo.yaml onto the boot partition\n4. Eject and insert SD card into Pi, power on\n5. First-boot.sh runs automatically:\n   - Reads opencpo.yaml\n   - Joins Tailscale mesh (auth key from config)\n   - Generates device keypair\n   - Requests signed cert from Core PKI\n   - Starts all gateway services\n   - Removes auth key from config (security)\n6. Pi appears in your Tailscale admin panel as \"opencpo-gw-\u003chostname\u003e\"\n7. Chargers on the local network can now connect\n```\n\n---\n\n## Hardware Requirements\n\n| Component | Minimum | Recommended | HA Pair |\n|-----------|---------|-------------|---------|\n| Board | Raspberry Pi Zero 2W | Raspberry Pi 4 (2GB+) | NanoPi R5S/R6S, Zimaboard, CM4 + dual-ETH carrier |\n| RAM | 512MB | 2GB+ | 2GB+ each |\n| Storage | SD card 8GB | SD card 16GB+ (A1/A2 rated) | 16GB+ each |\n| Network | Wi-Fi (Zero 2W) | Ethernet (Pi 4) | **2x Ethernet** (dual-NIC SBC) |\n| Power | 5V 2.5A | 5V 3A (USB-C on Pi 4) | 5V 3A each |\n\n**Notes:**\n- Ethernet strongly recommended for charger connectivity (reliability + latency)\n- Pi 4 preferred for sites with many chargers (\u003e4 simultaneous connections)\n- Pi Zero 2W works well for small sites (1-3 chargers)\n- Optional: Waveshare TPM HAT for hardware-backed key storage\n- Read-only rootfs: SD card wear is minimal, but quality card still recommended\n\n### Dual Ethernet (HA / Production)\n\nFor HA pairs and production sites, use an SBC with two Ethernet ports:\n\n| Board | ETH ports | Notes |\n|-------|-----------|-------|\n| **NanoPi R5S** | 1× 2.5GbE + 2× 1GbE | Best all-around. RK3566, 4GB RAM. ~$60 |\n| **NanoPi R6S** | 1× 2.5GbE + 2× 1GbE | Faster RK3588S, 8GB RAM. ~$80 |\n| **Zimaboard 832** | 2× Intel i226 1GbE | x86-64, PCIe slot for 4G mPCIe card. ~$100 |\n| **CM4 + dual-ETH carrier** | 2× 1GbE | Waveshare or similar CM4 IO board |\n| **Raspberry Pi 5** | 1× GbE + USB-ETH | Works; USB-ETH is fine for charger LAN |\n\n**Interface assignment (dual-NIC):**\n```\nETH0 → WAN / uplink  (Tailscale, Core API, internet — metric 100)\nETH1 → Charger LAN  (isolated, DHCP server, VRRP virtual IP)\n```\n\nSingle-ETH boards work fine in standalone mode. Dual-ETH is required for HA pairs.\n\n---\n\n## Components\n\n| Module | Description |\n|--------|-------------|\n| `gateway/proxy.py` | OCPP WebSocket proxy — listens locally, forwards to Core via mTLS |\n| `gateway/keyvault.py` | Certificate vault — device cert lifecycle, TPM support, auto-renewal |\n| `gateway/discovery.py` | Charger discovery — mDNS, ARP scan, reports to Core API |\n| `gateway/monitor.py` | Health monitoring — Prometheus metrics, heartbeat to Core, alerts |\n| `gateway/tap.py` | OCPP message tap — ring buffer, SSE stream, query/export |\n| `gateway/troubleshoot.py` | Remote diagnostics — network, chargers, speedtest, packet capture |\n| `gateway/ha.py` | **High Availability** — peer discovery, VRRP/keepalived, state replication, failover |\n| `gateway/connectivity.py` | **Multi-WAN** — 4G failover via ModemManager, bandwidth-aware mode |\n| `gateway/config.py` | Config management — loads opencpo.yaml, env overrides, validation |\n| `gateway/updater.py` | Auto-update — checks Core for updates, verifies, rolls back if broken |\n| `gateway/main.py` | Entry point — asyncio orchestration, watchdog, graceful shutdown |\n| `image/build.sh` | Pi image build script — pi-gen based, produces flashable .img.gz |\n| `image/first-boot.sh` | First boot provisioning — Tailscale join, cert request, service start |\n| `systemd/` | Systemd service + timer units for all components |\n| `config/` | Example config, firewall rules, kernel hardening |\n\n---\n\n## Quick Start (Development)\n\n```bash\n# Clone and set up\ngit clone https://github.com/opencpo/opencpo-bastion\ncd opencpo-bastion\n\n# Install dependencies\npip install -r requirements.txt\n\n# Copy and edit config\ncp config/opencpo.yaml.example opencpo.yaml\n# Edit: set core_api_url, tailscale_auth_key\n\n# Run locally (no Pi hardware needed)\nmake dev\n\n# Lint and test\nmake lint\nmake test\n```\n\n---\n\n## Building the Pi Image\n\n```bash\n# Requires Docker\nmake build\n# Output: image/opencpo-bastion-\u003cdate\u003e.img.gz\n\n# Flash with Balena Etcher or:\ngunzip -c image/opencpo-bastion-*.img.gz | sudo dd of=/dev/sdX bs=4M status=progress\n```\n\n---\n\n## Security Model\n\n- **No SSH on LAN** — management is Tailscale-only\n- **mTLS everywhere** — all Core communication uses device-specific client certs\n- **Certs never leave the device** — private keys encrypted at rest, TPM-backed when available\n- **Read-only rootfs** — overlayfs protects against SD corruption and tampering\n- **OCPP proxy only** on 0.0.0.0 — all other endpoints bind to Tailscale IP only\n- **Firewall** — iptables blocks everything except OCPP from local subnet, Tailscale for management\n- **Auth key rotation** — Tailscale auth key removed from config after successful join\n\n---\n\n## Deployment Modes\n\n### Standalone (default)\n\nOne gateway unit. No HA config needed. ETH0 for charger LAN or uplink, works on any Pi.\n\n```yaml\n# opencpo.yaml — minimum config\ntailscale_auth_key: tskey-...\ncore_api_url: https://core.example.com\n# ha.enabled defaults to \"auto\" — no peer found → standalone mode\n```\n\n### HA Pair (zero-config auto-discovery)\n\nTwo identical units. Flash the same image to both. They find each other automatically.\n\n```yaml\n# opencpo.yaml — same on both units (truly identical)\ntailscale_auth_key: tskey-...\ncore_api_url: https://core.example.com\n\nha:\n  enabled: auto          # default — find peer, negotiate roles\n  interface: eth1        # charger LAN interface\n  virtual_ip: \"\"         # auto-derived from eth1 subnet\n```\n\nBoot both units. Within 10 seconds they discover each other via UDP broadcast, negotiate\nactive/standby roles (tunnel health → VRRP priority → hostname tiebreak), start keepalived,\nand chargers connect to the shared VIP. Failover happens in \u003c3 seconds.\n\n### HA Pair (explicit roles)\n\nUse when you want deterministic role assignment (e.g. unit A is always primary):\n\n```yaml\n# Unit A — opencpo-A.yaml\nha:\n  enabled: true\n  role: primary          # always wants to be active\n  priority: 150\n  interface: eth1\n  virtual_ip: 192.168.10.100\n\n# Unit B — opencpo-B.yaml\nha:\n  enabled: true\n  role: secondary        # always standby unless A is down\n  priority: 100\n  interface: eth1\n  virtual_ip: 192.168.10.100\n```\n\n### API endpoints (HA)\n\n- `GET /ha/status` — role, peer status, VIP owner, last sync, replication lag\n- `POST /ha/failover` — graceful handoff (for planned maintenance / updates)\n\n---\n\n## 4G Failover\n\nPlug in any supported USB 4G dongle or mPCIe modem. It's auto-detected on boot via ModemManager.\nNo config needed for most carriers (APN `internet` is the universal default).\n\n```\nPrimary down?  →  3 ping failures  →  switch to 4G  →  alert Core  →  bandwidth-aware mode\nPrimary back?  →  3 ping successes →  switch back   →  alert Core  →  normal mode\n```\n\n**Bandwidth-aware mode** (automatic when on 4G):\n- CCTV: snapshot-only or reduced quality (no continuous MJPEG)\n- Sensor sync: interval increased from 30s → 120s\n- OCPP tap: only critical events forwarded\n- HA state replication: continues normally (small payloads — \u003c1KB per sync)\n\n### Supported modems\n\n| Modem | Interface | Notes |\n|-------|-----------|-------|\n| Huawei E3372 (HiLink) | `usb0` / `eth2` | Appears as USB Ethernet — no mmcli needed |\n| Huawei E3372 (Stick) | `wwan0` | usb_modeswitch handles HiLink→Stick |\n| Sierra Wireless MC7455 | `wwan0` | mPCIe — ideal for Zimaboard |\n| Quectel EC25 / EC21 | `wwan0` | USB — widely available |\n| Any ModemManager device | `wwan0` | If `mmcli -L` shows it, it works |\n\n### SIM setup\n\n1. Insert SIM into modem before powering on\n2. If SIM has a PIN: set `connectivity.failover.pin` in config\n3. Set APN if carrier doesn't use `internet`: `connectivity.failover.apn: your.apn`\n4. That's it — ModemManager handles the rest\n\n### Config\n\n```yaml\nconnectivity:\n  primary:\n    interface: eth0\n    check_interval: 30       # ping Core every N seconds\n    check_target: \"\"         # auto: Core API hostname. Or set explicit IP/host\n    failure_threshold: 3     # failures before failover activates\n  failover:\n    enabled: auto            # \"auto\" (detect modem), \"true\", \"false\"\n    interface: wwan0         # or \"usb0\" for HiLink-mode Huawei\n    apn: internet\n    pin: \"\"                  # SIM PIN (leave blank if none)\n    bandwidth_mode: true     # reduce non-essential traffic on 4G\n    max_monthly_gb: 5        # alert at 90% of this limit\n  wifi:\n    enabled: false           # tertiary option — WiFi as backup\n    ssid: \"\"\n    password: \"\"\n```\n\n### API\n\n- `GET /diag/connectivity` — mode, primary status, modem info, signal, carrier, data usage, last failover time\n\n---\n\n## Configuration Reference\n\nSee `config/opencpo.yaml.example` for full documentation.\n\n**Required:**\n- `tailscale_auth_key` — one-time Tailscale auth key (removed after first boot)\n- `core_api_url` — your OpenCPO Core URL (e.g. `https://core.example.com`)\n\n**Optional:**\n- `proxy_ports.ocpp16` — OCPP 1.6 listen port (default: 9100)\n- `proxy_ports.ocpp201` — OCPP 2.0.1 listen port (default: 9201)\n- `log_level` — debug/info/warning/error (default: info)\n- `metrics_port` — Prometheus port (default: 9090)\n- `auto_update` — enable/disable auto-update (default: true)\n- `update_time` — cron-style update schedule (default: \"03:00\")\n- `ha.*` — see [HA Pair](#ha-pair-zero-config-auto-discovery) section above\n- `connectivity.*` — see [4G Failover](#4g-failover) section above\n\n---\n\n## Site Intelligence\n\nOne 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.\n\n```\n                     ONE PI — ONE TUNNEL\n\n  ┌─────────────────────────────────────────────────────────┐\n  │                  OpenCPO Bastion                        │\n  │                                                         │\n  │  🔌 OCPP Proxy      — chargers ↔ core                  │\n  │  🌡  BME280          — enclosure temp, humidity, pressure│\n  │  ⚡ CT Clamp         — real power draw (independent)     │\n  │  🚪 Reed Switch      — enclosure tamper detection        │\n  │  💧 Flood Sensor     — water ingress                     │\n  │  🔊 MEMS Mic         — ambient dB (fan failure, arcing)  │\n  │  💡 TSL2591          — ambient light / site lighting     │\n  │  📷 UniFi Cameras    — video, motion, LPR, face events  │\n  │                                                         │\n  │  All management over Tailscale — nothing on WAN          │\n  └─────────────────────────────────────────────────────────┘\n```\n\n### Sensor Array\n\nPlug-and-play I2C/GPIO sensors auto-detected on boot. No configuration needed if using default I2C addresses and GPIO pins.\n\n| Sensor | Interface | What it tells you |\n|--------|-----------|-------------------|\n| BME280 | I2C 0x76/0x77 | Enclosure temperature, humidity, pressure |\n| ADS1115 + SCT-013 | I2C 0x48 | True power draw at supply — independent check on charger reports |\n| Reed switch | GPIO 17 | Enclosure door open / tamper detected |\n| Flood sensor | GPIO 27 | Water ingress — critical for outdoor/underground installs |\n| SPH0645 (I2S mic) | I2S | Ambient dB level only — detects fan failure, arcing, abnormal noise. **No audio recording.** |\n| TSL2591 | I2C 0x29 | Ambient light — site lighting status, day/night detection |\n\nAll sensors publish to Prometheus and a 24-hour ring buffer (1-min resolution).\n\n**API:**\n- `GET /sensors` — current readings for all detected sensors\n- `GET /sensors/{id}/history` — 24h time series for a single sensor\n- `GET /diag/sensors` — raw hardware scan: I2C addresses found, which sensors detected\n\n### CCTV Integration\n\nOpt-in (`cctv.enabled: true`). Cameras stay on the local LAN — only the admin-panel-bound stream proxies through the tunnel.\n\n**UniFi Protect** is the primary target. The `uiprotect` library (same as Home Assistant) handles auth, WebSocket, and reconnection.\n\n**Smart detection events** from Protect AI are the real value:\n\n| Feature | What it does |\n|---------|-------------|\n| **License Plate Recognition** | Reads plates from Protect → sends to Core → Core matches plate to fleet vehicle → auto-authorize charging session |\n| **Motion / Person / Vehicle** | Real-time events forwarded to Core API + SSE stream |\n| **Face Recognition** | Known faces logged as authorized access; unknown faces alert admin during configured hours |\n| **ONVIF fallback** | Any standards-compliant camera works if UniFi isn't present |\n\n**API:**\n- `GET /cctv` — list discovered cameras with status\n- `GET /cctv/{id}/snapshot` — JPEG frame on demand\n- `GET /cctv/{id}/stream` — MJPEG stream for dashboard embedding\n- `POST /cctv/{id}/ptz` — pan/tilt/zoom (if camera supports it)\n- `GET /events` — SSE stream of smart detection events\n- `GET /lpr/recent` — recent plate reads with thumbnails\n- `GET /lpr/search?plate=XX-1` — search plate history\n- `GET /faces/recent` — recent face events with known/unknown status\n\nLocal recording uses a circular buffer on the SD card or USB drive (configurable retention and size cap).\n\n---\n\n## License\n\nApache 2.0 — see LICENSE\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopencpo%2Fopencpo-bastion","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fopencpo%2Fopencpo-bastion","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopencpo%2Fopencpo-bastion/lists"}