// Package ocpp implements the DriverVault OCPP 1.6J Central System (CSMS) that // lets the server control an EV charger — start/stop a session, cap the current, // change availability, reset, and so on — over the persistent WebSocket the // charger dials out to. It is a separate long-lived subsystem from the read-only // Anker Solix cloud plugin (internal/plugins/builtin/ankersolix), which can only // monitor. // // The whole API server is dependency-free (stdlib only), so the WebSocket layer // here is a hand-rolled RFC 6455 implementation rather than gorilla/websocket. It // is deliberately minimal: text-message oriented (OCPP frames are JSON text), // single reader / serialized writer, with ping/pong and close handled inline. package ocpp import ( "bufio" "context" "crypto/rand" "crypto/sha1" "crypto/tls" "encoding/base64" "encoding/binary" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" ) // wsGUID is the RFC 6455 magic value appended to Sec-WebSocket-Key when deriving // the Sec-WebSocket-Accept response. const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" // maxMessageBytes caps a single assembled message; OCPP payloads are small, so // this is a generous ceiling that also bounds a hostile peer. const maxMessageBytes = 1 << 20 // 1 MiB // WebSocket opcodes (RFC 6455 §5.2). const ( opContinuation = 0x0 opText = 0x1 opBinary = 0x2 opClose = 0x8 opPing = 0x9 opPong = 0xA ) // ErrClosed is returned by ReadMessage once the peer has sent a close frame. var ErrClosed = errors.New("ocpp: connection closed") // Conn is a minimal RFC 6455 connection, usable as either the server (CSMS) or // client (proxy→upstream, and the test charge point) end. The only difference is // masking: per the spec a client masks the frames it sends, a server does not. type Conn struct { raw net.Conn br *bufio.Reader isServer bool wmu sync.Mutex // serializes all writes (data + control frames) closeOnce sync.Once closed chan struct{} } // Upgrade performs the server-side handshake on a hijackable ResponseWriter and // returns a ready Conn. On success the caller owns the connection and must not // touch w again. It negotiates the "ocpp1.6" subprotocol when offered. func Upgrade(w http.ResponseWriter, r *http.Request) (*Conn, error) { if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") || !headerHasToken(r.Header.Get("Connection"), "upgrade") { return nil, errors.New("ocpp: not a websocket upgrade request") } key := r.Header.Get("Sec-WebSocket-Key") if key == "" { return nil, errors.New("ocpp: missing Sec-WebSocket-Key") } hj, ok := w.(http.Hijacker) if !ok { return nil, errors.New("ocpp: response writer does not support hijacking") } conn, brw, err := hj.Hijack() if err != nil { return nil, err } var subproto string for _, p := range splitTokens(r.Header.Get("Sec-WebSocket-Protocol")) { if strings.EqualFold(p, "ocpp1.6") { subproto = "ocpp1.6" break } } var b strings.Builder b.WriteString("HTTP/1.1 101 Switching Protocols\r\n") b.WriteString("Upgrade: websocket\r\n") b.WriteString("Connection: Upgrade\r\n") b.WriteString("Sec-WebSocket-Accept: " + acceptKey(key) + "\r\n") if subproto != "" { b.WriteString("Sec-WebSocket-Protocol: " + subproto + "\r\n") } b.WriteString("\r\n") if _, err := conn.Write([]byte(b.String())); err != nil { _ = conn.Close() return nil, err } return &Conn{raw: conn, br: brw.Reader, isServer: true, closed: make(chan struct{})}, nil } // Dial opens a client WebSocket to rawURL (ws:// or wss://), offering the given // subprotocols and any extra request headers (e.g. Authorization for OCPP // security profile 1). It is used for proxy→upstream links and by tests. func Dial(ctx context.Context, rawURL string, subprotocols []string, header http.Header) (*Conn, error) { u, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("ocpp: bad url: %w", err) } var ( secure bool port = u.Port() ) switch strings.ToLower(u.Scheme) { case "ws", "http": if port == "" { port = "80" } case "wss", "https": secure, port = true, orDefault(port, "443") default: return nil, fmt.Errorf("ocpp: unsupported scheme %q", u.Scheme) } d := &net.Dialer{} raw, err := d.DialContext(ctx, "tcp", net.JoinHostPort(u.Hostname(), port)) if err != nil { return nil, err } if secure { tconn := tls.Client(raw, &tls.Config{ServerName: u.Hostname()}) if err := tconn.HandshakeContext(ctx); err != nil { _ = raw.Close() return nil, err } raw = tconn } if dl, ok := ctx.Deadline(); ok { _ = raw.SetDeadline(dl) } var keyBytes [16]byte if _, err := rand.Read(keyBytes[:]); err != nil { _ = raw.Close() return nil, err } key := base64.StdEncoding.EncodeToString(keyBytes[:]) reqPath := u.RequestURI() if reqPath == "" { reqPath = "/" } var b strings.Builder b.WriteString("GET " + reqPath + " HTTP/1.1\r\n") b.WriteString("Host: " + u.Host + "\r\n") b.WriteString("Upgrade: websocket\r\n") b.WriteString("Connection: Upgrade\r\n") b.WriteString("Sec-WebSocket-Version: 13\r\n") b.WriteString("Sec-WebSocket-Key: " + key + "\r\n") if len(subprotocols) > 0 { b.WriteString("Sec-WebSocket-Protocol: " + strings.Join(subprotocols, ", ") + "\r\n") } for k, vs := range header { for _, v := range vs { b.WriteString(k + ": " + v + "\r\n") } } b.WriteString("\r\n") if _, err := raw.Write([]byte(b.String())); err != nil { _ = raw.Close() return nil, err } br := bufio.NewReader(raw) resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodGet}) if err != nil { _ = raw.Close() return nil, err } resp.Body.Close() if resp.StatusCode != http.StatusSwitchingProtocols { _ = raw.Close() return nil, fmt.Errorf("ocpp: upstream refused upgrade (HTTP %d)", resp.StatusCode) } if got := resp.Header.Get("Sec-WebSocket-Accept"); got != acceptKey(key) { _ = raw.Close() return nil, errors.New("ocpp: bad Sec-WebSocket-Accept from upstream") } // Clear the dial deadline; per-operation deadlines are set explicitly later. _ = raw.SetDeadline(time.Time{}) return &Conn{raw: raw, br: br, isServer: false, closed: make(chan struct{})}, nil } // ReadMessage returns the next complete text/binary message, transparently // answering ping frames and honoring a close frame (after which it returns // ErrClosed). Control frames never surface to the caller. func (c *Conn) ReadMessage() ([]byte, error) { var ( buf []byte started bool ) for { f, err := c.readFrame() if err != nil { return nil, err } switch f.opcode { case opPing: _ = c.writeFrame(opPong, f.data) continue case opPong: continue case opClose: _ = c.writeFrame(opClose, f.data) c.markClosed() return nil, ErrClosed case opText, opBinary: if started { return nil, errors.New("ocpp: new data frame before previous finished") } started = true buf = append(buf, f.data...) case opContinuation: if !started { return nil, errors.New("ocpp: continuation frame with no start") } buf = append(buf, f.data...) default: return nil, fmt.Errorf("ocpp: unexpected opcode 0x%x", f.opcode) } if len(buf) > maxMessageBytes { return nil, errors.New("ocpp: message too large") } if f.fin { return buf, nil } } } // WriteMessage sends one OCPP text frame. func (c *Conn) WriteMessage(data []byte) error { select { case <-c.closed: return ErrClosed default: } return c.writeFrame(opText, data) } // SetReadDeadline bounds the next read (used to detect a dead peer between // heartbeats). A zero time clears it. func (c *Conn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) } // Close sends a close frame (best effort) and shuts the underlying connection. func (c *Conn) Close() error { var err error c.closeOnce.Do(func() { _ = c.writeFrame(opClose, nil) close(c.closed) err = c.raw.Close() }) return err } // markClosed records that the peer initiated close without double-sending. func (c *Conn) markClosed() { c.closeOnce.Do(func() { close(c.closed) _ = c.raw.Close() }) } // ---- framing ----------------------------------------------------------------- type frame struct { fin bool opcode byte data []byte } func (c *Conn) readFrame() (frame, error) { var h [2]byte if _, err := io.ReadFull(c.br, h[:]); err != nil { return frame{}, err } fin := h[0]&0x80 != 0 if h[0]&0x70 != 0 { return frame{}, errors.New("ocpp: reserved bits set (no extensions supported)") } opcode := h[0] & 0x0f masked := h[1]&0x80 != 0 length := uint64(h[1] & 0x7f) switch length { case 126: var ext [2]byte if _, err := io.ReadFull(c.br, ext[:]); err != nil { return frame{}, err } length = uint64(binary.BigEndian.Uint16(ext[:])) case 127: var ext [8]byte if _, err := io.ReadFull(c.br, ext[:]); err != nil { return frame{}, err } length = binary.BigEndian.Uint64(ext[:]) } if length > maxMessageBytes { return frame{}, errors.New("ocpp: frame too large") } // RFC 6455: the server must receive masked frames; a client must receive // unmasked ones. if c.isServer && !masked { return frame{}, errors.New("ocpp: unmasked frame from client") } if !c.isServer && masked { return frame{}, errors.New("ocpp: masked frame from server") } var maskKey [4]byte if masked { if _, err := io.ReadFull(c.br, maskKey[:]); err != nil { return frame{}, err } } data := make([]byte, length) if _, err := io.ReadFull(c.br, data); err != nil { return frame{}, err } if masked { for i := range data { data[i] ^= maskKey[i%4] } } return frame{fin: fin, opcode: opcode, data: data}, nil } func (c *Conn) writeFrame(opcode byte, data []byte) error { c.wmu.Lock() defer c.wmu.Unlock() mask := !c.isServer // clients mask their frames var head [14]byte head[0] = 0x80 | opcode // FIN + opcode (we never fragment outgoing messages) n := 2 l := len(data) switch { case l <= 125: head[1] = byte(l) case l <= 0xFFFF: head[1] = 126 binary.BigEndian.PutUint16(head[2:4], uint16(l)) n = 4 default: head[1] = 127 binary.BigEndian.PutUint64(head[2:10], uint64(l)) n = 10 } var maskKey [4]byte if mask { head[1] |= 0x80 if _, err := rand.Read(maskKey[:]); err != nil { return err } copy(head[n:n+4], maskKey[:]) n += 4 } _ = c.raw.SetWriteDeadline(time.Now().Add(30 * time.Second)) defer c.raw.SetWriteDeadline(time.Time{}) if _, err := c.raw.Write(head[:n]); err != nil { return err } if l == 0 { return nil } if mask { masked := make([]byte, l) for i := 0; i < l; i++ { masked[i] = data[i] ^ maskKey[i%4] } _, err := c.raw.Write(masked) return err } _, err := c.raw.Write(data) return err } // ---- small helpers ----------------------------------------------------------- func acceptKey(key string) string { h := sha1.New() _, _ = io.WriteString(h, key+wsGUID) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } // headerHasToken reports whether a comma-separated header value contains token // (case-insensitive) — e.g. Connection: keep-alive, Upgrade. func headerHasToken(value, token string) bool { for _, t := range splitTokens(value) { if strings.EqualFold(t, token) { return true } } return false } func splitTokens(value string) []string { if value == "" { return nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } func orDefault(v, def string) string { if v == "" { return def } return v }