Files
at-container-registry/cmd/credential-helper/protocol.go

233 lines
5.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// Credentials represents docker credentials (Docker credential helper protocol)
type Credentials struct {
ServerURL string `json:"ServerURL,omitempty"`
Username string `json:"Username,omitempty"`
Secret string `json:"Secret,omitempty"`
}
func newGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get",
Short: "Get credentials for a registry (Docker protocol)",
Hidden: true,
RunE: runGet,
}
}
func newStoreCmd() *cobra.Command {
return &cobra.Command{
Use: "store",
Short: "Store credentials (Docker protocol)",
Hidden: true,
RunE: runStore,
}
}
func newEraseCmd() *cobra.Command {
return &cobra.Command{
Use: "erase",
Short: "Erase credentials (Docker protocol)",
Hidden: true,
RunE: runErase,
}
}
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all credentials (Docker protocol extension)",
Hidden: true,
RunE: runList,
}
}
func runGet(cmd *cobra.Command, args []string) error {
// If stdin is a terminal, the user ran this directly (not Docker calling us)
if isTerminal(os.Stdin) {
fmt.Fprintf(os.Stderr, "The 'get' command is part of the Docker credential helper protocol.\n")
fmt.Fprintf(os.Stderr, "It should not be run directly.\n\n")
fmt.Fprintf(os.Stderr, "To authenticate with a registry, run:\n")
fmt.Fprintf(os.Stderr, " docker-credential-atcr login\n\n")
fmt.Fprintf(os.Stderr, "To check your accounts:\n")
fmt.Fprintf(os.Stderr, " docker-credential-atcr status\n")
return fmt.Errorf("not a pipe")
}
// 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 {
return fmt.Errorf("reading server URL: %w", err)
}
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
}
acct, err := cfg.resolveAccount(appViewURL, serverURL)
if err != nil {
return err
}
// Validate credentials
result := validateCredentials(appViewURL, acct.Handle, acct.DeviceSecret)
if !result.Valid {
if result.OAuthSessionExpired {
loginURL := result.LoginURL
if loginURL == "" {
loginURL = appViewURL + "/auth/oauth/login"
}
fmt.Fprintf(os.Stderr, "OAuth session expired for %s.\n", acct.Handle)
fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
fmt.Fprintf(os.Stderr, "Then retry your docker command.\n")
return fmt.Errorf("oauth session expired")
}
// Generic auth failure — remove the bad account
fmt.Fprintf(os.Stderr, "Credentials for %s are invalid.\n", acct.Handle)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
cfg.removeAccount(appViewURL, acct.Handle)
cfg.save() //nolint:errcheck
return fmt.Errorf("invalid credentials")
}
// Check for updates (cached, non-blocking)
checkAndNotifyUpdate()
// Return credentials for Docker
creds := Credentials{
ServerURL: serverURL,
Username: acct.Handle,
Secret: acct.DeviceSecret,
}
return json.NewEncoder(os.Stdout).Encode(creds)
}
func runStore(cmd *cobra.Command, args []string) error {
var creds Credentials
if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
return fmt.Errorf("decoding credentials: %w", err)
}
// Only store if the secret looks like a device secret
if !strings.HasPrefix(creds.Secret, "atcr_device_") {
// Not our device secret — ignore (e.g., docker login with app-password)
return nil
}
appViewURL := buildAppViewURL(creds.ServerURL)
cfg, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
}
cfg.addAccount(appViewURL, &Account{
Handle: creds.Username,
DeviceSecret: creds.Secret,
})
return cfg.save()
}
func runErase(cmd *cobra.Command, args []string) error {
var serverURL string
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
return fmt.Errorf("reading server URL: %w", err)
}
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
if err != nil {
return nil // No config, nothing to erase
}
reg := cfg.findRegistry(appViewURL)
if reg == nil {
return nil
}
// Erase the active account (or sole account)
handle := reg.Active
if handle == "" && len(reg.Accounts) == 1 {
for h := range reg.Accounts {
handle = h
}
}
if handle == "" {
return nil
}
cfg.removeAccount(appViewURL, handle)
return cfg.save()
}
func runList(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
// Return empty object
fmt.Println("{}")
return nil
}
// Docker list protocol: {"ServerURL": "Username", ...}
result := make(map[string]string)
for url, reg := range cfg.Registries {
// Strip scheme for Docker compatibility
host := strings.TrimPrefix(url, "https://")
host = strings.TrimPrefix(host, "http://")
for _, acct := range reg.Accounts {
result[host] = acct.Handle
}
}
return json.NewEncoder(os.Stdout).Encode(result)
}
// checkAndNotifyUpdate checks for updates in the background and notifies the user
func checkAndNotifyUpdate() {
cache := loadUpdateCheckCache()
if cache != nil && cache.Current == version {
// Cache is fresh and for current version
if isNewerVersion(cache.Latest, version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, version)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
}
// Check if cache is still fresh (24h)
if cache.CheckedAt.Add(updateCheckCacheTTL).After(timeNow()) {
return
}
}
latest, err := fetchLatestVersion()
if err != nil {
return // Silently fail
}
saveUpdateCheckCache(&UpdateCheckCache{
CheckedAt: timeNow(),
Latest: latest,
Current: version,
})
if isNewerVersion(latest, version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, version)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
}
}