registry-view/main.go
2026-01-27 09:55:57 +07:00

509 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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