diff --git a/charger/warp-ws.go b/charger/warp-ws.go index 8f8ca5e94..10bc9449a 100644 --- a/charger/warp-ws.go +++ b/charger/warp-ws.go @@ -20,6 +20,7 @@ import ( "github.com/evcc-io/evcc/charger/warp" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" + "github.com/icholy/digest" ) type WarpWS struct { @@ -116,10 +117,9 @@ func NewWarpWSFromConfig(ctx context.Context, other map[string]any) (api.Charger } wb.pmHelper = request.NewHelper(wb.log) if cc.EnergyManagerUser != "" { - wb.pmHelper.Client.Transport = &warp.DigestTransport{ + wb.pmHelper.Client.Transport = &digest.Transport{ Username: cc.EnergyManagerUser, Password: cc.EnergyManagerPassword, - Base: wb.pmHelper.Client.Transport, } } } @@ -159,10 +159,9 @@ func NewWarpWS(ctx context.Context, uri, user, password string, meterIndex uint) client := request.NewHelper(log) if user != "" { - client.Client.Transport = &warp.DigestTransport{ + client.Client.Transport = &digest.Transport{ Username: user, Password: password, - Base: client.Client.Transport, } } @@ -203,7 +202,7 @@ func (w *WarpWS) run(uri, user, pass string, ctx context.Context) { if conn != nil { conn.Close(websocket.StatusInternalError, "dial failed") } - w.log.ERROR.Printf("ws dial failed: %v", err) + w.log.ERROR.Printf("ws dial to %s failed: %v", uri, err) } else { w.log.DEBUG.Printf("ws connected to %s", uri) bo.Reset() @@ -234,22 +233,29 @@ func dialWebsocket(ctx context.Context, wsURL, user, pass string) (*websocket.Co } // Extract challeng from response + if resp == nil { + return nil, nil, fmt.Errorf("no response on websocket dial") + } www := resp.Header.Get("WWW-Authenticate") - resp.Body.Close() - ch, err := warp.ParseDigestChallenge(www) + chal, err := digest.ParseChallenge(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) + cred, _ := digest.Digest(chal, digest.Options{ + Username: user, + Password: pass, + Method: "GET", + URI: wsURL, + Count: 1, + }) + resp.Body.Close() // Dial with Digest Auth dialer := websocket.DialOptions{ HTTPHeader: http.Header{ - "Authorization": []string{auth}, + "Authorization": []string{cred.String()}, }, } diff --git a/charger/warp/digest_auth.go b/charger/warp/digest_auth.go deleted file mode 100644 index 19f3c962b..000000000 --- a/charger/warp/digest_auth.go +++ /dev/null @@ -1,132 +0,0 @@ -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) -} diff --git a/go.mod b/go.mod index 857aaeafd..10fe85cbc 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/hasura/go-graphql-client v0.15.1 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 + github.com/icholy/digest v1.1.0 github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/insomniacslk/tapo v1.0.2 github.com/itchyny/gojq v0.12.18 diff --git a/go.sum b/go.sum index 8f5a1345c..c726c3978 100644 --- a/go.sum +++ b/go.sum @@ -399,6 +399,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= +github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -1066,6 +1068,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=