Files
at-container-registry/cmd/credential-helper/main.go
T

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
}