evcc-io/util/sponsor/auth.go
Stefan 13215c74c2
Some checks failed
CodeQL / Analyze (go) (push) Failing after 1m16s
CodeQL / Analyze (actions) (push) Failing after 4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7s
Default / Clean (push) Has been cancelled
Default / Build (push) Has been cancelled
Default / Test (push) Has been cancelled
Default / Lint (push) Has been cancelled
Default / UI (push) Has been cancelled
Default / Integration (push) Has been cancelled
Default / Warm fork cache (Go) (push) Has been cancelled
Default / Warm fork cache (UI) (push) Has been cancelled
Remove sponsor token gating
Unlock all locally gated features without a sponsor token. The change sits
in util/sponsor/auth.go instead of the ~68 device constructors:

- Subject defaults to a non-empty value, so RedactedStatus reports an active
  sponsorship and the frontend unlocks isSponsor
- IsAuthorized always returns true, opening every caller including the
  modbus proxy and the optimizer gate
- ConfigureSponsorship still validates a configured token but never fails,
  so an expired token no longer aborts startup

Drops TestAlpitronicSponsorGate and TestSigenergyEVDCSponsorGate, which
asserted exactly the gate that is removed here.

Cloud-backed services (optimizer API, remote access, cloud vehicles,
telemetry) still require a real token - those are checked server side.

See FORK.md.
2026-08-30 10:41:18 +02:00

186 lines
4.6 KiB
Go

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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// unlocked is the sponsorship subject used by this fork. Sponsor gating is
// removed here, so Subject is never empty and IsAuthorized always holds.
const unlocked = "unlocked"
var (
mu sync.RWMutex
Subject = unlocked
Token string
ExpiresAt time.Time
)
func machineID() string {
return machine.ProtectedID("evcc-sponsor")
}
const unavailable = "sponsorship unavailable"
// startupTimeout leaves the network time to settle at boot; grpc retries dialing with backoff until deadline
const startupTimeout = 30 * time.Second
func IsAuthorized() bool {
// sponsorship gating removed in this fork
return true
}
func IsAuthorizedForApi() bool {
mu.RLock()
defer mu.RUnlock()
return IsAuthorized() && Subject != unavailable && Token != ""
}
// ConfigureSponsorship validates a sponsor token when one is configured, but
// never fails: sponsorship is not required in this fork. A valid token is
// still picked up so cloud-backed services keep working for real sponsors.
func ConfigureSponsorship(token string) error {
err := configureSponsorship(token)
mu.Lock()
defer mu.Unlock()
if err != nil {
// stay unlocked, but do not offer a rejected token to cloud services
Token = ""
}
if Subject == "" {
Subject = unlocked
}
return 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(), startupTimeout)
defer cancel()
res, err := client.IsAuthorized(ctx, &pb.AuthRequest{Token: token, MachineId: machineID()}, grpc.WaitForReady(true))
if err == nil && res.Authorized {
Subject = res.Subject
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:]
}
type Status struct {
Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt"`
ExpiresSoon bool `json:"expiresSoon,omitempty"`
Token string `json:"token,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),
}
}