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) } } } }