@@ -2,15 +2,17 @@ package main
22
33import (
44 "encoding/json"
5+ "errors"
56 "fmt"
6- "io"
77 "os"
88 "os/exec"
99 "path"
1010 "runtime"
1111 "runtime/debug"
1212 "strings"
1313 "time"
14+
15+ "github.com/zalando/go-keyring"
1416)
1517
1618type CacheFile struct {
@@ -29,22 +31,29 @@ type ClientAuthentication struct {
2931 } `json:"status"`
3032}
3133
34+ // keyringService is the service name used for entries stored in the OS secret store.
35+ const keyringService = "kube-credential-cache"
36+
37+ // Backend abstracts where cached credentials are persisted between invocations.
38+ //
39+ // kcc-cache is a short-lived process (spawned by kubectl on every API call), so
40+ // there is no in-process memory that survives between calls. A Backend therefore
41+ // persists credentials somewhere external: either the OS secret store ("keyring")
42+ // or a plaintext JSON file ("file").
43+ type Backend interface {
44+ // Get returns the cached credential for key. ok is false when there is no
45+ // (valid) entry for key.
46+ Get (key string ) (cred ClientAuthentication , ok bool , err error )
47+ // Set stores cred under key.
48+ Set (key string , cred ClientAuthentication ) error
49+ }
50+
3251func main () {
3352 // configuration
3453 var (
35- cacheFilepath string
3654 refreshMargin = time .Second * 30
3755 cacheKeyEnvlist = []string {"KUBE_CREDENTIAL_CACHE_USER" , "AWS_PROFILE" , "AWS_REGION" , "AWS_VAULT" }
3856 )
39- if e := os .Getenv ("KUBE_CREDENTIAL_CACHE_FILE" ); e != "" {
40- cacheFilepath = e
41- } else {
42- cacheDir , err := os .UserCacheDir ()
43- if err != nil {
44- fatal ("can't find CacheDir. fix error or set 'KUBE_CREDENTIAL_CACHE_FILE': %s" , err )
45- }
46- cacheFilepath = path .Join (cacheDir , "kube-credential-cache" , "cache.json" )
47- }
4857 if e := os .Getenv ("KUBE_CREDENTIAL_CACHE_REFRESH_MARGIN" ); e != "" {
4958 d , err := time .ParseDuration (e )
5059 if err != nil {
@@ -101,75 +110,16 @@ func main() {
101110 }
102111 }
103112
104- // open file
105- f , err := os .OpenFile (cacheFilepath , os .O_RDWR | os .O_CREATE , 0600 )
106- if err != nil {
107- if os .IsNotExist (err ) {
108- if err := os .MkdirAll (path .Dir (cacheFilepath ), 0700 ); err != nil {
109- fatal ("mkdir failed: %s" , err )
110- }
111- f , err = os .OpenFile (cacheFilepath , os .O_RDWR | os .O_CREATE , 0600 )
112- if err != nil {
113- fatal ("file open failed(after mkdir): %s" , err )
114- }
115- } else {
116- fatal ("file open failed: %s" , err )
117- }
118- }
119- defer func () {
120- if err := f .Close (); err != nil {
121- log ("file close failed: %s" , err )
122- }
123- }()
124-
125- // read file
126- updated := false
127- cacheFile := CacheFile {}
128- bytes , err := io .ReadAll (f )
129- if err != nil {
130- fatal ("file read failed: %s" , err )
131- }
132- if len (bytes ) > 0 {
133- if err := json .Unmarshal (bytes , & cacheFile ); err != nil {
134- log ("json.Unmarshal() failed(read cache file): %s\n ...Corruption detected, recreate cache file" , err )
135- updated = true
136- }
137- }
138- defer func () {
139- // update cache file
140- if updated {
141- qpanic := func (err error ) {
142- if err != nil {
143- panic (err )
144- }
145- }
146-
147- // cleanup
148- for k , v := range cacheFile .Credentials {
149- if time .Now ().After (v .Status .ExpirationTimestamp ) {
150- delete (cacheFile .Credentials , k )
151- }
152- }
153-
154- // update
155- err := f .Truncate (0 )
156- qpanic (err )
157- bytes , err := json .Marshal (cacheFile )
158- qpanic (err )
159- _ , err = f .WriteAt (bytes , 0 )
160- qpanic (err )
161- }
162- }()
113+ // select storage backend
114+ backend := newBackend (os .Getenv ("KUBE_CREDENTIAL_CACHE_BACKEND" ))
163115
164116 // check cache
165- if len (cacheFile .Credentials ) == 0 {
166- cacheFile .Credentials = map [string ]ClientAuthentication {}
117+ cache , ok , err := backend .Get (cacheKey )
118+ if err != nil {
119+ fatal ("cache read failed: %s" , err )
167120 }
168- cache , ok := cacheFile .Credentials [cacheKey ]
169- if ! ok || ok && time .Until (cache .Status .ExpirationTimestamp ) < refreshMargin {
121+ if ! ok || time .Until (cache .Status .ExpirationTimestamp ) < refreshMargin {
170122 // refresh
171- tmpCache := ClientAuthentication {}
172-
173123 if len (os .Args ) < 2 {
174124 fatal ("not enough command at args" )
175125 }
@@ -188,22 +138,157 @@ func main() {
188138 fatal ("empty stdout, but without error" )
189139 }
190140
191- if err := json .Unmarshal (bytes , & tmpCache ); err != nil {
141+ cache = ClientAuthentication {}
142+ if err := json .Unmarshal (bytes , & cache ); err != nil {
192143 fatal ("json.Unmarshal() failed(read command output): %s\n actual stdout: %s" , err , string (bytes ))
193144 }
194145
195- cacheFile .Credentials [cacheKey ] = tmpCache
196- updated = true
146+ if err := backend .Set (cacheKey , cache ); err != nil {
147+ fatal ("cache write failed: %s" , err )
148+ }
197149 }
198150
199151 // print
200- output , err := json .Marshal (cacheFile . Credentials [ cacheKey ] )
152+ output , err := json .Marshal (cache )
201153 if err != nil {
202154 fatal ("json.Marshal() failed: %s" , err )
203155 }
204156 fmt .Println (string (output ))
205157}
206158
159+ // newBackend selects a storage backend.
160+ //
161+ // "keyring" -> OS secret store (macOS Keychain / Linux Secret Service / Windows Credential Manager)
162+ // "file" -> plaintext JSON cache file
163+ // "" -> keyring if the OS secret store is reachable, otherwise file (for headless/CI hosts)
164+ func newBackend (name string ) Backend {
165+ switch name {
166+ case "file" :
167+ return fileBackend {path : resolveCacheFilepath ()}
168+ case "keyring" :
169+ return keyringBackend {}
170+ case "" :
171+ if keyringAvailable () {
172+ return keyringBackend {}
173+ }
174+ return fileBackend {path : resolveCacheFilepath ()}
175+ default :
176+ fatal ("invalid environment variable 'KUBE_CREDENTIAL_CACHE_BACKEND': %q (expected \" keyring\" or \" file\" )" , name )
177+ return nil // unreachable
178+ }
179+ }
180+
181+ // keyringAvailable reports whether the OS secret store can be reached. The probe
182+ // is non-destructive: a working store returns ErrNotFound for a missing entry,
183+ // while an unavailable store (e.g. no Secret Service / D-Bus) returns another error.
184+ func keyringAvailable () bool {
185+ _ , err := keyring .Get (keyringService , "__kcc_probe__" )
186+ return err == nil || errors .Is (err , keyring .ErrNotFound )
187+ }
188+
189+ // keyringBackend stores each credential as a JSON value in the OS secret store,
190+ // keyed by the cache key. Nothing is written to disk in plaintext.
191+ type keyringBackend struct {}
192+
193+ func (keyringBackend ) Get (key string ) (ClientAuthentication , bool , error ) {
194+ s , err := keyring .Get (keyringService , key )
195+ if err != nil {
196+ if errors .Is (err , keyring .ErrNotFound ) {
197+ return ClientAuthentication {}, false , nil
198+ }
199+ return ClientAuthentication {}, false , err
200+ }
201+ var cred ClientAuthentication
202+ if err := json .Unmarshal ([]byte (s ), & cred ); err != nil {
203+ // treat corruption as a cache miss; it will be overwritten on refresh
204+ log ("json.Unmarshal() failed(read keyring entry): %s\n ...Corruption detected, refreshing credential" , err )
205+ return ClientAuthentication {}, false , nil
206+ }
207+ return cred , true , nil
208+ }
209+
210+ func (keyringBackend ) Set (key string , cred ClientAuthentication ) error {
211+ bytes , err := json .Marshal (cred )
212+ if err != nil {
213+ return err
214+ }
215+ return keyring .Set (keyringService , key , string (bytes ))
216+ }
217+
218+ // fileBackend stores all credentials as a single plaintext JSON file, protected
219+ // only by filesystem permissions (0600 file, 0700 directory).
220+ type fileBackend struct {
221+ path string
222+ }
223+
224+ func (b fileBackend ) load () (CacheFile , error ) {
225+ cf := CacheFile {Credentials : map [string ]ClientAuthentication {}}
226+ bytes , err := os .ReadFile (b .path )
227+ if err != nil {
228+ if os .IsNotExist (err ) {
229+ return cf , nil
230+ }
231+ return cf , err
232+ }
233+ if len (bytes ) > 0 {
234+ if err := json .Unmarshal (bytes , & cf ); err != nil {
235+ // recreate on corruption, matching previous behaviour
236+ log ("json.Unmarshal() failed(read cache file): %s\n ...Corruption detected, recreate cache file" , err )
237+ return CacheFile {Credentials : map [string ]ClientAuthentication {}}, nil
238+ }
239+ }
240+ if cf .Credentials == nil {
241+ cf .Credentials = map [string ]ClientAuthentication {}
242+ }
243+ return cf , nil
244+ }
245+
246+ func (b fileBackend ) Get (key string ) (ClientAuthentication , bool , error ) {
247+ cf , err := b .load ()
248+ if err != nil {
249+ return ClientAuthentication {}, false , err
250+ }
251+ cred , ok := cf .Credentials [key ]
252+ return cred , ok , nil
253+ }
254+
255+ func (b fileBackend ) Set (key string , cred ClientAuthentication ) error {
256+ cf , err := b .load ()
257+ if err != nil {
258+ return err
259+ }
260+
261+ // cleanup expired entries
262+ for k , v := range cf .Credentials {
263+ if time .Now ().After (v .Status .ExpirationTimestamp ) {
264+ delete (cf .Credentials , k )
265+ }
266+ }
267+ cf .Credentials [key ] = cred
268+
269+ bytes , err := json .Marshal (cf )
270+ if err != nil {
271+ return err
272+ }
273+ if err := os .MkdirAll (path .Dir (b .path ), 0700 ); err != nil {
274+ return fmt .Errorf ("mkdir failed: %w" , err )
275+ }
276+ return os .WriteFile (b .path , bytes , 0600 )
277+ }
278+
279+ // resolveCacheFilepath returns the path of the plaintext cache file used by the
280+ // file backend.
281+ func resolveCacheFilepath () string {
282+ if e := os .Getenv ("KUBE_CREDENTIAL_CACHE_FILE" ); e != "" {
283+ return e
284+ }
285+ cacheDir , err := os .UserCacheDir ()
286+ if err != nil {
287+ fatal ("can't find CacheDir. fix error or set 'KUBE_CREDENTIAL_CACHE_FILE': %s" , err )
288+ }
289+ return path .Join (cacheDir , "kube-credential-cache" , "cache.json" )
290+ }
291+
207292func fatal (format string , v ... any ) {
208293 log (format , v ... )
209294
0 commit comments