187 lines
5.7 KiB
Go
187 lines
5.7 KiB
Go
package selectel
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/realmanual/traefik-selectel/internal/httpx"
|
|
)
|
|
|
|
// DefaultAuthURL — Keystone (identity v3) Selectel.
|
|
const DefaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3"
|
|
|
|
const (
|
|
tokenRefreshMargin = 10 * time.Minute
|
|
// Если expires_at не удалось разобрать: документация заявляет 24 часа, берём консервативно 1 час.
|
|
fallbackTokenTTL = time.Hour
|
|
)
|
|
|
|
// Credentials — учётные данные сервисного пользователя Selectel.
|
|
// Значения секретов не должны попадать в логи: String() их скрывает.
|
|
type Credentials struct {
|
|
Username string
|
|
Password string
|
|
AccountID string // имя домена Keystone (номер аккаунта)
|
|
ProjectID string
|
|
}
|
|
|
|
// String скрывает секреты.
|
|
func (c Credentials) String() string {
|
|
return fmt.Sprintf("Credentials{username=%q account=%q project=%q password=<redacted>}", c.Username, c.AccountID, c.ProjectID)
|
|
}
|
|
|
|
// Validate проверяет, что все поля заданы.
|
|
func (c Credentials) Validate() error {
|
|
var missing []string
|
|
if c.Username == "" {
|
|
missing = append(missing, "SELECTEL_USERNAME")
|
|
}
|
|
if c.Password == "" {
|
|
missing = append(missing, "SELECTEL_PASSWORD")
|
|
}
|
|
if c.AccountID == "" {
|
|
missing = append(missing, "SELECTEL_ACCOUNT_ID")
|
|
}
|
|
if c.ProjectID == "" {
|
|
missing = append(missing, "SELECTEL_PROJECT_ID")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("selectel: не заданы учётные данные: %s", strings.Join(missing, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TokenSource выдаёт IAM-токен проекта.
|
|
type TokenSource interface {
|
|
Token(ctx context.Context) (string, error)
|
|
// Invalidate сбрасывает кэш (например, после 401).
|
|
Invalidate()
|
|
}
|
|
|
|
// IAMTokenSource получает токен через Keystone и кэширует его в памяти.
|
|
type IAMTokenSource struct {
|
|
creds Credentials
|
|
authURL string
|
|
http *http.Client
|
|
retry httpx.Retrier
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
expires time.Time
|
|
}
|
|
|
|
// NewIAMTokenSource создаёт источник токенов. authURL пустой — DefaultAuthURL.
|
|
func NewIAMTokenSource(creds Credentials, authURL string, timeout time.Duration) (*IAMTokenSource, error) {
|
|
if err := creds.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if authURL == "" {
|
|
authURL = DefaultAuthURL
|
|
}
|
|
return &IAMTokenSource{
|
|
creds: creds,
|
|
authURL: strings.TrimRight(authURL, "/"),
|
|
http: &http.Client{Timeout: timeout},
|
|
retry: httpx.DefaultRetrier(),
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// Token возвращает актуальный токен, обновляя его за tokenRefreshMargin до истечения.
|
|
func (s *IAMTokenSource) Token(ctx context.Context) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.token != "" && s.now().Add(tokenRefreshMargin).Before(s.expires) {
|
|
return s.token, nil
|
|
}
|
|
tok, exp, err := s.fetch(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.token, s.expires = tok, exp
|
|
return tok, nil
|
|
}
|
|
|
|
// Invalidate сбрасывает кэшированный токен.
|
|
func (s *IAMTokenSource) Invalidate() {
|
|
s.mu.Lock()
|
|
s.token, s.expires = "", time.Time{}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
type authRequest struct {
|
|
Auth struct {
|
|
Identity struct {
|
|
Methods []string `json:"methods"`
|
|
Password struct {
|
|
User struct {
|
|
Name string `json:"name"`
|
|
Domain map[string]string `json:"domain"`
|
|
Password string `json:"password"`
|
|
} `json:"user"`
|
|
} `json:"password"`
|
|
} `json:"identity"`
|
|
Scope struct {
|
|
Project struct {
|
|
ID string `json:"id"`
|
|
Domain map[string]string `json:"domain"`
|
|
} `json:"project"`
|
|
} `json:"scope"`
|
|
} `json:"auth"`
|
|
}
|
|
|
|
func (s *IAMTokenSource) fetch(ctx context.Context) (string, time.Time, error) {
|
|
var ar authRequest
|
|
ar.Auth.Identity.Methods = []string{"password"}
|
|
ar.Auth.Identity.Password.User.Name = s.creds.Username
|
|
ar.Auth.Identity.Password.User.Password = s.creds.Password
|
|
ar.Auth.Identity.Password.User.Domain = map[string]string{"name": s.creds.AccountID}
|
|
ar.Auth.Scope.Project.ID = s.creds.ProjectID
|
|
ar.Auth.Scope.Project.Domain = map[string]string{"name": s.creds.AccountID}
|
|
payload, err := json.Marshal(ar)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
resp, err := s.retry.Do(ctx, s.http, func() (*http.Request, error) {
|
|
req, err := http.NewRequest(http.MethodPost, s.authURL+"/auth/tokens", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
return req, nil
|
|
})
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("selectel: получение IAM-токена: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := httpx.ReadBody(resp)
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
// Тело ответа Keystone намеренно не логируется.
|
|
return "", time.Time{}, fmt.Errorf("selectel: получение IAM-токена: статус %d", resp.StatusCode)
|
|
}
|
|
tok := resp.Header.Get("X-Subject-Token")
|
|
if tok == "" {
|
|
return "", time.Time{}, fmt.Errorf("selectel: в ответе нет заголовка X-Subject-Token")
|
|
}
|
|
exp := s.now().Add(fallbackTokenTTL)
|
|
var parsed struct {
|
|
Token struct {
|
|
ExpiresAt string `json:"expires_at"`
|
|
} `json:"token"`
|
|
}
|
|
if json.Unmarshal(body, &parsed) == nil && parsed.Token.ExpiresAt != "" {
|
|
if t, err := time.Parse(time.RFC3339Nano, parsed.Token.ExpiresAt); err == nil {
|
|
exp = t
|
|
}
|
|
}
|
|
return tok, exp, nil
|
|
}
|