fix(auth): gordon auth internal reads stale creds when XDG_RUNTIME_DIR differs between daemon and CLI
- commit
- 61c0290515735e59fe179c4d9fe50775b44b3550
- author
- bnema · · 2026-02-24 21:47 UTC
- parent
- 095150c
- signature
- unsigned
2 files · +132 -11
M internal/app/run.go +51 -11
| @@ -819,19 +819,59 @@ func cleanupInternalCredentials() { | |||
| 819 | 819 | _ = os.Remove(getInternalCredentialsFile()) | |
| 820 | 820 | } | |
| 821 | 821 | ||
| 822 | - | // GetInternalCredentials reads the internal registry credentials from file. | |
| 823 | - | // This is used by the CLI to display credentials for manual recovery. | |
| 824 | - | func GetInternalCredentials() (*InternalCredentials, error) { | |
| 825 | - | credFile := getInternalCredentialsFile() | |
| 826 | - | data, err := os.ReadFile(credFile) | |
| 827 | - | if err != nil { | |
| 828 | - | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | |
| 822 | + | // getInternalCredentialsCandidates returns candidate file paths in priority order: | |
| 823 | + | // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon) | |
| 824 | + | // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR) | |
| 825 | + | // 3. ~/.gordon/run/ (fallback for non-systemd environments) | |
| 826 | + | func getInternalCredentialsCandidates() []string { | |
| 827 | + | var candidates []string | |
| 828 | + | ||
| 829 | + | // 1. XDG_RUNTIME_DIR (set in daemon's environment) | |
| 830 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |
| 831 | + | candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json")) | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env) | |
| 835 | + | uid := os.Getuid() | |
| 836 | + | sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json") | |
| 837 | + | // Avoid duplicate if XDG_RUNTIME_DIR already points here | |
| 838 | + | if len(candidates) == 0 || candidates[0] != sysRuntime { | |
| 839 | + | candidates = append(candidates, sysRuntime) | |
| 829 | 840 | } | |
| 830 | - | var creds InternalCredentials | |
| 831 | - | if err := json.Unmarshal(data, &creds); err != nil { | |
| 832 | - | return nil, fmt.Errorf("failed to parse credentials: %w", err) | |
| 841 | + | ||
| 842 | + | // 3. ~/.gordon/run/ fallback | |
| 843 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |
| 844 | + | candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json")) | |
| 833 | 845 | } | |
| 834 | - | return &creds, nil | |
| 846 | + | ||
| 847 | + | return candidates | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | // GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists. | |
| 851 | + | // Exported for testing. | |
| 852 | + | func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) { | |
| 853 | + | for _, path := range candidates { | |
| 854 | + | data, err := os.ReadFile(path) | |
| 855 | + | if os.IsNotExist(err) { | |
| 856 | + | continue | |
| 857 | + | } | |
| 858 | + | if err != nil { | |
| 859 | + | return nil, fmt.Errorf("failed to read credentials file %s: %w", path, err) | |
| 860 | + | } | |
| 861 | + | var creds InternalCredentials | |
| 862 | + | if err := json.Unmarshal(data, &creds); err != nil { | |
| 863 | + | return nil, fmt.Errorf("failed to parse credentials at %s: %w", path, err) | |
| 864 | + | } | |
| 865 | + | return &creds, nil | |
| 866 | + | } | |
| 867 | + | return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates) | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | // GetInternalCredentials reads the internal registry credentials from file. | |
| 871 | + | // Probes all candidate runtime directories so CLI works regardless of whether | |
| 872 | + | // XDG_RUNTIME_DIR is set in the current shell environment. | |
| 873 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |
| 874 | + | return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates()) | |
| 835 | 875 | } | |
| 836 | 876 | ||
| 837 | 877 | // createAuthService creates the authentication service and token store. | |
| @@ -819,19 +819,59 @@ func cleanupInternalCredentials() { | |||||
| 819 | _ = os.Remove(getInternalCredentialsFile()) | 819 | _ = os.Remove(getInternalCredentialsFile()) | ||
| 820 | } | 820 | } | ||
| 821 | 821 | ||||
| 822 | - | // GetInternalCredentials reads the internal registry credentials from file. | 822 | + | // getInternalCredentialsCandidates returns candidate file paths in priority order: |
| 823 | - | // This is used by the CLI to display credentials for manual recovery. | 823 | + | // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon) |
| 824 | - | func GetInternalCredentials() (*InternalCredentials, error) { | 824 | + | // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR) |
| 825 | - | credFile := getInternalCredentialsFile() | 825 | + | // 3. ~/.gordon/run/ (fallback for non-systemd environments) |
| 826 | - | data, err := os.ReadFile(credFile) | 826 | + | func getInternalCredentialsCandidates() []string { |
| 827 | - | if err != nil { | 827 | + | var candidates []string |
| 828 | - | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | 828 | + | |
| 829 | + | // 1. XDG_RUNTIME_DIR (set in daemon's environment) | |||
| 830 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |||
| 831 | + | candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json")) | |||
| 832 | + | } | |||
| 833 | + | ||||
| 834 | + | // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env) | |||
| 835 | + | uid := os.Getuid() | |||
| 836 | + | sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json") | |||
| 837 | + | // Avoid duplicate if XDG_RUNTIME_DIR already points here | |||
| 838 | + | if len(candidates) == 0 || candidates[0] != sysRuntime { | |||
| 839 | + | candidates = append(candidates, sysRuntime) | |||
| 829 | } | 840 | } | ||
| 830 | - | var creds InternalCredentials | 841 | + | |
| 831 | - | if err := json.Unmarshal(data, &creds); err != nil { | 842 | + | // 3. ~/.gordon/run/ fallback |
| 832 | - | return nil, fmt.Errorf("failed to parse credentials: %w", err) | 843 | + | if homeDir, err := os.UserHomeDir(); err == nil { |
| 844 | + | candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json")) | |||
| 833 | } | 845 | } | ||
| 834 | - | return &creds, nil | 846 | + | |
| 847 | + | return candidates | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | // GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists. | |||
| 851 | + | // Exported for testing. | |||
| 852 | + | func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) { | |||
| 853 | + | for _, path := range candidates { | |||
| 854 | + | data, err := os.ReadFile(path) | |||
| 855 | + | if os.IsNotExist(err) { | |||
| 856 | + | continue | |||
| 857 | + | } | |||
| 858 | + | if err != nil { | |||
| 859 | + | return nil, fmt.Errorf("failed to read credentials file %s: %w", path, err) | |||
| 860 | + | } | |||
| 861 | + | var creds InternalCredentials | |||
| 862 | + | if err := json.Unmarshal(data, &creds); err != nil { | |||
| 863 | + | return nil, fmt.Errorf("failed to parse credentials at %s: %w", path, err) | |||
| 864 | + | } | |||
| 865 | + | return &creds, nil | |||
| 866 | + | } | |||
| 867 | + | return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates) | |||
| 868 | + | } | |||
| 869 | + | ||||
| 870 | + | // GetInternalCredentials reads the internal registry credentials from file. | |||
| 871 | + | // Probes all candidate runtime directories so CLI works regardless of whether | |||
| 872 | + | // XDG_RUNTIME_DIR is set in the current shell environment. | |||
| 873 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 874 | + | return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates()) | |||
| 835 | } | 875 | } | ||
| 836 | 876 | ||||
| 837 | // createAuthService creates the authentication service and token store. | 877 | // createAuthService creates the authentication service and token store. | ||
A internal/app/run_internal_creds_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) { | |
| 10 | + | // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while | |
| 11 | + | // CLI has no XDG_RUNTIME_DIR set. | |
| 12 | + | tmpRuntime := t.TempDir() | |
| 13 | + | gordonRuntime := filepath.Join(tmpRuntime, "gordon") | |
| 14 | + | if err := os.MkdirAll(gordonRuntime, 0700); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Write "live" creds to the runtime dir (as daemon would) | |
| 19 | + | liveCreds := `{"username":"gordon-internal","password":"livepassword"}` | |
| 20 | + | if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Also write a stale creds file to home fallback | |
| 25 | + | tmpHome := t.TempDir() | |
| 26 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |
| 27 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |
| 31 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first | |
| 36 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 37 | + | ||
| 38 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |
| 39 | + | filepath.Join(gordonRuntime, "internal-creds.json"), | |
| 40 | + | filepath.Join(gordonHome, "internal-creds.json"), | |
| 41 | + | }) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatalf("unexpected error: %v", err) | |
| 44 | + | } | |
| 45 | + | if creds.Password != "livepassword" { | |
| 46 | + | t.Errorf("got password %q, want %q", creds.Password, "livepassword") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestGetInternalCredentialsFallsBackToStale(t *testing.T) { | |
| 51 | + | // If only the stale/home file exists, it should still work | |
| 52 | + | tmpHome := t.TempDir() | |
| 53 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |
| 54 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |
| 58 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |
| 63 | + | filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist | |
| 64 | + | filepath.Join(gordonHome, "internal-creds.json"), | |
| 65 | + | }) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatalf("unexpected error: %v", err) | |
| 68 | + | } | |
| 69 | + | if creds.Password != "stalepassword" { | |
| 70 | + | t.Errorf("got password %q, want %q", creds.Password, "stalepassword") | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) { | |
| 75 | + | _, err := GetInternalCredentialsFromCandidates([]string{ | |
| 76 | + | filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"), | |
| 77 | + | }) | |
| 78 | + | if err == nil { | |
| 79 | + | t.Fatal("expected error when no credentials found, got nil") | |
| 80 | + | } | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) { | |||
| 10 | + | // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while | |||
| 11 | + | // CLI has no XDG_RUNTIME_DIR set. | |||
| 12 | + | tmpRuntime := t.TempDir() | |||
| 13 | + | gordonRuntime := filepath.Join(tmpRuntime, "gordon") | |||
| 14 | + | if err := os.MkdirAll(gordonRuntime, 0700); err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // Write "live" creds to the runtime dir (as daemon would) | |||
| 19 | + | liveCreds := `{"username":"gordon-internal","password":"livepassword"}` | |||
| 20 | + | if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Also write a stale creds file to home fallback | |||
| 25 | + | tmpHome := t.TempDir() | |||
| 26 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |||
| 27 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |||
| 31 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first | |||
| 36 | + | t.Setenv("XDG_RUNTIME_DIR", "") | |||
| 37 | + | ||||
| 38 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 39 | + | filepath.Join(gordonRuntime, "internal-creds.json"), | |||
| 40 | + | filepath.Join(gordonHome, "internal-creds.json"), | |||
| 41 | + | }) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatalf("unexpected error: %v", err) | |||
| 44 | + | } | |||
| 45 | + | if creds.Password != "livepassword" { | |||
| 46 | + | t.Errorf("got password %q, want %q", creds.Password, "livepassword") | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestGetInternalCredentialsFallsBackToStale(t *testing.T) { | |||
| 51 | + | // If only the stale/home file exists, it should still work | |||
| 52 | + | tmpHome := t.TempDir() | |||
| 53 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |||
| 54 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |||
| 58 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 63 | + | filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist | |||
| 64 | + | filepath.Join(gordonHome, "internal-creds.json"), | |||
| 65 | + | }) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatalf("unexpected error: %v", err) | |||
| 68 | + | } | |||
| 69 | + | if creds.Password != "stalepassword" { | |||
| 70 | + | t.Errorf("got password %q, want %q", creds.Password, "stalepassword") | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) { | |||
| 75 | + | _, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 76 | + | filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"), | |||
| 77 | + | }) | |||
| 78 | + | if err == nil { | |||
| 79 | + | t.Fatal("expected error when no credentials found, got nil") | |||
| 80 | + | } | |||
| 81 | + | } | |||