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=}", 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 }