feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user