282 lines
7.4 KiB
Go
282 lines
7.4 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// AccessToken represents a temporary access token for instance
|
|
type AccessToken struct {
|
|
Token string `json:"token"`
|
|
InstanceID int `json:"instance_id"`
|
|
UserID int `json:"user_id"`
|
|
InstanceType string `json:"instance_type"`
|
|
TargetPort int32 `json:"target_port"`
|
|
AccessURL string `json:"access_url"`
|
|
Upstream string `json:"upstream"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// InstanceAccessService manages instance access tokens
|
|
type InstanceAccessService struct {
|
|
tokens map[string]*AccessToken
|
|
mu sync.RWMutex
|
|
secret string
|
|
stopChan chan struct{}
|
|
}
|
|
|
|
type instanceAccessClaims struct {
|
|
InstanceID int `json:"instance_id"`
|
|
UserID int `json:"user_id"`
|
|
InstanceType string `json:"instance_type"`
|
|
TargetPort int32 `json:"target_port"`
|
|
AccessURL string `json:"access_url"`
|
|
Upstream string `json:"upstream"`
|
|
TokenType string `json:"token_type"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// NewInstanceAccessService creates a new instance access service
|
|
func NewInstanceAccessService() *InstanceAccessService {
|
|
service := &InstanceAccessService{
|
|
tokens: make(map[string]*AccessToken),
|
|
secret: getInstanceAccessTokenSecret(),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
// Start cleanup goroutine
|
|
go service.cleanupExpiredTokens()
|
|
|
|
return service
|
|
}
|
|
|
|
// GenerateToken generates a new access token for an instance
|
|
func (s *InstanceAccessService) GenerateToken(userID, instanceID int, instanceType string, accessURL string, upstream string, targetPort int32, duration time.Duration) (*AccessToken, error) {
|
|
now := time.Now()
|
|
expiresAt := now.Add(duration)
|
|
|
|
claims := instanceAccessClaims{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
InstanceType: instanceType,
|
|
TargetPort: targetPort,
|
|
AccessURL: accessURL,
|
|
Upstream: upstream,
|
|
TokenType: "instance_access",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenString, err := token.SignedString([]byte(s.secret))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
|
|
accessToken := &AccessToken{
|
|
Token: tokenString,
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
InstanceType: instanceType,
|
|
TargetPort: targetPort,
|
|
AccessURL: accessURL,
|
|
Upstream: upstream,
|
|
ExpiresAt: expiresAt,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
return accessToken, nil
|
|
}
|
|
|
|
// ValidateToken validates an access token
|
|
func (s *InstanceAccessService) ValidateToken(token string) (*AccessToken, error) {
|
|
accessToken, err := s.validateSignedToken(token)
|
|
if err == nil {
|
|
return accessToken, nil
|
|
}
|
|
|
|
legacyToken, legacyErr := s.validateLegacyToken(token)
|
|
if legacyErr == nil {
|
|
return legacyToken, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
func (s *InstanceAccessService) validateSignedToken(token string) (*AccessToken, error) {
|
|
parsed, err := jwt.ParseWithClaims(token, &instanceAccessClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, errors.New("unexpected signing method")
|
|
}
|
|
return []byte(s.secret), nil
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
claims, ok := parsed.Claims.(*instanceAccessClaims)
|
|
if !ok || !parsed.Valid {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
if claims.TokenType != "instance_access" || claims.AccessURL == "" {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
expiresAt := time.Time{}
|
|
if claims.ExpiresAt != nil {
|
|
expiresAt = claims.ExpiresAt.Time
|
|
}
|
|
createdAt := time.Time{}
|
|
if claims.IssuedAt != nil {
|
|
createdAt = claims.IssuedAt.Time
|
|
}
|
|
|
|
return &AccessToken{
|
|
Token: token,
|
|
InstanceID: claims.InstanceID,
|
|
UserID: claims.UserID,
|
|
InstanceType: claims.InstanceType,
|
|
TargetPort: claims.TargetPort,
|
|
AccessURL: claims.AccessURL,
|
|
Upstream: claims.Upstream,
|
|
ExpiresAt: expiresAt,
|
|
CreatedAt: createdAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *InstanceAccessService) validateLegacyToken(token string) (*AccessToken, error) {
|
|
s.mu.RLock()
|
|
accessToken, exists := s.tokens[token]
|
|
s.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
if time.Now().After(accessToken.ExpiresAt) {
|
|
s.mu.Lock()
|
|
delete(s.tokens, token)
|
|
s.mu.Unlock()
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
|
|
return accessToken, nil
|
|
}
|
|
|
|
// RevokeToken revokes an access token
|
|
func (s *InstanceAccessService) RevokeToken(token string) {
|
|
s.mu.Lock()
|
|
delete(s.tokens, token)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// GetAccessURL generates access URL for an instance
|
|
func (s *InstanceAccessService) GetAccessURL(instanceID int, instanceType string, podIP string, podName string) string {
|
|
// Generate access URL based on instance type
|
|
switch instanceType {
|
|
case "openclaw":
|
|
// OpenClaw desktop typically uses VNC or web interface
|
|
if podIP != "" {
|
|
return fmt.Sprintf("https://%s:3001/", podIP)
|
|
}
|
|
case "ubuntu", "debian", "centos":
|
|
// Linux desktops typically use noVNC or similar
|
|
if podIP != "" {
|
|
return fmt.Sprintf("http://%s:6901/vnc.html", podIP)
|
|
}
|
|
default:
|
|
// Default VNC access
|
|
if podIP != "" {
|
|
return fmt.Sprintf("http://%s:6080/vnc.html", podIP)
|
|
}
|
|
}
|
|
|
|
// Fallback to pod name based URL (for ingress/routing scenarios)
|
|
if podName != "" {
|
|
return fmt.Sprintf("/access/instance/%d", instanceID)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// GetAccessURLWithEndpoint generates access URL using the provided endpoint (nodeIP:port or direct IP)
|
|
func (s *InstanceAccessService) GetAccessURLWithEndpoint(instanceID int, instanceType string, endpoint string) string {
|
|
if endpoint == "" {
|
|
return ""
|
|
}
|
|
|
|
// Generate access URL based on instance type
|
|
switch instanceType {
|
|
case "openclaw":
|
|
// OpenClaw desktop typically uses VNC or web interface
|
|
return fmt.Sprintf("https://%s/", endpoint)
|
|
case "ubuntu", "debian", "centos":
|
|
// Linux desktops typically use noVNC or similar
|
|
return fmt.Sprintf("http://%s/vnc.html", endpoint)
|
|
default:
|
|
// Default VNC access
|
|
return fmt.Sprintf("http://%s/vnc.html", endpoint)
|
|
}
|
|
}
|
|
|
|
// GetProxyURL generates a proxied access URL
|
|
func (s *InstanceAccessService) GetProxyURL(instanceID int, token string) string {
|
|
return fmt.Sprintf("/api/v1/instances/%d/access?token=%s", instanceID, token)
|
|
}
|
|
|
|
// cleanupExpiredTokens periodically removes expired tokens
|
|
func (s *InstanceAccessService) cleanupExpiredTokens() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-s.stopChan:
|
|
return
|
|
case <-ticker.C:
|
|
now := time.Now()
|
|
s.mu.Lock()
|
|
for token, accessToken := range s.tokens {
|
|
if now.After(accessToken.ExpiresAt) {
|
|
delete(s.tokens, token)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop terminates the background cleanup goroutine.
|
|
func (s *InstanceAccessService) Stop() {
|
|
close(s.stopChan)
|
|
}
|
|
|
|
// GetActiveTokenCount returns the number of active tokens
|
|
func (s *InstanceAccessService) GetActiveTokenCount() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.tokens)
|
|
}
|
|
|
|
func getInstanceAccessTokenSecret() string {
|
|
if secret := os.Getenv("INSTANCE_ACCESS_TOKEN_SECRET"); secret != "" {
|
|
return secret
|
|
}
|
|
if secret := os.Getenv("JWT_SECRET"); secret != "" {
|
|
return secret
|
|
}
|
|
return "clawreef-instance-access-secret-change-in-production"
|
|
}
|