Files
traefik-selectel/internal/selectel/auth.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

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
}