mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
233 lines
5.9 KiB
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")
|
|
}
|
|
}
|