From b312c9e35ef4b2f168dd0cf0650786e552ad26d2 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 27 Jan 2026 09:55:57 +0700 Subject: [PATCH] first commit --- .gitignore | 24 +++ README.md | 103 +++++++++ config.go | 185 ++++++++++++++++ config.yaml.example | 22 ++ go.mod | 5 + go.sum | 4 + main.go | 508 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 851 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.go create mode 100644 config.yaml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..663960b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Бинарные файлы +registry-view +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Конфигурация с паролями (не коммитить!) +config.yaml + +# Тестовые файлы +*.test + +# Временные файлы +*.tmp +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e53eb6 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Docker Registry Viewer + +Программа на Go для просмотра списка образов из Docker Registry с поддержкой авторизации. + +## Возможности + +- Подключение к Docker Registry с авторизацией (Basic Auth и Bearer Token) +- Получение списка всех репозиториев +- Получение списка тегов для каждого репозитория +- Поддержка пагинации для больших реестров +- Конфигурация через YAML файл +- Поддержка учетных данных из `~/.docker/config.json` (как в Docker CLI) + +## Установка + +```bash +go mod download +``` + +## Конфигурация + +Все настройки хранятся в файле `config.yaml`. Скопируйте пример конфигурации: + +```bash +cp config.yaml.example config.yaml +``` + +### Способ 1: Указать учетные данные в config.yaml + +```yaml +registry: + url: "https://registry.example.com" + username: "admin" + password: "password123" +debug: false +``` + +### Способ 2: Использовать учетные данные из Docker config + +Вы можете использовать учетные данные из `~/.docker/config.json` (те же, что использует Docker CLI): + +```yaml +registry: + url: "https://registry.example.com" +use_docker_config: true +debug: false +``` + +Программа автоматически найдет учетные данные для указанного registry в файле `~/.docker/config.json`. + +**Примечание:** Если `use_docker_config: false` и не указаны `username`/`password`, программа попытается автоматически загрузить их из Docker config как fallback. + +## Использование + +### Базовое использование + +```bash +go run main.go +``` + +Программа автоматически загрузит конфигурацию из `config.yaml`. + +### Указание другого файла конфигурации + +```bash +go run main.go -config /path/to/config.yaml +# или краткая форма +go run main.go -c /path/to/config.yaml +``` + +### Сборка + +```bash +go build -o registry-view +./registry-view +``` + +### Режим отладки + +Для диагностики проблем с авторизацией включите режим отладки в `config.yaml`: + +```yaml +debug: true +``` + +Режим отладки покажет: +- URL запросов +- HTTP статусы ответов +- Заголовки ответов +- Тела ответов +- Процесс получения токенов + +## Формат вывода + +Программа выводит: +1. Список всех репозиториев с нумерацией +2. Детальную информацию: для каждого репозитория список всех тегов + +## Примечания + +- Программа поддерживает как Basic Authentication, так и Bearer Token (OAuth2) +- Автоматически обрабатывает пагинацию при большом количестве репозиториев +- Для работы с Registry API v2 требуется соответствующий доступ diff --git a/config.go b/config.go new file mode 100644 index 0000000..f4a3795 --- /dev/null +++ b/config.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config представляет структуру конфигурации +type Config struct { + Registry struct { + URL string `yaml:"url"` + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"registry"` + Debug bool `yaml:"debug"` + UseDockerConfig bool `yaml:"use_docker_config"` // Использовать ~/.docker/config.json +} + +// DockerConfig представляет структуру Docker config.json +type DockerConfig struct { + Auths map[string]DockerAuth `json:"auths"` +} + +// DockerAuth представляет учетные данные для одного registry +type DockerAuth struct { + Auth string `json:"auth"` // base64(username:password) + Username string `json:"username"` // альтернативный формат + Password string `json:"password"` // альтернативный формат +} + +// LoadDockerConfig загружает учетные данные из ~/.docker/config.json +func LoadDockerConfig(registryURL string) (username, password string, err error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("не удалось получить домашнюю директорию: %w", err) + } + + dockerConfigPath := filepath.Join(homeDir, ".docker", "config.json") + data, err := os.ReadFile(dockerConfigPath) + if err != nil { + return "", "", fmt.Errorf("не удалось прочитать Docker config: %w", err) + } + + var dockerConfig DockerConfig + if err := json.Unmarshal(data, &dockerConfig); err != nil { + return "", "", fmt.Errorf("ошибка парсинга Docker config: %w", err) + } + + // Парсим URL registry для поиска подходящей записи + registryHost, err := extractRegistryHost(registryURL) + if err != nil { + return "", "", fmt.Errorf("ошибка парсинга URL registry: %w", err) + } + + // Ищем подходящий registry в конфиге + // Проверяем точное совпадение и варианты с/без протокола + var auth DockerAuth + found := false + + // Варианты для поиска + searchKeys := []string{ + registryURL, // полный URL + registryHost, // только хост + strings.TrimPrefix(registryURL, "https://"), // без https:// + strings.TrimPrefix(registryURL, "http://"), // без http:// + } + + for _, key := range searchKeys { + if a, ok := dockerConfig.Auths[key]; ok { + auth = a + found = true + fmt.Printf("[DEBUG] Найдены учетные данные в Docker config по ключу: %s\n", key) + break + } + } + + // Если не нашли точное совпадение, пробуем частичное + if !found { + for dockerKey, a := range dockerConfig.Auths { + if strings.Contains(registryHost, dockerKey) || strings.Contains(dockerKey, registryHost) { + auth = a + found = true + break + } + } + } + + if !found { + return "", "", fmt.Errorf("не найдены учетные данные для registry %s в Docker config", registryURL) + } + + // Извлекаем username и password + if auth.Auth != "" { + // Формат: base64(username:password) + decoded, err := base64.StdEncoding.DecodeString(auth.Auth) + if err != nil { + return "", "", fmt.Errorf("ошибка декодирования auth: %w", err) + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("неверный формат auth в Docker config") + } + return parts[0], parts[1], nil + } else if auth.Username != "" && auth.Password != "" { + // Альтернативный формат: отдельные поля + return auth.Username, auth.Password, nil + } + + return "", "", fmt.Errorf("не найдены учетные данные в Docker config для %s", registryURL) +} + +// extractRegistryHost извлекает хост из URL registry +func extractRegistryHost(registryURL string) (string, error) { + u, err := url.Parse(registryURL) + if err != nil { + return "", err + } + return u.Host, nil +} + +// LoadConfig загружает конфигурацию из файла +func LoadConfig(configPath string) (*Config, error) { + // Если путь не указан, пробуем стандартные места + if configPath == "" { + configPath = "config.yaml" + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("ошибка чтения файла конфигурации %s: %w", configPath, err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("ошибка парсинга конфигурации: %w", err) + } + + // Валидация обязательных полей + if config.Registry.URL == "" { + return nil, fmt.Errorf("registry.url обязателен в конфигурации") + } + + // Если включено использование Docker config, всегда используем его + if config.UseDockerConfig { + username, password, err := LoadDockerConfig(config.Registry.URL) + if err != nil { + return nil, fmt.Errorf("не удалось загрузить учетные данные из Docker config: %w", err) + } + config.Registry.Username = username + config.Registry.Password = password + if config.Debug { + fmt.Printf("[DEBUG] Загружены учетные данные из Docker config для %s\n", config.Registry.URL) + fmt.Printf("[DEBUG] Username: %s\n", username) + } + } else if config.Registry.Username == "" || config.Registry.Password == "" { + // Если не указаны учетные данные в YAML, пробуем загрузить из Docker config как fallback + username, password, err := LoadDockerConfig(config.Registry.URL) + if err != nil { + return nil, fmt.Errorf("не указаны учетные данные в config.yaml и не удалось загрузить из Docker config: %w", err) + } + config.Registry.Username = username + config.Registry.Password = password + if config.Debug { + fmt.Printf("[DEBUG] Автоматически загружены учетные данные из Docker config (fallback)\n") + fmt.Printf("[DEBUG] Username: %s\n", username) + } + } else if config.Debug { + fmt.Printf("[DEBUG] Используются учетные данные из config.yaml\n") + fmt.Printf("[DEBUG] Username: %s\n", config.Registry.Username) + } + + // Финальная проверка + if config.Registry.Username == "" || config.Registry.Password == "" { + return nil, fmt.Errorf("не указаны учетные данные (username/password). Укажите в config.yaml или используйте use_docker_config: true") + } + + return &config, nil +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..67958d3 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,22 @@ +# Пример конфигурации для Docker Registry Viewer +# Скопируйте этот файл в config.yaml и заполните своими данными + +# Настройки Docker Registry +registry: + # URL Docker Registry (например: https://registry.example.com или http://localhost:5000) + url: "https://registry.example.com" + + # Имя пользователя для авторизации (опционально, если используется use_docker_config) + username: "admin" + + # Пароль для авторизации (опционально, если используется use_docker_config) + password: "password123" + +# Использовать учетные данные из ~/.docker/config.json (true/false) +# Если true, username и password из config.yaml будут проигнорированы +# Если false и не указаны username/password, будет попытка загрузить из Docker config +use_docker_config: false + +# Режим отладки (true/false) +# При включении показывает детальную информацию о запросах и ответах +debug: false diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e34f273 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module registry-view + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b76bc2c --- /dev/null +++ b/main.go @@ -0,0 +1,508 @@ +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 { + // Формат: ; 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) + } + } + } +}