mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 20:57:01 +00:00
584 lines
17 KiB
Go
584 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DeviceConfig represents the stored device configuration
|
|
type DeviceConfig struct {
|
|
Handle string `json:"handle"`
|
|
DeviceSecret string `json:"device_secret"`
|
|
AppViewURL string `json:"appview_url"`
|
|
}
|
|
|
|
// DeviceCredentials stores multiple device configurations keyed by AppView URL
|
|
type DeviceCredentials struct {
|
|
Credentials map[string]DeviceConfig `json:"credentials"`
|
|
}
|
|
|
|
// DockerDaemonConfig represents Docker's daemon.json configuration
|
|
type DockerDaemonConfig struct {
|
|
InsecureRegistries []string `json:"insecure-registries"`
|
|
}
|
|
|
|
// Docker credential helper protocol
|
|
// https://github.com/docker/docker-credential-helpers
|
|
|
|
// Credentials represents docker credentials
|
|
type Credentials struct {
|
|
ServerURL string `json:"ServerURL,omitempty"`
|
|
Username string `json:"Username,omitempty"`
|
|
Secret string `json:"Secret,omitempty"`
|
|
}
|
|
|
|
// Device authorization API types
|
|
|
|
type DeviceCodeRequest struct {
|
|
DeviceName string `json:"device_name"`
|
|
}
|
|
|
|
type DeviceCodeResponse struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURI string `json:"verification_uri"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
|
|
type DeviceTokenRequest struct {
|
|
DeviceCode string `json:"device_code"`
|
|
}
|
|
|
|
type DeviceTokenResponse struct {
|
|
DeviceSecret string `json:"device_secret,omitempty"`
|
|
Handle string `json:"handle,omitempty"`
|
|
DID string `json:"did,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var (
|
|
version = "dev"
|
|
commit = "none"
|
|
date = "unknown"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version>\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
command := os.Args[1]
|
|
|
|
switch command {
|
|
case "get":
|
|
handleGet()
|
|
case "store":
|
|
handleStore()
|
|
case "erase":
|
|
handleErase()
|
|
case "version":
|
|
fmt.Printf("docker-credential-atcr %s (commit: %s, built: %s)\n", version, commit, date)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// handleGet retrieves credentials for the given server
|
|
func handleGet() {
|
|
// Docker sends the server URL as a plain string on stdin (not JSON)
|
|
var serverURL string
|
|
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Build AppView URL to use as lookup key
|
|
appViewURL := buildAppViewURL(serverURL)
|
|
|
|
// Load all device credentials
|
|
configPath := getConfigPath()
|
|
allCreds, err := loadDeviceCredentials(configPath)
|
|
if err != nil {
|
|
// No credentials file exists yet
|
|
allCreds = &DeviceCredentials{
|
|
Credentials: make(map[string]DeviceConfig),
|
|
}
|
|
}
|
|
|
|
// Look up device config for this specific AppView URL
|
|
deviceConfig, found := getDeviceConfig(allCreds, appViewURL)
|
|
|
|
// If credentials exist, validate them
|
|
if found && deviceConfig.DeviceSecret != "" {
|
|
if !validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) {
|
|
fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL)
|
|
// Delete the invalid credentials
|
|
delete(allCreds.Credentials, appViewURL)
|
|
if err := saveDeviceCredentials(configPath, allCreds); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to save updated credentials: %v\n", err)
|
|
}
|
|
// Mark as not found so we re-authorize below
|
|
found = false
|
|
}
|
|
}
|
|
|
|
if !found || deviceConfig.DeviceSecret == "" {
|
|
// No credentials for this AppView
|
|
// Check if we should attempt interactive authorization
|
|
// We only do this if:
|
|
// 1. ATCR_AUTO_AUTH environment variable is set to "1", OR
|
|
// 2. We're in an interactive terminal (stderr is a terminal)
|
|
shouldAutoAuth := os.Getenv("ATCR_AUTO_AUTH") == "1" || isTerminal(os.Stderr)
|
|
|
|
if !shouldAutoAuth {
|
|
fmt.Fprintf(os.Stderr, "No valid credentials found for %s\n", appViewURL)
|
|
fmt.Fprintf(os.Stderr, "\nTo authenticate, run:\n")
|
|
fmt.Fprintf(os.Stderr, " export ATCR_AUTO_AUTH=1\n")
|
|
fmt.Fprintf(os.Stderr, " docker push %s/<user>/<image>:<tag>\n", serverURL)
|
|
fmt.Fprintf(os.Stderr, "\nThis will trigger device authorization in your browser.\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Auto-auth enabled - trigger device authorization
|
|
fmt.Fprintf(os.Stderr, "Starting device authorization for %s...\n", appViewURL)
|
|
|
|
newConfig, err := authorizeDevice(serverURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err)
|
|
fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Save device configuration
|
|
if err := saveDeviceConfig(configPath, newConfig); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "✓ Device authorized successfully for %s!\n", appViewURL)
|
|
deviceConfig = newConfig
|
|
}
|
|
|
|
// Return credentials for Docker
|
|
creds := Credentials{
|
|
ServerURL: serverURL,
|
|
Username: deviceConfig.Handle,
|
|
Secret: deviceConfig.DeviceSecret,
|
|
}
|
|
|
|
if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// handleStore stores credentials (Docker calls this after login)
|
|
func handleStore() {
|
|
var creds Credentials
|
|
if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// This is a no-op for the device auth flow
|
|
// Users should use the automatic device authorization, not docker login
|
|
// If they use docker login with app-password, that goes through /auth/token directly
|
|
}
|
|
|
|
// handleErase removes stored credentials for a specific AppView
|
|
func handleErase() {
|
|
// Docker sends the server URL as a plain string on stdin (not JSON)
|
|
var serverURL string
|
|
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Build AppView URL to use as lookup key
|
|
appViewURL := buildAppViewURL(serverURL)
|
|
|
|
// Load all device credentials
|
|
configPath := getConfigPath()
|
|
allCreds, err := loadDeviceCredentials(configPath)
|
|
if err != nil {
|
|
// No credentials file exists, nothing to erase
|
|
return
|
|
}
|
|
|
|
// Remove the specific AppView URL's credentials
|
|
delete(allCreds.Credentials, appViewURL)
|
|
|
|
// If no credentials remain, remove the file entirely
|
|
if len(allCreds.Credentials) == 0 {
|
|
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Otherwise, save the updated credentials
|
|
if err := saveDeviceCredentials(configPath, allCreds); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error saving device config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// authorizeDevice performs the device authorization flow
|
|
func authorizeDevice(serverURL string) (*DeviceConfig, error) {
|
|
appViewURL := buildAppViewURL(serverURL)
|
|
|
|
// Get device name (hostname)
|
|
deviceName, err := os.Hostname()
|
|
if err != nil {
|
|
deviceName = "Unknown Device"
|
|
}
|
|
|
|
// 1. Request device code
|
|
fmt.Fprintf(os.Stderr, "Requesting device authorization...\n")
|
|
|
|
reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
|
|
resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to request device code: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
|
}
|
|
|
|
var codeResp DeviceCodeResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode device code response: %w", err)
|
|
}
|
|
|
|
// 2. Display authorization URL and user code
|
|
verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode
|
|
|
|
fmt.Fprintf(os.Stderr, "\n╔════════════════════════════════════════════════════════════════╗\n")
|
|
fmt.Fprintf(os.Stderr, "║ Device Authorization Required ║\n")
|
|
fmt.Fprintf(os.Stderr, "╚════════════════════════════════════════════════════════════════╝\n\n")
|
|
fmt.Fprintf(os.Stderr, "Visit this URL in your browser:\n")
|
|
fmt.Fprintf(os.Stderr, " %s\n\n", verificationURL)
|
|
fmt.Fprintf(os.Stderr, "Your code: %s\n\n", codeResp.UserCode)
|
|
|
|
// Try to open browser (may fail on headless systems)
|
|
if err := openBrowser(verificationURL); err == nil {
|
|
fmt.Fprintf(os.Stderr, "Opening browser...\n\n")
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Could not open browser automatically (%v)\n", err)
|
|
fmt.Fprintf(os.Stderr, "Please open the URL above manually.\n\n")
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "Waiting for authorization")
|
|
|
|
// 3. Poll for authorization completion
|
|
pollInterval := time.Duration(codeResp.Interval) * time.Second
|
|
timeout := time.Duration(codeResp.ExpiresIn) * time.Second
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
dots := 0
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(pollInterval)
|
|
|
|
// Show progress dots
|
|
dots = (dots + 1) % 4
|
|
fmt.Fprintf(os.Stderr, "\rWaiting for authorization%s ", strings.Repeat(".", dots))
|
|
|
|
// Poll token endpoint
|
|
tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
|
|
tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "\nPoll failed: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
var tokenResult DeviceTokenResponse
|
|
json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
|
|
tokenResp.Body.Close()
|
|
|
|
if tokenResult.Error == "authorization_pending" {
|
|
// Still waiting
|
|
continue
|
|
}
|
|
|
|
if tokenResult.Error != "" {
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
|
|
}
|
|
|
|
// Success!
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
return &DeviceConfig{
|
|
Handle: tokenResult.Handle,
|
|
DeviceSecret: tokenResult.DeviceSecret,
|
|
AppViewURL: appViewURL,
|
|
}, nil
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
return nil, fmt.Errorf("authorization timeout")
|
|
}
|
|
|
|
// getConfigPath returns the path to the device configuration file
|
|
func getConfigPath() string {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
atcrDir := filepath.Join(homeDir, ".atcr")
|
|
if err := os.MkdirAll(atcrDir, 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
return filepath.Join(atcrDir, "device.json")
|
|
}
|
|
|
|
// loadDeviceCredentials loads all device credentials from disk
|
|
func loadDeviceCredentials(path string) (*DeviceCredentials, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try to unmarshal as new format (map of credentials)
|
|
var creds DeviceCredentials
|
|
if err := json.Unmarshal(data, &creds); err == nil && creds.Credentials != nil {
|
|
return &creds, nil
|
|
}
|
|
|
|
// Backward compatibility: Try to unmarshal as old format (single config)
|
|
var oldConfig DeviceConfig
|
|
if err := json.Unmarshal(data, &oldConfig); err == nil && oldConfig.DeviceSecret != "" {
|
|
// Migrate old format to new format
|
|
creds = DeviceCredentials{
|
|
Credentials: map[string]DeviceConfig{
|
|
oldConfig.AppViewURL: oldConfig,
|
|
},
|
|
}
|
|
return &creds, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid device credentials format")
|
|
}
|
|
|
|
// getDeviceConfig retrieves a specific device config for an AppView URL
|
|
func getDeviceConfig(creds *DeviceCredentials, appViewURL string) (*DeviceConfig, bool) {
|
|
if creds == nil || creds.Credentials == nil {
|
|
return nil, false
|
|
}
|
|
config, found := creds.Credentials[appViewURL]
|
|
return &config, found
|
|
}
|
|
|
|
// saveDeviceCredentials saves all device credentials to disk
|
|
func saveDeviceCredentials(path string, creds *DeviceCredentials) error {
|
|
data, err := json.MarshalIndent(creds, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
// saveDeviceConfig saves a single device config by adding/updating it in the credentials map
|
|
func saveDeviceConfig(path string, config *DeviceConfig) error {
|
|
// Load existing credentials (or create new)
|
|
creds, err := loadDeviceCredentials(path)
|
|
if err != nil {
|
|
// Create new credentials structure
|
|
creds = &DeviceCredentials{
|
|
Credentials: make(map[string]DeviceConfig),
|
|
}
|
|
}
|
|
|
|
// Add or update the config for this AppView URL
|
|
creds.Credentials[config.AppViewURL] = *config
|
|
|
|
// Save back to disk
|
|
return saveDeviceCredentials(path, creds)
|
|
}
|
|
|
|
// openBrowser opens the specified URL in the default browser
|
|
func openBrowser(url string) error {
|
|
var cmd *exec.Cmd
|
|
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", url)
|
|
case "darwin":
|
|
cmd = exec.Command("open", url)
|
|
case "windows":
|
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
|
default:
|
|
return fmt.Errorf("unsupported platform")
|
|
}
|
|
|
|
return cmd.Start()
|
|
}
|
|
|
|
// buildAppViewURL constructs the AppView URL with the appropriate protocol
|
|
func buildAppViewURL(serverURL string) string {
|
|
// If serverURL already has a scheme, use it as-is
|
|
if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") {
|
|
return serverURL
|
|
}
|
|
|
|
// Determine protocol based on Docker configuration and heuristics
|
|
if isInsecureRegistry(serverURL) {
|
|
return "http://" + serverURL
|
|
}
|
|
|
|
// Default to HTTPS (mirrors Docker's default behavior)
|
|
return "https://" + serverURL
|
|
}
|
|
|
|
// isInsecureRegistry checks if a registry should use HTTP instead of HTTPS
|
|
func isInsecureRegistry(serverURL string) bool {
|
|
// Check Docker's insecure-registries configuration
|
|
insecureRegistries := getDockerInsecureRegistries()
|
|
for _, reg := range insecureRegistries {
|
|
// Match exact serverURL or just the host part
|
|
if reg == serverURL || reg == stripPort(serverURL) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Fallback heuristics: localhost and private IPs
|
|
host := stripPort(serverURL)
|
|
|
|
// Check for localhost variants
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return true
|
|
}
|
|
|
|
// Check if it's a private IP address
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if ip.IsLoopback() || ip.IsPrivate() {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getDockerInsecureRegistries reads Docker's insecure-registries configuration
|
|
func getDockerInsecureRegistries() []string {
|
|
var paths []string
|
|
|
|
// Common Docker daemon.json locations
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
programData := os.Getenv("ProgramData")
|
|
if programData != "" {
|
|
paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json"))
|
|
}
|
|
default:
|
|
// Linux and macOS
|
|
paths = append(paths, "/etc/docker/daemon.json")
|
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
// Rootless Docker location
|
|
paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json"))
|
|
}
|
|
}
|
|
|
|
// Try each path
|
|
for _, path := range paths {
|
|
if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 {
|
|
return config.InsecureRegistries
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readDockerDaemonConfig reads and parses a Docker daemon.json file
|
|
func readDockerDaemonConfig(path string) *DockerDaemonConfig {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var config DockerDaemonConfig
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
return nil
|
|
}
|
|
|
|
return &config
|
|
}
|
|
|
|
// stripPort removes the port from a host:port string
|
|
func stripPort(hostPort string) string {
|
|
if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
|
|
// Check if this is IPv6 (has multiple colons)
|
|
if strings.Count(hostPort, ":") > 1 {
|
|
// IPv6 address, don't strip
|
|
return hostPort
|
|
}
|
|
return hostPort[:colonIdx]
|
|
}
|
|
return hostPort
|
|
}
|
|
|
|
// isTerminal checks if the file is a terminal
|
|
func isTerminal(f *os.File) bool {
|
|
// Use file stat to check if it's a character device (terminal)
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// On Unix, terminals are character devices with mode & ModeCharDevice set
|
|
return (stat.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
// validateCredentials checks if the credentials are still valid by making a test request
|
|
func validateCredentials(appViewURL, handle, deviceSecret string) bool {
|
|
// Call /auth/token to validate device secret and get JWT
|
|
// This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
// Build /auth/token URL with minimal scope (just access to /v2/)
|
|
tokenURL := appViewURL + "/auth/token?service=" + appViewURL
|
|
|
|
req, err := http.NewRequest("GET", tokenURL, nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Set basic auth with device credentials
|
|
req.SetBasicAuth(handle, deviceSecret)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// Network error - assume credentials are valid but server unreachable
|
|
// Don't trigger re-auth on network issues
|
|
return true
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// 200 = valid credentials
|
|
// 401 = invalid/expired credentials
|
|
// Any other error = assume valid (don't re-auth on server issues)
|
|
return resp.StatusCode == http.StatusOK
|
|
}
|