evcc-io/charger/ocpp/forwarder.go
2026-06-21 11:10:12 +00:00

743 lines
22 KiB
Go

package ocpp
// Hybrid OCPP proxy. Chargers connect to evcc's central system; for each charger
// with a matching rule a "sidecar" WebSocket to the upstream OCPP server runs in
// parallel. Billing-critical Calls (actionsRelayedToUpstream) are relayed to
// upstream as the authoritative responder and evcc's handler is bypassed; all
// other messages are processed by evcc and mirrored to upstream for observation.
// Upstream Calls are injected into the charger unless the rule is read-only.
//
// Design: docs/agents/ocpp-forwarder.md
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/evcc-io/evcc/util"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocppj"
"github.com/lorenzodonini/ocpp-go/ws"
)
// forwarder rules, guarded by forwarderMu
var (
forwarderMu sync.RWMutex
forwarderRules []ForwarderRule
)
// ForwarderEnabled returns true when at least one rule is configured.
func ForwarderEnabled() bool {
forwarderMu.RLock()
defer forwarderMu.RUnlock()
return len(forwarderRules) > 0
}
// ApplyForwarderRules replaces the forwarding rules at runtime and republishes status.
func ApplyForwarderRules(rules []ForwarderRule) {
forwarderMu.Lock()
forwarderRules = rules
forwarderMu.Unlock()
valid := make(map[string]bool, len(rules))
hasWildcard := false
for _, r := range rules {
valid[r.StationID] = true
if r.StationID == "*" {
hasWildcard = true
}
}
// drop sidecars/errors for removed rules
var stale []*sidecar
sidecarsMu.Lock()
for id, sc := range sidecars {
if !valid[id] && !hasWildcard {
stale = append(stale, sc)
delete(sidecars, id)
}
}
for id := range forwarderErrors {
if !valid[id] && !hasWildcard {
delete(forwarderErrors, id)
}
}
sidecarsMu.Unlock()
for _, sc := range stale {
sc.conn.CloseNow()
}
// connected charger: (re)dial sidecar; otherwise test-dial to surface config errors
for _, r := range rules {
if r.StationID == "*" || r.UpstreamURL == "" {
continue
}
sidecarsMu.Lock()
sc, active := sidecars[r.StationID]
connected := connectedChargers[r.StationID]
var changed *sidecar
if active && !sc.rule.sameConnection(r) {
changed = sc
delete(sidecars, r.StationID)
active = false
}
sidecarsMu.Unlock()
if changed != nil {
changed.conn.CloseNow()
}
if active {
continue
}
if connected {
pendingMu.Lock()
pendingMsgs[r.StationID] = nil
pendingMu.Unlock()
go dialUpstreamSidecar(r.StationID, r)
} else {
go validateUpstream(r.StationID, r)
}
}
notifyUpdated()
}
// validateUpstream test-dials a rule's upstream and records/clears the error so
// the UI reflects unreachable hosts.
func validateUpstream(id string, rule ForwarderRule) {
upstreamBase := strings.TrimRight(rule.UpstreamURL, "/")
upstreamPath := rule.upstreamPath(id)
tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure}
if rule.CaCert != "" {
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok {
recordForwarderError(id, "invalid CA certificate")
notifyUpdated()
return
}
tlsConfig.RootCAs = caCertPool
}
var header http.Header
if rule.Username != "" || rule.Password != "" {
header = authHeader(rule.Username, rule.Password)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{
Subprotocols: []string{"ocpp1.6"},
HTTPHeader: header,
HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}},
})
cancel()
// a charger may have connected meanwhile; its sidecar is authoritative
sidecarsMu.Lock()
_, active := sidecars[id]
sidecarsMu.Unlock()
if active {
if conn != nil {
conn.CloseNow()
}
return
}
if err != nil {
recordForwarderError(id, err.Error())
notifyUpdated()
return
}
conn.Close(websocket.StatusNormalClosure, "")
if clearForwarderError(id) {
notifyUpdated()
}
}
// ForwarderRules returns the current forwarding rules.
func ForwarderRules() []ForwarderRule {
forwarderMu.RLock()
defer forwarderMu.RUnlock()
return forwarderRules
}
// actionsRelayedToUpstream lists actions for which upstream is the authoritative
// Central System: evcc's handler is bypassed and upstream's reply relayed back.
var actionsRelayedToUpstream = map[string]bool{
"Authorize": true,
"StartTransaction": true,
"StopTransaction": true,
"DataTransfer": true,
}
var forwarderLog = util.NewLogger("ocpp-forwarder")
// init wires the forwarder hooks declared in instance.go.
func init() {
chargerConnectHook = onChargerConnect
chargerDisconnectHook = onChargerDisconnect
chargerMessageHook = onChargerMessage
}
// sidecar holds an upstream connection for a single charger.
type sidecar struct {
chargerID string
upstreamURL string
rule ForwarderRule // rule used to dial; detects param changes
conn *websocket.Conn
// message IDs of upstream-initiated Calls; the charger's reply is routed back to upstream
pendingUpstreamCallsMu sync.Mutex
pendingUpstreamCalls map[string]struct{}
// message IDs of charger Calls whose evcc handler was bypassed; upstream's reply is relayed to the charger
pendingChargerCallsMu sync.Mutex
pendingChargerCalls map[string]struct{}
// when > 0, forward at most one MeterValues per interval to upstream; evcc still sees every frame
meterInterval time.Duration
lastMeterFwdMu sync.Mutex
lastMeterFwd time.Time
}
var (
sidecarsMu sync.Mutex
sidecars = make(map[string]*sidecar)
// connected charger ids so a runtime-added rule can start a sidecar; guarded by sidecarsMu
connectedChargers = make(map[string]bool)
// last upstream failure per charger, surfaced to the UI; guarded by sidecarsMu
forwarderErrors = make(map[string]string)
// raw frames buffered per charger while the sidecar dials; flushed in order
// on connect so BootNotification reaches upstream
pendingMu sync.Mutex
pendingMsgs = make(map[string][][]byte)
)
// resolveRule returns the forwarding rule for chargerID, or the "*" fallback.
func resolveRule(chargerID string) (ForwarderRule, bool) {
forwarderMu.RLock()
rules := forwarderRules
forwarderMu.RUnlock()
var fallback ForwarderRule
var hasFallback bool
for _, r := range rules {
if r.StationID == chargerID {
return r, true
}
if r.StationID == "*" {
fallback = r
hasFallback = true
}
}
return fallback, hasFallback
}
// onChargerConnect dials a sidecar for the connecting charger when a rule matches.
func onChargerConnect(ch ws.Channel) {
id := ch.ID()
sidecarsMu.Lock()
connectedChargers[id] = true
sidecarsMu.Unlock()
rule, ok := resolveRule(id)
if !ok {
return
}
// open the buffer before dialling so early messages are captured, not dropped
pendingMu.Lock()
pendingMsgs[id] = nil
pendingMu.Unlock()
go dialUpstreamSidecar(id, rule)
}
func dialUpstreamSidecar(id string, rule ForwarderRule) {
upstreamBase := strings.TrimRight(rule.UpstreamURL, "/")
upstreamPath := rule.upstreamPath(id)
var header http.Header
if rule.Username != "" || rule.Password != "" {
header = authHeader(rule.Username, rule.Password)
}
tlsConfig := &tls.Config{InsecureSkipVerify: rule.Insecure}
if rule.CaCert != "" {
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM([]byte(rule.CaCert)); !ok {
forwarderLog.WARN.Printf("forwarder: failed to parse CA cert for %s; forwarding disabled", id)
recordForwarderError(id, "invalid CA certificate")
notifyUpdated()
drainPendingWithErrors(id, nil)
return
}
tlsConfig.RootCAs = caCertPool
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
conn, _, err := websocket.Dial(ctx, upstreamBase+upstreamPath, &websocket.DialOptions{
Subprotocols: []string{"ocpp1.6"},
HTTPHeader: header,
HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}},
})
cancel()
if err != nil {
forwarderLog.WARN.Printf("forwarder: dial upstream for %s: %v; forwarding disabled", id, err)
recordForwarderError(id, err.Error())
notifyUpdated()
drainPendingWithErrors(id, nil)
return
}
conn.SetReadLimit(-1) // no limit; OCPP frames can be large
sc := &sidecar{
chargerID: id,
upstreamURL: upstreamBase,
rule: rule,
conn: conn,
pendingUpstreamCalls: make(map[string]struct{}),
pendingChargerCalls: make(map[string]struct{}),
}
// install sidecar and drain the pending buffer
sidecarsMu.Lock()
pendingMu.Lock()
sidecars[id] = sc
buffered := pendingMsgs[id]
delete(pendingMsgs, id)
pendingMu.Unlock()
sidecarsMu.Unlock()
// flush buffered Calls; register relay actions so upstream's reply routes back.
// CallResults/Errors are skipped: evcc already answered them.
flushed := 0
for _, frame := range buffered {
msgType, msgID, action, err := parseOCPPFrame(frame)
if err != nil || msgType != ocppj.CALL {
continue
}
if actionsRelayedToUpstream[action] {
sc.pendingChargerCallsMu.Lock()
sc.pendingChargerCalls[msgID] = struct{}{}
sc.pendingChargerCallsMu.Unlock()
}
if err := conn.Write(context.Background(), websocket.MessageText, frame); err != nil {
forwarderLog.ERROR.Printf("forwarder: write buffered frame to upstream for %s: %v", id, err)
conn.CloseNow()
recordForwarderError(id, err.Error())
notifyUpdated()
return
}
flushed++
}
if flushed > 0 {
forwarderLog.DEBUG.Printf("forwarder: flushed %d call(s) to upstream for %s (skipped %d non-call frame(s))",
flushed, id, len(buffered)-flushed)
}
clearForwarderError(id)
notifyUpdated()
forwarderLog.INFO.Printf("forwarder: %s → %s", id, upstreamBase+upstreamPath)
sc.readFromUpstream(rule.ReadOnly)
}
// drainPendingWithErrors discards charger id's pending buffer, sending a CallError
// to the charger for each buffered relay Call so it is not left hanging. sc may be nil.
func drainPendingWithErrors(id string, sc *sidecar) {
pendingMu.Lock()
buffered := pendingMsgs[id]
delete(pendingMsgs, id)
pendingMu.Unlock()
cs := Instance()
for _, frame := range buffered {
msgType, msgID, action, err := parseOCPPFrame(frame)
if err != nil || msgType != ocppj.CALL || !actionsRelayedToUpstream[action] {
continue
}
errFrame, _ := (&ocppj.CallError{
MessageTypeId: ocppj.CALL_ERROR,
UniqueId: msgID,
ErrorCode: ocppj.GenericError,
ErrorDescription: "Upstream OCPP server unavailable",
}).MarshalJSON()
if writeErr := cs.Write(id, errFrame); writeErr != nil {
forwarderLog.WARN.Printf("forwarder: send error for pending call %s to %s: %v", msgID, id, writeErr)
}
}
// error out tracked pendingChargerCalls (sidecar dropped mid-session)
if sc != nil {
sc.pendingChargerCallsMu.Lock()
for msgID := range sc.pendingChargerCalls {
errFrame, _ := (&ocppj.CallError{
MessageTypeId: ocppj.CALL_ERROR,
UniqueId: msgID,
ErrorCode: ocppj.GenericError,
ErrorDescription: "Upstream OCPP server disconnected",
}).MarshalJSON()
if writeErr := cs.Write(sc.chargerID, errFrame); writeErr != nil {
forwarderLog.WARN.Printf("forwarder: send disconnect error for %s to %s: %v", msgID, sc.chargerID, writeErr)
}
}
sc.pendingChargerCalls = make(map[string]struct{})
sc.pendingChargerCallsMu.Unlock()
}
}
// onChargerDisconnect closes the charger's sidecar connection.
func onChargerDisconnect(ch ws.Channel) {
id := ch.ID()
// discard pending buffer
pendingMu.Lock()
delete(pendingMsgs, id)
pendingMu.Unlock()
sidecarsMu.Lock()
delete(connectedChargers, id)
sc, ok := sidecars[id]
if ok {
delete(sidecars, id)
}
_, hadErr := forwarderErrors[id]
delete(forwarderErrors, id)
sidecarsMu.Unlock()
if ok {
sc.conn.CloseNow()
forwarderLog.DEBUG.Printf("forwarder: %s upstream connection closed", id)
}
if ok || hadErr {
notifyUpdated()
}
}
// onChargerMessage handles a raw OCPP frame from a charger and reports whether
// evcc's handler should be bypassed (upstream is the authoritative responder).
// Relay-action Calls are forwarded and bypassed; other Calls are forwarded and
// also handled by evcc; CallResults/Errors are forwarded only when they answer
// an upstream-initiated Call.
func onChargerMessage(ch ws.Channel, data []byte) bool {
id := ch.ID()
msgType, msgID, action, err := parseOCPPFrame(data)
if err != nil {
return false
}
sidecarsMu.Lock()
sc := sidecars[id]
sidecarsMu.Unlock()
switch msgType {
case ocppj.CALL:
relay := actionsRelayedToUpstream[action]
if sc != nil {
// throttle MeterValues to upstream; evcc still processes every frame
if action == "MeterValues" && sc.meterInterval > 0 {
sc.lastMeterFwdMu.Lock()
elapsed := time.Since(sc.lastMeterFwd)
if elapsed < sc.meterInterval {
sc.lastMeterFwdMu.Unlock()
return false // evcc handles normally; skip upstream
}
sc.lastMeterFwd = time.Now()
sc.lastMeterFwdMu.Unlock()
}
if relay {
sc.pendingChargerCallsMu.Lock()
sc.pendingChargerCalls[msgID] = struct{}{}
sc.pendingChargerCallsMu.Unlock()
}
if err := sc.conn.Write(context.Background(), websocket.MessageText, data); err != nil {
forwarderLog.ERROR.Printf("forwarder: write to upstream for %s: %v", id, err)
}
return relay
}
// sidecar not ready: buffer if a pending slot exists
pendingMu.Lock()
_, hasPending := pendingMsgs[id]
if hasPending {
pendingMsgs[id] = append(pendingMsgs[id], slices.Clone(data))
}
pendingMu.Unlock()
// bypass evcc for relay actions while buffering; upstream answers after flush
return relay && hasPending
case ocppj.CALL_RESULT, ocppj.CALL_ERROR:
// forward only if this answers an upstream-initiated Call
if sc == nil {
return false
}
sc.pendingUpstreamCallsMu.Lock()
_, isUpstream := sc.pendingUpstreamCalls[msgID]
if isUpstream {
delete(sc.pendingUpstreamCalls, msgID)
}
sc.pendingUpstreamCallsMu.Unlock()
if !isUpstream {
return false
}
if err := sc.conn.Write(context.Background(), websocket.MessageText, data); err != nil {
forwarderLog.ERROR.Printf("forwarder: write upstream reply for %s: %v", id, err)
}
return false // evcc may also see it; harmless for unknown IDs
}
return false
}
// readFromUpstream relays frames from upstream: Calls are injected into the
// charger (reply routed back), responses to bypassed charger Calls are relayed
// to the charger, others discarded. Calls are rejected in read-only mode.
func (sc *sidecar) readFromUpstream(readOnly bool) {
defer func() {
sidecarsMu.Lock()
if sidecars[sc.chargerID] == sc {
delete(sidecars, sc.chargerID)
}
sidecarsMu.Unlock()
// error out pending relayed calls; upstream is gone
drainPendingWithErrors(sc.chargerID, sc)
sc.conn.CloseNow()
notifyUpdated()
}()
for {
_, msg, err := sc.conn.Read(context.Background())
if err != nil {
forwarderLog.DEBUG.Printf("forwarder: upstream disconnected for %s: %v", sc.chargerID, err)
// charger disconnect deletes the sidecar first, so a still-current sidecar means upstream dropped
sidecarsMu.Lock()
upstreamDrop := sidecars[sc.chargerID] == sc
sidecarsMu.Unlock()
if upstreamDrop {
recordForwarderError(sc.chargerID, err.Error())
}
return
}
msgType, msgID, action, err := parseOCPPFrame(msg)
if err != nil {
forwarderLog.WARN.Printf("forwarder: upstream parse error for %s: %v", sc.chargerID, err)
continue
}
switch msgType {
case ocppj.CALL:
if readOnly {
forwarderLog.DEBUG.Printf("forwarder: blocking upstream call %s in read-only session %s", msgID, sc.chargerID)
errFrame, _ := (&ocppj.CallError{
MessageTypeId: ocppj.CALL_ERROR,
UniqueId: msgID,
ErrorCode: ocppj.SecurityError,
ErrorDescription: "Charger control not allowed: forwarder is in read-only mode",
}).MarshalJSON()
_ = sc.conn.Write(context.Background(), websocket.MessageText, errFrame)
continue
}
// absorb upstream's MeterValueSampleInterval as our throttle; reply
// Accepted without touching the charger's config
if action == "ChangeConfiguration" {
if interval, ok := extractMeterValueSampleInterval(msg); ok {
sc.lastMeterFwdMu.Lock()
sc.meterInterval = interval
sc.lastMeterFwdMu.Unlock()
forwarderLog.DEBUG.Printf("forwarder: upstream set MeterValueSampleInterval=%v for %s", interval, sc.chargerID)
accepted, _ := (&ocppj.CallResult{
MessageTypeId: ocppj.CALL_RESULT,
UniqueId: msgID,
Payload: core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted),
}).MarshalJSON()
_ = sc.conn.Write(context.Background(), websocket.MessageText, accepted)
continue
}
}
// track id so the charger's reply is forwarded back to upstream
sc.pendingUpstreamCallsMu.Lock()
sc.pendingUpstreamCalls[msgID] = struct{}{}
sc.pendingUpstreamCallsMu.Unlock()
if err := Instance().Write(sc.chargerID, msg); err != nil {
forwarderLog.ERROR.Printf("forwarder: inject upstream call into charger %s: %v", sc.chargerID, err)
}
case ocppj.CALL_RESULT, ocppj.CALL_ERROR:
// upstream's authoritative response to a bypassed charger Call?
sc.pendingChargerCallsMu.Lock()
_, isChargerCall := sc.pendingChargerCalls[msgID]
if isChargerCall {
delete(sc.pendingChargerCalls, msgID)
}
sc.pendingChargerCallsMu.Unlock()
if isChargerCall {
// relay to charger; its handler was bypassed and it awaits this reply
if err := Instance().Write(sc.chargerID, msg); err != nil {
forwarderLog.ERROR.Printf("forwarder: relay upstream response to charger %s: %v", sc.chargerID, err)
}
continue
}
// discard: evcc already replied for non-relay Calls
}
}
}
// ForwarderSessionStatus is the observable state of one forwarder session.
type ForwarderSessionStatus struct {
ChargerID string `json:"chargerId"`
UpstreamURL string `json:"upstreamUrl"`
UpstreamConnected bool `json:"upstreamConnected"`
Error string `json:"error,omitempty"`
}
// recordForwarderError stores a charger's last upstream failure; caller must notifyUpdated.
func recordForwarderError(id, msg string) {
sidecarsMu.Lock()
forwarderErrors[id] = msg
sidecarsMu.Unlock()
}
// clearForwarderError drops a charger's stored error, reporting whether one existed; caller must notifyUpdated.
func clearForwarderError(id string) bool {
sidecarsMu.Lock()
_, ok := forwarderErrors[id]
delete(forwarderErrors, id)
sidecarsMu.Unlock()
return ok
}
var (
forwarderCbMu sync.Mutex
forwarderUpdatedCb func()
)
func notifyUpdated() {
status := GetForwarderStatus()
forwarderLog.DEBUG.Printf("forwarder: notifyUpdated sessions=%d", len(status))
forwarderCbMu.Lock()
cb := forwarderUpdatedCb
forwarderCbMu.Unlock()
if cb != nil {
cb()
}
}
// SetForwarderUpdated registers a callback fired when a session connects or disconnects.
func SetForwarderUpdated(cb func()) {
forwarderCbMu.Lock()
forwarderUpdatedCb = cb
forwarderCbMu.Unlock()
}
// GetForwarderStatus returns a snapshot of all active forwarder sessions.
func GetForwarderStatus() []ForwarderSessionStatus {
forwarderMu.RLock()
rules := append([]ForwarderRule(nil), forwarderRules...)
forwarderMu.RUnlock()
sidecarsMu.Lock()
defer sidecarsMu.Unlock()
out := make([]ForwarderSessionStatus, 0, len(rules))
for _, r := range rules {
if r.StationID == "*" {
continue
}
st := ForwarderSessionStatus{
ChargerID: r.StationID,
UpstreamURL: strings.TrimRight(r.UpstreamURL, "/"),
}
if _, ok := sidecars[r.StationID]; ok {
st.UpstreamConnected = true
} else if msg, ok := forwarderErrors[r.StationID]; ok {
st.Error = msg
}
out = append(out, st)
}
return out
}
// sameConnection reports whether two rules dial upstream identically (no reconnect
// needed). ReadOnly is excluded; it applies live per message.
func (r ForwarderRule) sameConnection(o ForwarderRule) bool {
return r.UpstreamURL == o.UpstreamURL &&
r.UpstreamStationID == o.UpstreamStationID &&
r.Username == o.Username &&
r.Password == o.Password &&
r.Insecure == o.Insecure &&
r.CaCert == o.CaCert
}
// upstreamPath returns the upstream WebSocket path, defaulting to the charger's own ID.
func (r ForwarderRule) upstreamPath(chargerID string) string {
sid := r.UpstreamStationID
if sid == "" {
sid = chargerID
}
return "/" + strings.TrimLeft(sid, "/")
}
// authHeader returns a Basic Auth header for the given credentials.
func authHeader(username, password string) http.Header {
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
h := make(http.Header)
h.Set("Authorization", "Basic "+creds)
return h
}
// extractMeterValueSampleInterval returns the interval from a ChangeConfiguration
// Call for key MeterValueSampleInterval.
func extractMeterValueSampleInterval(msg []byte) (time.Duration, bool) {
var frame []json.RawMessage
if err := json.Unmarshal(msg, &frame); err != nil || len(frame) < 4 {
return 0, false
}
var req core.ChangeConfigurationRequest
if err := json.Unmarshal(frame[3], &req); err != nil {
return 0, false
}
if req.Key != "MeterValueSampleInterval" {
return 0, false
}
secs, err := strconv.Atoi(req.Value)
if err != nil || secs <= 0 {
return 0, false
}
return time.Duration(secs) * time.Second, true
}
// parseOCPPFrame extracts the message type, id and (for Calls) action from a raw frame.
func parseOCPPFrame(msg []byte) (msgType ocppj.MessageType, msgID string, action string, err error) {
var frame []json.RawMessage
if err = json.Unmarshal(msg, &frame); err != nil || len(frame) < 2 {
return 0, "", "", fmt.Errorf("invalid OCPP frame")
}
if err = json.Unmarshal(frame[0], &msgType); err != nil {
return 0, "", "", fmt.Errorf("invalid message type: %w", err)
}
if err = json.Unmarshal(frame[1], &msgID); err != nil {
return 0, "", "", fmt.Errorf("invalid message id: %w", err)
}
if msgType == ocppj.CALL && len(frame) >= 3 {
_ = json.Unmarshal(frame[2], &action) // best-effort; empty string if missing
}
return msgType, msgID, action, nil
}