509 lines
18 KiB
Go
509 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
)
|
||
|
||
// RegistryClient представляет клиент для работы с Docker Registry
|
||
type RegistryClient struct {
|
||
BaseURL string
|
||
Username string
|
||
Password string
|
||
Client *http.Client
|
||
Debug bool
|
||
token string // кэшированный токен
|
||
}
|
||
|
||
// NewRegistryClient создает новый клиент для работы с Registry
|
||
func NewRegistryClient(baseURL, username, password string, debug bool) *RegistryClient {
|
||
return &RegistryClient{
|
||
BaseURL: strings.TrimSuffix(baseURL, "/"),
|
||
Username: username,
|
||
Password: password,
|
||
Client: &http.Client{},
|
||
Debug: debug,
|
||
}
|
||
}
|
||
|
||
// authenticate выполняет авторизацию и возвращает токен
|
||
// scope - опциональный scope для запроса токена (например, "registry:catalog:*")
|
||
func (r *RegistryClient) authenticate(scope string) (string, error) {
|
||
authURL := fmt.Sprintf("%s/v2/", r.BaseURL)
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Попытка авторизации на: %s\n", authURL)
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", authURL, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка создания запроса: %w", err)
|
||
}
|
||
|
||
// Добавляем Basic Auth заголовок
|
||
auth := base64.StdEncoding.EncodeToString([]byte(r.Username + ":" + r.Password))
|
||
req.Header.Set("Authorization", "Basic "+auth)
|
||
|
||
resp, err := r.Client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка выполнения запроса: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Статус ответа: %d\n", resp.StatusCode)
|
||
fmt.Printf("[DEBUG] Заголовки: %v\n", resp.Header)
|
||
fmt.Printf("[DEBUG] Тело ответа: %s\n", string(body))
|
||
}
|
||
|
||
// Если получили 401, проверяем заголовок WWW-Authenticate для Bearer token
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] WWW-Authenticate заголовок: %s\n", wwwAuth)
|
||
}
|
||
|
||
if wwwAuth != "" {
|
||
if strings.HasPrefix(wwwAuth, "Bearer") {
|
||
// Извлекаем realm, service и scope из заголовка
|
||
realm, service, scopeFromHeader := parseWwwAuthenticate(wwwAuth)
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Парсинг WWW-Authenticate: realm=%s, service=%s, scope=%s\n", realm, service, scopeFromHeader)
|
||
}
|
||
if realm != "" {
|
||
// Используем переданный scope, если он указан, иначе используем из заголовка
|
||
tokenScope := scope
|
||
if tokenScope == "" {
|
||
tokenScope = scopeFromHeader
|
||
}
|
||
token, err := r.getBearerToken(realm, service, tokenScope)
|
||
if err != nil {
|
||
return "", fmt.Errorf("не удалось получить Bearer token: %w", err)
|
||
}
|
||
return token, nil
|
||
}
|
||
} else if strings.HasPrefix(wwwAuth, "Basic") {
|
||
// Если требуется Basic Auth, но мы его уже отправили, значит неправильные учетные данные
|
||
return "", fmt.Errorf("неверные учетные данные (Basic Auth отклонен). Проверьте username и password")
|
||
}
|
||
}
|
||
|
||
// Если не удалось получить Bearer token и получили 401
|
||
return "", fmt.Errorf("ошибка авторизации: статус %d, WWW-Authenticate: %s, ответ: %s",
|
||
resp.StatusCode, wwwAuth, string(body))
|
||
}
|
||
|
||
// Если авторизация прошла успешно (200)
|
||
if resp.StatusCode == http.StatusOK {
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Авторизация успешна (Basic Auth)\n")
|
||
}
|
||
return "", nil // Basic Auth достаточно
|
||
}
|
||
|
||
return "", fmt.Errorf("неожиданный статус ответа: %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
// parseWwwAuthenticate парсит заголовок WWW-Authenticate
|
||
func parseWwwAuthenticate(header string) (realm, service, scope string) {
|
||
// Формат: Bearer realm="...",service="...",scope="..."
|
||
// Убираем префикс "Bearer " если есть
|
||
header = strings.TrimPrefix(header, "Bearer ")
|
||
header = strings.TrimSpace(header)
|
||
|
||
// Парсим параметры, учитывая что значения могут быть в кавычках
|
||
parts := strings.Split(header, ",")
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if strings.HasPrefix(part, "realm=") {
|
||
realm = strings.Trim(strings.TrimPrefix(part, "realm="), "\"")
|
||
} else if strings.HasPrefix(part, "service=") {
|
||
service = strings.Trim(strings.TrimPrefix(part, "service="), "\"")
|
||
} else if strings.HasPrefix(part, "scope=") {
|
||
scope = strings.Trim(strings.TrimPrefix(part, "scope="), "\"")
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// getBearerToken получает Bearer token из auth service
|
||
func (r *RegistryClient) getBearerToken(realm, service, scope string) (string, error) {
|
||
// Формируем URL для получения токена
|
||
tokenURL := realm
|
||
if !strings.Contains(tokenURL, "?") {
|
||
tokenURL += "?"
|
||
} else {
|
||
tokenURL += "&"
|
||
}
|
||
|
||
// Добавляем параметры
|
||
params := []string{}
|
||
if service != "" {
|
||
params = append(params, "service="+service)
|
||
}
|
||
// Добавляем scope только если он указан (не добавляем по умолчанию)
|
||
// Для GitLab Registry нужно запрашивать токен с конкретным scope
|
||
if scope != "" {
|
||
params = append(params, "scope="+scope)
|
||
}
|
||
|
||
tokenURL += strings.Join(params, "&")
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Запрос токена по URL: %s\n", tokenURL)
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", tokenURL, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка создания запроса токена: %w", err)
|
||
}
|
||
|
||
// Добавляем Basic Auth для получения токена
|
||
auth := base64.StdEncoding.EncodeToString([]byte(r.Username + ":" + r.Password))
|
||
req.Header.Set("Authorization", "Basic "+auth)
|
||
|
||
resp, err := r.Client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка получения токена: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Статус ответа токена: %d\n", resp.StatusCode)
|
||
fmt.Printf("[DEBUG] Тело ответа токена: %s\n", string(body))
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Errorf("ошибка получения токена: статус %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
// Пробуем разные форматы ответа
|
||
var tokenResp struct {
|
||
Token string `json:"token"`
|
||
AccessToken string `json:"access_token"`
|
||
}
|
||
|
||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||
return "", fmt.Errorf("ошибка парсинга токена: %w, тело: %s", err, string(body))
|
||
}
|
||
|
||
// Используем token или access_token
|
||
token := tokenResp.Token
|
||
if token == "" {
|
||
token = tokenResp.AccessToken
|
||
}
|
||
|
||
if token == "" {
|
||
return "", fmt.Errorf("токен не найден в ответе: %s", string(body))
|
||
}
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Токен успешно получен (длина: %d)\n", len(token))
|
||
// Декодируем JWT для отладки (только payload, без проверки подписи)
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) >= 2 {
|
||
// Декодируем payload (вторая часть)
|
||
payload := parts[1]
|
||
// Добавляем padding если нужно
|
||
switch len(payload) % 4 {
|
||
case 2:
|
||
payload += "=="
|
||
case 3:
|
||
payload += "="
|
||
}
|
||
decoded, err := base64.RawURLEncoding.DecodeString(payload)
|
||
if err == nil {
|
||
var claims map[string]interface{}
|
||
if json.Unmarshal(decoded, &claims) == nil {
|
||
fmt.Printf("[DEBUG] JWT claims: %+v\n", claims)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return token, nil
|
||
}
|
||
|
||
// ListRepositories возвращает список всех репозиториев в Registry
|
||
func (r *RegistryClient) ListRepositories() ([]string, error) {
|
||
// Формируем запрос к API для получения списка репозиториев
|
||
reposURL := fmt.Sprintf("%s/v2/_catalog", r.BaseURL)
|
||
|
||
// Поддерживаем пагинацию
|
||
var allRepos []string
|
||
url := reposURL
|
||
|
||
for {
|
||
req, err := http.NewRequest("GET", url, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка создания запроса: %w", err)
|
||
}
|
||
|
||
// Добавляем авторизацию
|
||
if r.token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||
} else {
|
||
auth := base64.StdEncoding.EncodeToString([]byte(r.Username + ":" + r.Password))
|
||
req.Header.Set("Authorization", "Basic "+auth)
|
||
}
|
||
|
||
resp, err := r.Client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка выполнения запроса: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// Если получили 401, нужно получить токен с правильным scope
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Получен 401 при запросе каталога, WWW-Authenticate: %s\n", wwwAuth)
|
||
}
|
||
|
||
// Если есть WWW-Authenticate с scope, используем его для получения токена
|
||
if wwwAuth != "" && strings.HasPrefix(wwwAuth, "Bearer") {
|
||
realm, service, scopeFromHeader := parseWwwAuthenticate(wwwAuth)
|
||
if realm != "" {
|
||
// Используем scope из заголовка, если он есть
|
||
scope := scopeFromHeader
|
||
if scope == "" {
|
||
// Если scope не указан в заголовке, используем стандартный для каталога
|
||
scope = "registry:catalog:*"
|
||
}
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Запрос токена с scope: %s, service: %s\n", scope, service)
|
||
}
|
||
|
||
// Получаем токен напрямую через getBearerToken, так как authenticate делает лишний запрос
|
||
token, err := r.getBearerToken(realm, service, scope)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка получения токена: %w, оригинальный ответ: %s", err, string(body))
|
||
}
|
||
r.token = token
|
||
|
||
// Повторяем запрос с новым токеном
|
||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||
resp, err = r.Client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка выполнения запроса после получения токена: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
}
|
||
}
|
||
|
||
// Если все еще 401, возвращаем ошибку
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("ошибка авторизации при запросе каталога: статус %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("ошибка получения списка репозиториев: статус %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
var catalog struct {
|
||
Repositories []string `json:"repositories"`
|
||
}
|
||
|
||
if err := json.NewDecoder(resp.Body).Decode(&catalog); err != nil {
|
||
return nil, fmt.Errorf("ошибка парсинга ответа: %w", err)
|
||
}
|
||
|
||
allRepos = append(allRepos, catalog.Repositories...)
|
||
|
||
// Проверяем наличие ссылки на следующую страницу
|
||
link := resp.Header.Get("Link")
|
||
if link == "" {
|
||
break
|
||
}
|
||
|
||
// Парсим ссылку на следующую страницу
|
||
nextURL := extractNextURL(link)
|
||
if nextURL == "" {
|
||
break
|
||
}
|
||
|
||
url = r.BaseURL + nextURL
|
||
}
|
||
|
||
return allRepos, nil
|
||
}
|
||
|
||
// extractNextURL извлекает URL следующей страницы из заголовка Link
|
||
func extractNextURL(link string) string {
|
||
// Формат: <url>; rel="next"
|
||
parts := strings.Split(link, ";")
|
||
if len(parts) > 0 {
|
||
url := strings.TrimSpace(parts[0])
|
||
url = strings.Trim(url, "<>")
|
||
return url
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ListTags возвращает список тегов для конкретного репозитория
|
||
func (r *RegistryClient) ListTags(repository string) ([]string, error) {
|
||
tagsURL := fmt.Sprintf("%s/v2/%s/tags/list", r.BaseURL, repository)
|
||
|
||
req, err := http.NewRequest("GET", tagsURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка создания запроса: %w", err)
|
||
}
|
||
|
||
if r.token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||
} else {
|
||
auth := base64.StdEncoding.EncodeToString([]byte(r.Username + ":" + r.Password))
|
||
req.Header.Set("Authorization", "Basic "+auth)
|
||
}
|
||
|
||
resp, err := r.Client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка выполнения запроса: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// Если получили 401, получаем токен с правильным scope для этого репозитория
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
||
|
||
if wwwAuth != "" && strings.HasPrefix(wwwAuth, "Bearer") {
|
||
realm, service, scopeFromHeader := parseWwwAuthenticate(wwwAuth)
|
||
if realm != "" {
|
||
scope := scopeFromHeader
|
||
if scope == "" {
|
||
// Если scope не указан, используем scope для конкретного репозитория
|
||
scope = fmt.Sprintf("repository:%s:pull", repository)
|
||
}
|
||
|
||
if r.Debug {
|
||
fmt.Printf("[DEBUG] Запрос токена для репозитория с scope: %s, service: %s\n", scope, service)
|
||
}
|
||
|
||
// Получаем токен напрямую через getBearerToken
|
||
token, err := r.getBearerToken(realm, service, scope)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка получения токена: %w, оригинальный ответ: %s", err, string(body))
|
||
}
|
||
r.token = token
|
||
|
||
// Повторяем запрос с новым токеном
|
||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||
resp, err = r.Client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка выполнения запроса после получения токена: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
}
|
||
}
|
||
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("ошибка авторизации при запросе тегов: статус %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("ошибка получения списка тегов: статус %d, тело: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
var tagsResp struct {
|
||
Name string `json:"name"`
|
||
Tags []string `json:"tags"`
|
||
}
|
||
|
||
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
|
||
return nil, fmt.Errorf("ошибка парсинга ответа: %w", err)
|
||
}
|
||
|
||
return tagsResp.Tags, nil
|
||
}
|
||
|
||
func main() {
|
||
// Парсим аргументы командной строки
|
||
var configPath string
|
||
flag.StringVar(&configPath, "config", "config.yaml", "Путь к файлу конфигурации")
|
||
flag.StringVar(&configPath, "c", "config.yaml", "Путь к файлу конфигурации (краткая форма)")
|
||
flag.Parse()
|
||
|
||
// Загружаем конфигурацию
|
||
config, err := LoadConfig(configPath)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Ошибка загрузки конфигурации: %v\n", err)
|
||
fmt.Fprintf(os.Stderr, "Использование: %s [-config=config.yaml]\n", os.Args[0])
|
||
os.Exit(1)
|
||
}
|
||
|
||
// Создаем клиент
|
||
client := NewRegistryClient(
|
||
config.Registry.URL,
|
||
config.Registry.Username,
|
||
config.Registry.Password,
|
||
config.Debug,
|
||
)
|
||
|
||
fmt.Printf("Подключение к Docker Registry: %s\n", config.Registry.URL)
|
||
fmt.Printf("Пользователь: %s\n", config.Registry.Username)
|
||
if client.Debug {
|
||
fmt.Println("Режим отладки включен")
|
||
}
|
||
fmt.Println("Получение списка репозиториев...\n")
|
||
|
||
// Получаем список репозиториев
|
||
repos, err := client.ListRepositories()
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
if len(repos) == 0 {
|
||
fmt.Println("Репозитории не найдены.")
|
||
return
|
||
}
|
||
|
||
fmt.Printf("Найдено репозиториев: %d\n\n", len(repos))
|
||
fmt.Println("Список репозиториев:")
|
||
fmt.Println(strings.Repeat("=", 80))
|
||
|
||
for i, repo := range repos {
|
||
fmt.Printf("%d. %s\n", i+1, repo)
|
||
}
|
||
|
||
// Опционально: показать теги для каждого репозитория
|
||
fmt.Println("\n" + strings.Repeat("=", 80))
|
||
fmt.Println("\nДетальная информация (репозиторий -> теги):")
|
||
fmt.Println(strings.Repeat("=", 80))
|
||
|
||
for _, repo := range repos {
|
||
tags, err := client.ListTags(repo)
|
||
if err != nil {
|
||
fmt.Printf(" %s: [ошибка получения тегов: %v]\n", repo, err)
|
||
continue
|
||
}
|
||
|
||
if len(tags) == 0 {
|
||
fmt.Printf(" %s: [нет тегов]\n", repo)
|
||
} else {
|
||
fmt.Printf(" %s:\n", repo)
|
||
for _, tag := range tags {
|
||
fmt.Printf(" - %s\n", tag)
|
||
}
|
||
}
|
||
}
|
||
}
|