package sponsor // LICENSE // Copyright (c) evcc.io (andig, naltatis, premultiply) // This module is NOT covered by the MIT license. All rights reserved. // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. import ( "context" "errors" "fmt" "os" "strings" "sync" "time" "github.com/evcc-io/evcc/api/proto/pb" "github.com/evcc-io/evcc/util/cloud" "github.com/evcc-io/evcc/util/machine" "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) var ( mu sync.RWMutex Subject, Token, ActivationKey string ExpiresAt time.Time ) func machineID() string { return machine.ProtectedID("evcc-sponsor") } const unavailable = "sponsorship unavailable" func IsAuthorized() bool { mu.RLock() defer mu.RUnlock() return len(Subject) > 0 } func IsAuthorizedForApi() bool { mu.RLock() defer mu.RUnlock() return IsAuthorized() && Subject != unavailable && Token != "" } // ActivateSponsorship activates a license key with email and returns the JWT token func ActivateSponsorship(licenseKey, email string) (string, error) { conn, err := cloud.Connection() if err != nil { return "", fmt.Errorf("connection failed: %w", err) } client := pb.NewAuthClient(conn) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() res, err := client.Activate(ctx, &pb.ActivateRequest{ Key: licenseKey, Email: email, MachineId: machineID(), }) if err != nil { return "", fmt.Errorf("activation failed: %w", err) } if res.Error != "" { return "", fmt.Errorf("%s", res.Error) } return res.Token, nil } // check and set sponsorship token func ConfigureSponsorship(token string) error { mu.Lock() defer mu.Unlock() if token == "" { if sub := checkVictron(); sub != "" { Subject = sub return nil } if os.Getenv("HEMSPRO") != "" { if sub := checkHemsPro(); sub != "" { Subject = sub return nil } } var err error if token, err = checkPulsares(); token == "" || err != nil { return err } } Token = token // check expiry locally to avoid cloud roundtrip var claims jwt.RegisteredClaims if _, _, err := jwt.NewParser().ParseUnverified(token, &claims); err == nil && claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) { return errors.New("token is expired - get a fresh one from https://sponsor.evcc.io") } conn, err := cloud.Connection() if err != nil { return err } client := pb.NewAuthClient(conn) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() res, err := client.IsAuthorized(ctx, &pb.AuthRequest{Token: token}) if err == nil && res.Authorized { Subject = res.Subject ActivationKey = res.ActivationKey ExpiresAt = res.ExpiresAt.AsTime() } if err != nil { if s, ok := status.FromError(err); ok && s.Code() != codes.Unknown { Subject = unavailable err = nil } else { if strings.Contains(err.Error(), "token is expired") { err = fmt.Errorf("%w - get a fresh one from https://sponsor.evcc.io", err) } else { err = fmt.Errorf("sponsortoken: %w", err) } } } return err } // redactToken returns a redacted version of the token showing only start and end characters func redactToken(token string) string { if len(token) <= 12 { return "" } return token[:6] + "......." + token[len(token)-6:] } // redactKey returns a redacted version of the activation key showing only the first segment func redactKey(key string) string { if idx := strings.Index(key, "-"); idx > 0 { return key[:idx] + "-XXXXX-XXXXX-XXXXX-XXXXX" } return "" } type Status struct { Name string `json:"name"` ExpiresAt time.Time `json:"expiresAt,omitempty"` ExpiresSoon bool `json:"expiresSoon,omitempty"` Token string `json:"token,omitempty"` ActivationKey string `json:"activationKey,omitempty"` } // RedactedStatus returns the sponsorship status func RedactedStatus() Status { mu.RLock() defer mu.RUnlock() var expiresSoon bool if d := time.Until(ExpiresAt); d < 30*24*time.Hour && d > 0 { expiresSoon = true } return Status{ Name: Subject, ExpiresAt: ExpiresAt, ExpiresSoon: expiresSoon, Token: redactToken(Token), ActivationKey: redactKey(ActivationKey), } }