Add remote access (#28688)

This commit is contained in:
Michael Geers 2026-04-13 22:21:33 +02:00 • committed by GitHub
parent 16f2f89d42
commit 9b8ac11777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1778 additions and 14 deletions

166
server/remote/clients.go Normal file
View file

@ -0,0 +1,166 @@
package remote
import (
"crypto/rand"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/samber/lo"
"github.com/sethvargo/go-password/password"
"golang.org/x/crypto/bcrypt"
)
// dummyHash is a bcrypt hash of a random value, used to make the
// "unknown user" path take the same time as a real password check and
// prevent username enumeration via timing side channels.
var dummyHash []byte
func init() {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost)
if err != nil {
panic(err)
}
dummyHash = h
}
// Client is a single tunnel basic-auth credential used by a remote client.
type Client struct {
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type persistedClient struct {
Client
Hash string `json:"hash"`
}
// loadClients reads the persisted client list.
func loadClients() []persistedClient {
var res []persistedClient
_ = settings.Json(keys.RemoteClients, &res)
return res
}
// saveClients persists the given client list.
func saveClients(list []persistedClient) error {
return settings.SetJson(keys.RemoteClients, list)
}
// generatePassword returns a crypto-random alphanumeric password
// with 20 characters including 4 digits (~96 bits of entropy).
func generatePassword() (string, error) {
return password.Generate(20, 4, 0, false, false)
}
// Clients returns the list of configured clients (without password hashes).
func (r *Remote) Clients() []Client {
return lo.Map(loadClients(), func(c persistedClient, _ int) Client {
return c.Client
})
}
// CreateClient creates a new client with an auto-generated password.
// expiresIn <= 0 means the client never expires.
// Returns the cleartext password (shown to the user only once).
func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) {
username = strings.TrimSpace(username)
if username == "" {
return Client{}, "", errors.New("username required")
}
// RFC 7617: ":" is the basic-auth separator; reject control chars too.
for _, r := range username {
if r == ':' || r < 0x20 || r == 0x7f {
return Client{}, "", errors.New("username contains invalid characters")
}
}
var expires *time.Time
if expiresIn > 0 {
expires = new(time.Now().Add(expiresIn))
}
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
for _, c := range list {
if c.Username == username {
return Client{}, "", fmt.Errorf("client %q already exists", username)
}
}
password, err := generatePassword()
if err != nil {
return Client{}, "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return Client{}, "", err
}
c := persistedClient{
Client: Client{
Username: username,
CreatedAt: time.Now(),
ExpiresAt: expires,
},
Hash: string(hash),
}
list = append(list, c)
if err := saveClients(list); err != nil {
return Client{}, "", err
}
return c.Client, password, nil
}
// DeleteClient removes a client by username.
func (r *Remote) DeleteClient(username string) error {
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
idx := slices.IndexFunc(list, func(c persistedClient) bool {
return c.Username == username
})
if idx == -1 {
return fmt.Errorf("client %s not found", username)
}
return saveClients(slices.Delete(list, idx, idx+1))
}
// Authenticate validates basic-auth credentials. Always runs bcrypt
// (against a dummy hash on miss) to prevent username enumeration via timing.
func (r *Remote) Authenticate(username, password string) bool {
hash := dummyHash
var found *persistedClient
for _, c := range loadClients() {
if c.Username == username {
found = &c
hash = []byte(c.Hash)
break
}
}
valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
if !valid || found == nil {
return false
}
if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) {
return false
}
return true
}

View file

@ -0,0 +1,51 @@
package remote
import (
"sync"
"time"
)
// authRateLimiter tracks failed authentication attempts in a sliding window.
// When the failure count exceeds the threshold, further attempts are blocked
// to prevent brute-force attacks.
type authRateLimiter struct {
mu sync.Mutex
failures []time.Time
window time.Duration
max int
now func() time.Time
}
func newAuthRateLimiter() *authRateLimiter {
return &authRateLimiter{
window: time.Minute,
max: 10,
now: time.Now,
}
}
// allow checks whether an authentication attempt should proceed.
func (rl *authRateLimiter) allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := rl.now().Add(-rl.window)
// prune old entries
valid := rl.failures[:0]
for _, t := range rl.failures {
if t.After(cutoff) {
valid = append(valid, t)
}
}
rl.failures = valid
return len(rl.failures) < rl.max
}
// fail records a failed authentication attempt.
func (rl *authRateLimiter) fail() {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.failures = append(rl.failures, rl.now())
}

View file

@ -0,0 +1,70 @@
package remote
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuthRateLimiter(t *testing.T) {
t.Run("allows requests under threshold", func(t *testing.T) {
rl := newAuthRateLimiter()
for range rl.max {
assert.True(t, rl.allow())
rl.fail()
}
})
t.Run("blocks after threshold", func(t *testing.T) {
rl := newAuthRateLimiter()
for range rl.max {
rl.fail()
}
assert.False(t, rl.allow())
})
t.Run("recovers after window expires", func(t *testing.T) {
now := time.Now()
var mu sync.Mutex
rl := newAuthRateLimiter()
rl.now = func() time.Time {
mu.Lock()
defer mu.Unlock()
return now
}
for range rl.max {
rl.fail()
}
assert.False(t, rl.allow())
// advance past window
mu.Lock()
now = now.Add(rl.window + time.Second)
mu.Unlock()
assert.True(t, rl.allow())
})
t.Run("successful auth does not count as failure", func(t *testing.T) {
rl := newAuthRateLimiter()
// fill up to max-1 failures
for range rl.max - 1 {
rl.fail()
}
// allow should still work (no fail() call = successful auth)
assert.True(t, rl.allow())
// still under threshold
assert.True(t, rl.allow())
})
}

228
server/remote/remote.go Normal file
View file

@ -0,0 +1,228 @@
package remote
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
)
// Settings is the persisted remote access configuration.
type Settings struct {
Enabled bool `json:"enabled"`
URL string `json:"url,omitempty"`
Token string `json:"token,omitempty"`
TunnelURL string `json:"tunnelUrl,omitempty"`
}
// Remote manages the remote access tunnel lifecycle.
type Remote struct {
mu sync.Mutex
cloudHost string
settings Settings
tunnel *Tunnel
httpHandler http.Handler
log *util.Logger
publisher chan<- util.Param
lastSeen map[string]time.Time // persisted: username → last activity
connected map[string]int // in-memory: active connection count per user
}
// New creates a new Remote manager, loads persisted settings, and connects if enabled.
func New(cloudHost string, httpHandler http.Handler, valueChan chan<- util.Param) *Remote {
r := &Remote{
cloudHost: cloudHost,
httpHandler: httpHandler,
log: util.NewLogger("remote"),
publisher: valueChan,
lastSeen: make(map[string]time.Time),
connected: make(map[string]int),
}
// load saved settings
_ = settings.Json(keys.Remote, &r.settings)
_ = settings.Json(keys.RemoteLastSeen, &r.lastSeen)
if r.settings.Enabled && r.settings.Token != "" {
go r.connect()
}
shutdown.Register(r.disconnect)
go func() {
for range time.Tick(time.Minute) {
r.publish()
}
}()
return r
}
// Enable enables or disables remote access. When enabling for the first time,
// it registers with the cloud to obtain a URL and token.
func (r *Remote) Enable(enable bool) error {
r.mu.Lock()
r.settings.Enabled = enable
r.saveSettings()
r.mu.Unlock()
if enable {
// TODO why do we need a go routine for this?
go r.connect()
} else {
r.disconnect()
}
r.publish()
return nil
}
// Enabled returns whether remote access is enabled.
func (r *Remote) Enabled() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.settings.Enabled
}
func (r *Remote) connect() {
r.mu.Lock()
token := r.settings.Token
r.mu.Unlock()
if token == "" {
if err := r.register(); err != nil {
r.log.ERROR.Printf("registration failed: %v", err)
return
}
}
r.log.INFO.Printf("remote access via %s", r.settings.URL)
tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish)
r.mu.Lock()
r.tunnel = tunnel
r.mu.Unlock()
// blocks until disconnected
tunnel.run()
}
func (r *Remote) disconnect() {
r.mu.Lock()
defer r.mu.Unlock()
if r.tunnel != nil {
r.tunnel.Close()
r.tunnel = nil
}
}
type registerRequest struct {
SponsorToken string `json:"sponsorToken"`
}
type registerResponse struct {
URL string `json:"url"`
Token string `json:"token"`
TunnelURL string `json:"tunnelUrl"`
}
// register calls the cloud registration endpoint and persists the result.
func (r *Remote) register() error {
uri := fmt.Sprintf("https://%s/api/register", r.cloudHost)
data := registerRequest{SponsorToken: sponsor.Token}
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
var res registerResponse
client := request.NewHelper(r.log)
if err := client.DoJSON(req, &res); err != nil {
return err
}
r.mu.Lock()
r.settings.URL = res.URL
r.settings.Token = res.Token
r.settings.TunnelURL = res.TunnelURL
r.saveSettings()
r.mu.Unlock()
r.log.INFO.Printf("registered as %s", res.URL)
return nil
}
// TrackActivity tracks remote client connections and disconnections.
func (r *Remote) TrackActivity(username string, active bool) {
r.mu.Lock()
defer r.mu.Unlock()
if active {
r.lastSeen[username] = time.Now()
r.connected[username]++
} else if r.connected[username] > 0 {
r.connected[username]--
}
}
// saveSettings persists the current settings. Must be called with mu held.
func (r *Remote) saveSettings() {
if err := settings.SetJson(keys.Remote, r.settings); err != nil {
r.log.ERROR.Println(err)
}
}
// ConfigStatus returns the current remote access config and status.
func (r *Remote) ConfigStatus() globalconfig.ConfigStatus {
r.mu.Lock()
defer r.mu.Unlock()
connected := r.tunnel != nil && r.tunnel.IsConnected()
loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked()
return globalconfig.ConfigStatus{
Config: struct {
Enabled bool `json:"enabled"`
}{
Enabled: r.settings.Enabled,
},
Status: struct {
Connected bool `json:"connected"`
URL string `json:"url,omitempty"`
LoginBlocked bool `json:"loginBlocked"`
LastSeen map[string]time.Time `json:"lastSeen,omitempty"`
}{
Connected: connected,
URL: r.settings.URL,
LoginBlocked: loginBlocked,
LastSeen: r.lastSeen,
},
}
}
// publish sends the current status to the UI via the value channel.
func (r *Remote) publish() {
if r.publisher == nil {
return
}
// refresh lastSeen for open connections (auth only fires once)
r.mu.Lock()
now := time.Now()
for user, count := range r.connected {
if count > 0 {
r.lastSeen[user] = now
}
}
_ = settings.SetJson(keys.RemoteLastSeen, r.lastSeen)
r.mu.Unlock()
r.publisher <- util.Param{Key: keys.Remote, Val: r.ConfigStatus()}
}

199
server/remote/tunnel.go Normal file
View file

@ -0,0 +1,199 @@
package remote
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/coder/websocket"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/hashicorp/yamux"
)
// Tunnel manages a WebSocket+yamux tunnel to the cloud proxy.
type Tunnel struct {
tunnelURL string
token string
httpHandler http.Handler
authenticate func(user, pass string) bool
trackActivity func(username string, active bool)
log *util.Logger
cancel func()
onStateChange func()
rateLimiter *authRateLimiter
mu sync.Mutex
session *yamux.Session
}
// NewTunnel creates a new tunnel client.
func NewTunnel(tunnelURL, token string, httpHandler http.Handler, authenticate func(user, pass string) bool, trackActivity func(string, bool), log *util.Logger, onStateChange func()) *Tunnel {
return &Tunnel{
tunnelURL: tunnelURL,
token: token,
httpHandler: httpHandler,
authenticate: authenticate,
trackActivity: trackActivity,
log: log,
onStateChange: onStateChange,
rateLimiter: newAuthRateLimiter(),
}
}
// run establishes the tunnel and reconnects on failure.
func (t *Tunnel) run() {
bo := backoff.NewExponentialBackOff(
backoff.WithInitialInterval(time.Second),
backoff.WithMaxInterval(60*time.Second),
backoff.WithMaxElapsedTime(0),
)
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
for {
ok, err := t.connect(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
t.log.ERROR.Printf("tunnel: %v", err)
}
// reset backoff after successful connection
if ok {
bo.Reset()
}
select {
case <-ctx.Done():
return
case <-time.After(bo.NextBackOff()):
}
}
}
func (t *Tunnel) connect(ctx context.Context) (bool, error) {
conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{
HTTPHeader: http.Header{
"Authorization": []string{"Bearer " + t.token},
"X-Sponsor-Token": []string{sponsor.Token},
},
})
if err != nil {
return false, fmt.Errorf("websocket dial: %w", err)
}
netConn := websocket.NetConn(ctx, conn, websocket.MessageBinary)
config := yamux.DefaultConfig()
config.LogOutput = t.log.TRACE.Writer()
session, err := yamux.Client(netConn, config)
if err != nil {
netConn.Close() // closes the underlying socket connection
return false, fmt.Errorf("yamux client: %w", err)
}
t.changeState(session, nil)
// accept streams from the proxy
srv := &http.Server{
Handler: t.basicAuthMiddleware(t.httpHandler),
}
if err := srv.Serve(session); err != nil {
t.changeState(nil, err)
}
return true, nil
}
func (t *Tunnel) changeState(session *yamux.Session, err error) {
t.mu.Lock()
t.session = session
t.mu.Unlock()
if t.onStateChange != nil {
t.onStateChange()
}
if session != nil {
t.log.INFO.Println("tunnel connected")
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) {
t.log.INFO.Println("tunnel disconnected")
} else {
t.log.INFO.Println("tunnel disconnected:", err)
}
}
}
// IsConnected returns whether the tunnel is currently connected.
func (t *Tunnel) IsConnected() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.session != nil
}
// LoginBlocked returns whether login attempts are currently blocked by the rate limiter.
func (t *Tunnel) LoginBlocked() bool {
return !t.rateLimiter.allow()
}
// Close tears down the tunnel.
func (t *Tunnel) Close() {
t.mu.Lock()
defer t.mu.Unlock()
// close websocket; produces io.EOF in yamux which it handles silently
if t.session != nil {
t.session.Close() // closes the underlying socket connection
}
if t.cancel != nil {
t.cancel()
t.cancel = nil
}
}
// basicAuthMiddleware wraps a handler with HTTP basic auth, validating
// credentials against the given authenticate function per request.
// It rate-limits failed attempts to prevent brute-force attacks.
func (t *Tunnel) basicAuthMiddleware(next http.Handler) http.Handler {
rejectAuth := func(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="evcc"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || t.authenticate == nil {
rejectAuth(w)
return
}
if !t.rateLimiter.allow() {
t.log.INFO.Printf("login blocked for %q (rate limited)", user)
http.Error(w, "Too many failed login attempts. Try again in 1 minute.", http.StatusTooManyRequests)
return
}
if !t.authenticate(user, pass) {
t.rateLimiter.fail()
t.log.INFO.Printf("failed login attempt for %q", user)
rejectAuth(w)
return
}
if t.trackActivity != nil {
t.trackActivity(user, true)
defer t.trackActivity(user, false) // long-running requests (ws)
}
next.ServeHTTP(w, r)
})
}