diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 4979dc7b3..055bf01de 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -20,7 +20,6 @@ import ( "github.com/evcc-io/evcc/charger/warp" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/jpfielding/go-http-digest/pkg/digest" ) type WarpWS struct { @@ -32,6 +31,8 @@ type WarpWS struct { uri string pmURI string meterIndex uint + user string + password string mu sync.RWMutex @@ -117,7 +118,11 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger } wb.pmHelper = request.NewHelper(wb.log) if cc.EnergyManagerUser != "" { - wb.pmHelper.Client.Transport = digest.NewTransport(cc.EnergyManagerUser, cc.EnergyManagerPassword, wb.pmHelper.Client.Transport) + wb.pmHelper.Client.Transport = &warp.DigestTransport{ + Username: cc.EnergyManagerUser, + Password: cc.EnergyManagerPassword, + Base: wb.pmHelper.Client.Transport, + } } } @@ -156,7 +161,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) client := request.NewHelper(log) if user != "" { - client.Client.Transport = digest.NewTransport(user, password, client.Client.Transport) + client.Client.Transport = &warp.DigestTransport{ + Username: user, + Password: password, + Base: client.Client.Transport, + } } w := &WarpWS{ @@ -166,6 +175,8 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) meterMap: map[int]int{}, metersValueIDsTopic: fmt.Sprintf("meters/%d/value_ids", meterIndex), metersValuesTopic: fmt.Sprintf("meters/%d/values", meterIndex), + user: user, + password: password, } uri, err := parseURI(w.uri, true) @@ -184,9 +195,11 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) func (w *WarpWS) run(uri string, ctx context.Context) { bo := backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(0)) bo.MaxInterval = 30 * time.Second + for ctx.Err() == nil { w.log.DEBUG.Printf("ws connecting to %s …", uri) - conn, resp, err := websocket.Dial(ctx, uri, nil) + + conn, resp, err := dialWebsocket(ctx, uri, w.user, w.password) if err != nil { if resp != nil { resp.Body.Close() @@ -194,29 +207,59 @@ func (w *WarpWS) run(uri string, ctx context.Context) { if conn != nil { conn.Close(websocket.StatusInternalError, "dial failed") } - if ctx.Err() != nil { - return - } - d := bo.NextBackOff() - w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) + w.log.ERROR.Printf("ws dial failed: %v", err) + } else { + w.log.DEBUG.Printf("ws connected to %s", uri) + bo.Reset() - select { - case <-ctx.Done(): - return - case <-time.After(d): - // continue to next reconnect attempt + if err := w.handleConnection(ctx, conn); err != nil { + w.log.ERROR.Println(err) } - continue } - w.log.DEBUG.Printf("ws connected to %s", uri) - bo.Reset() - if err := w.handleConnection(ctx, conn); err != nil { - w.log.ERROR.Println(err) + if ctx.Err() != nil { + return + } + d := bo.NextBackOff() + w.log.DEBUG.Printf("ws reconnecting to %s in %v", uri, d) + + select { + case <-ctx.Done(): + return + case <-time.After(d): } } } +func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Conn, *http.Response, error) { + conn, resp, err := websocket.Dial(ctx, wsURL, nil) + if err == nil { + return conn, resp, err + } + + // Extract challeng from response + www := resp.Header.Get("WWW-Authenticate") + resp.Body.Close() + + ch, err := warp.ParseDigestChallenge(www) + if err != nil { + return nil, resp, fmt.Errorf("digest parse error: %w", err) + } + + // Build authorization header + u, _ := url.Parse(wsURL) + auth := warp.BuildDigestAuthHeader(ch, "GET", u.Path, user, pass) + + // Dial with Digest Auth + dialer := websocket.DialOptions{ + HTTPHeader: http.Header{ + "Authorization": []string{auth}, + }, + } + + return websocket.Dial(ctx, wsURL, &dialer) +} + func parseURI(uri string, toWS bool) (string, error) { u, err := url.Parse(util.DefaultScheme(strings.TrimRight(uri, "/"), "http")) if err != nil { diff --git a/charger/warp/digest_auth.go b/charger/warp/digest_auth.go new file mode 100644 index 000000000..19f3c962b --- /dev/null +++ b/charger/warp/digest_auth.go @@ -0,0 +1,132 @@ +package warp + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +type DigestTransport struct { + Username string + Password string + Base http.RoundTripper + mu sync.Mutex + challenge *DigestChallenge +} + +type DigestChallenge struct { + Realm string + Nonce string + Qop string + Opaque string + Algorithm string +} + +func ParseDigestChallenge(h string) (*DigestChallenge, error) { + dc := &DigestChallenge{} + parts := strings.Split(h, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "Digest ") { + p = strings.TrimPrefix(p, "Digest ") + } + kv := strings.SplitN(p, "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.Trim(kv[1], `"`) + switch key { + case "realm": + dc.Realm = val + case "nonce": + dc.Nonce = val + case "opaque": + dc.Opaque = val + case "qop": + dc.Qop = val + case "algorithm": + dc.Algorithm = val + } + } + return dc, nil +} + +func BuildDigestAuthHeader(ch *DigestChallenge, method, uri, user, pass string) string { + ha1 := md5Hex(fmt.Sprintf("%s:%s:%s", user, ch.Realm, pass)) + ha2 := md5Hex(fmt.Sprintf("%s:%s", method, uri)) + + cnonce := randomHex(16) + nc := "00000001" + + response := md5Hex(fmt.Sprintf("%s:%s:%s:%s:%s:%s", + ha1, ch.Nonce, nc, cnonce, ch.Qop, ha2)) + + return fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm="MD5", response="%s", qop=%s, nc=%s, cnonce="%s"`, + user, ch.Realm, ch.Nonce, uri, response, ch.Qop, nc, cnonce, + ) +} + +func md5Hex(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (t *DigestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.Base == nil { + t.Base = http.DefaultTransport + } + // If we already have a challenge → send digest directly + t.mu.Lock() + ch := t.challenge + t.mu.Unlock() + if ch != nil { + return t.roundTripWithDigest(req, ch) + } + // 1. First try without Auth + resp, err := t.Base.RoundTrip(req) + if err != nil { + return resp, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + // 2. Parse challenge + hdr := resp.Header.Get("WWW-Authenticate") + ch, err = ParseDigestChallenge(hdr) + if err != nil { + return resp, err + } + // Save challenge + t.mu.Lock() + t.challenge = ch + t.mu.Unlock() + // Reread body (if necessery) + if req.Body != nil { + bodyBytes, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + } + // 3. Second try with Digest Auth + return t.roundTripWithDigest(req, ch) +} + +func (t *DigestTransport) roundTripWithDigest(req *http.Request, ch *DigestChallenge) (*http.Response, error) { + // Copy request + r2 := req.Clone(req.Context()) + uri := r2.URL.RequestURI() + auth := BuildDigestAuthHeader(ch, r2.Method, uri, t.Username, t.Password) + r2.Header.Set("Authorization", auth) + return t.Base.RoundTrip(r2) +}