mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 16:56:56 +00:00
The credential helper stored accounts keyed by handle, which broke on handle renames and let the Docker `store` path (username+secret, no DID) overwrite a good account with a DID-less one — how evan.jarrett.net on buoy.cr ended up active with a blank DID. Re-key everything by the stable DID, treating handle as a mutable display label. DID is recovered client-side via standard AT-proto handle resolution (DNS TXT _atproto.<handle> + HTTPS .well-known/atproto-did) — no server change, no JWT, no auth, and no indigo pulled into the helper. - resolve.go: stdlib handle->DID resolver - config.go: v3 DID-keyed schema; find/activeAccount/upsert/rekey helpers; upsert never blanks a known DID; migrateV2toV3 re-keys existing files in place (no login lost; DID-less accounts stay provisional and self-heal) - protocol.go: store resolves+preserves DID; get lazily backfills+re-keys; list reports the active account's handle (was arbitrary map iteration) - status/switch/logout: display handle, key/compare by DID - config_test.go: migration, upsert-never-blanks-DID, rekey, find, list, resolver parse helpers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
5.4 KiB
Go
204 lines
5.4 KiB
Go
package credhelper
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Status message styles (matching gh CLI conventions)
|
|
var (
|
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
|
|
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
|
|
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan
|
|
boldStyle = lipgloss.NewStyle().Bold(true)
|
|
)
|
|
|
|
// logSuccess prints a green ✓ prefixed message to stderr
|
|
func logSuccess(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", successStyle.Render("✓"), fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// logWarning prints a yellow ! prefixed message to stderr
|
|
func logWarning(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", warningStyle.Render("!"), fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// logInfo prints a cyan - prefixed message to stderr
|
|
func logInfo(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", infoStyle.Render("-"), fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// logInfof prints a cyan - prefixed message to stderr without a trailing newline
|
|
func logInfof(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s %s", infoStyle.Render("-"), fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// bold renders text in bold
|
|
func bold(s string) string {
|
|
return boldStyle.Render(s)
|
|
}
|
|
|
|
// DockerDaemonConfig represents Docker's daemon.json configuration
|
|
type DockerDaemonConfig struct {
|
|
InsecureRegistries []string `json:"insecure-registries"`
|
|
}
|
|
|
|
// 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 {
|
|
if reg == serverURL || reg == stripPort(serverURL) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Fallback heuristics: localhost and private IPs
|
|
host := stripPort(serverURL)
|
|
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return true
|
|
}
|
|
|
|
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
|
|
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
programData := os.Getenv("ProgramData")
|
|
if programData != "" {
|
|
paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json"))
|
|
}
|
|
default:
|
|
paths = append(paths, "/etc/docker/daemon.json")
|
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json"))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if strings.Count(hostPort, ":") > 1 {
|
|
return hostPort
|
|
}
|
|
return hostPort[:colonIdx]
|
|
}
|
|
return hostPort
|
|
}
|
|
|
|
// isTerminal checks if the file is a terminal
|
|
func isTerminal(f *os.File) bool {
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return (stat.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
// configDirOverride, when non-empty, replaces the default $HOME/<ConfigDirName>
|
|
// config directory. Test-only seam; unset in normal operation.
|
|
var configDirOverride string
|
|
|
|
// getConfigDir returns the per-brand config directory under $HOME, creating
|
|
// it if needed. The directory name comes from cfg.ConfigDirName.
|
|
func getConfigDir() string {
|
|
dir := configDirOverride
|
|
if dir == "" {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
dir = filepath.Join(homeDir, cfg.ConfigDirName)
|
|
}
|
|
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating %s directory: %v\n", cfg.ConfigDirName, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// getConfigPath returns the path to the device configuration file
|
|
func getConfigPath() string {
|
|
return filepath.Join(getConfigDir(), "device.json")
|
|
}
|