first commit
This commit is contained in:
commit
b312c9e35e
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Бинарные файлы
|
||||||
|
registry-view
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Конфигурация с паролями (не коммитить!)
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Тестовые файлы
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Временные файлы
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@ -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 требуется соответствующий доступ
|
||||||
185
config.go
Normal file
185
config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
22
config.yaml.example
Normal file
22
config.yaml.example
Normal file
@ -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
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module registry-view
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -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=
|
||||||
508
main.go
Normal file
508
main.go
Normal file
@ -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 {
|
||||||
|
// Формат: <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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user