Add remote access (#28688)
This commit is contained in:
parent
16f2f89d42
commit
9b8ac11777
25 changed files with 1778 additions and 14 deletions
166
server/remote/clients.go
Normal file
166
server/remote/clients.go
Normal 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
|
||||
}
|
||||
51
server/remote/ratelimit.go
Normal file
51
server/remote/ratelimit.go
Normal 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())
|
||||
}
|
||||
70
server/remote/ratelimit_test.go
Normal file
70
server/remote/ratelimit_test.go
Normal 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
228
server/remote/remote.go
Normal 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
199
server/remote/tunnel.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue