88
99# This module requires Python 3.8+ (secrets, f-strings with =, os.replace, json.JSONDecodeError). This should be fine since it will always run on localhost and the Ansible Controller has to be Python 3.9+ anyway
1010
11+ import copy
1112import email .encoders
1213import email .mime .application
1314import email .mime .multipart
1819import mimetypes
1920import os
2021import secrets
21- import urllib .parse
22+ import tempfile
23+ import time
2224from urllib .error import HTTPError , URLError
2325
2426from ansible .module_utils .common .collections import Mapping
2729from ansible .module_utils ._text import to_bytes , to_native , to_text
2830from ansible .module_utils .urls import (ConnectionError , SSLValidationError ,
2931 open_url )
32+ from ansible .utils .display import Display
33+
34+ display = Display ()
3035
3136
3237def prepare_multipart_no_base64 (fields ):
@@ -131,6 +136,11 @@ def prepare_multipart_no_base64(fields):
131136 )
132137
133138
139+ CACHE_DIR = os .environ .get ('XDG_RUNTIME_DIR' , '/tmp' )
140+ CACHE_FILE = os .path .join (CACHE_DIR , 'lfops_bitwarden_cache.json' )
141+ CACHE_VERSION = 2026032701
142+
143+
134144class BitwardenException (Exception ):
135145 pass
136146
@@ -140,6 +150,8 @@ class Bitwarden(object):
140150
141151 def __init__ (self , hostname = '127.0.0.1' , port = 8087 ):
142152 self ._base_url = 'http://%s:%s' % (hostname , port )
153+ self ._cache = None
154+ self ._load_cache ()
143155
144156 def _api_call (self , url_path , method = 'GET' , body = None , body_format = 'json' ):
145157 url = '%s/%s' % (self ._base_url , url_path )
@@ -158,7 +170,8 @@ def _api_call(self, url_path, method='GET', body=None, body_format='json'):
158170
159171 # mostly taken from ansible.builtin.url lookup plugin
160172 try :
161- response = open_url (url , method = method , data = body , headers = headers )
173+ # increased the timeout since listing all items via `list/object/items` takes forever (13s for ~2500 items)
174+ response = open_url (url , method = method , data = body , headers = headers , timeout = 60 )
162175 except HTTPError as e :
163176 raise BitwardenException ("Received HTTP error for %s : %s" % (url , to_native (e )))
164177 except URLError as e :
@@ -179,17 +192,85 @@ def _api_call(self, url_path, method='GET', body=None, body_format='json'):
179192 return result
180193
181194
195+ def _load_cache (self ):
196+ """Load the cache from disk. If missing, unreadable, or invalid, start with an empty cache.
197+ Freshness is handled by sync().
198+ """
199+ try :
200+ with open (CACHE_FILE , 'r' ) as f :
201+ data = json .load (f )
202+ if data .get ('version' ) == CACHE_VERSION :
203+ self ._cache = data
204+ item_count = len (self ._cache ['items' ]) if self ._cache ['items' ] is not None else 0
205+ display .vvv ('lfbw - cache loaded from %s (%d items)' % (CACHE_FILE , item_count ))
206+ return
207+ except (IOError , OSError , ValueError , json .decoder .JSONDecodeError ):
208+ pass
209+ self ._cache = {
210+ 'version' : CACHE_VERSION ,
211+ 'sync_timestamp' : 0 ,
212+ 'items' : None ,
213+ 'templates' : {},
214+ }
215+ display .vvv ('lfbw - no valid cache found, starting fresh' )
216+
217+
218+ def _save_cache (self ):
219+ """Write the cache to disk atomically.
220+ """
221+ try :
222+ fd , tmp_path = tempfile .mkstemp (
223+ dir = os .path .dirname (CACHE_FILE ),
224+ prefix = '.lfops_bw_cache_' ,
225+ )
226+ try :
227+ with os .fdopen (fd , 'w' ) as f :
228+ json .dump (self ._cache , f )
229+ os .replace (tmp_path , CACHE_FILE )
230+ display .vvv ('lfbw - cache saved to %s' % (CACHE_FILE ))
231+ except Exception :
232+ os .unlink (tmp_path )
233+ raise
234+ except (IOError , OSError ):
235+ display .vvv ('lfbw - failed to save cache to %s' % (CACHE_FILE ))
236+
237+
238+ def _get_template (self , template_name ):
239+ """Return a template from cache, fetching from API on first use.
240+ Templates are static API schema definitions that never change.
241+ """
242+ if template_name not in self ._cache ['templates' ]:
243+ display .vvv ('lfbw - fetching template "%s" from API' % (template_name ))
244+ result = self ._api_call ('object/template/%s' % (template_name ))
245+ self ._cache ['templates' ][template_name ] = result ['data' ]['template' ]
246+ self ._save_cache ()
247+ else :
248+ display .vvv ('lfbw - using cached template "%s"' % (template_name ))
249+ return copy .deepcopy (self ._cache ['templates' ][template_name ])
250+
251+
252+ @property
182253 def is_unlocked (self ):
183254 """Check if the Bitwarden vault is unlocked.
184255 """
185256 result = self ._api_call ('status' )
186257 return result ['data' ]['template' ]['status' ] == 'unlocked'
187258
188259
189- def sync (self ):
190- """Pull the latest vault data from server.
260+ def sync (self , force = False , interval = 60 ):
261+ """Pull the latest vault data from server and repopulate the items cache.
262+ Syncs only if the last sync was more than `interval` seconds ago, unless `force` is True.
191263 """
192- return self ._api_call ('sync' , method = 'POST' )
264+ if not force and time .time () - self ._cache .get ('sync_timestamp' , 0 ) < interval :
265+ display .vvv ('lfbw - sync skipped, last sync was recent enough' )
266+ return
267+ display .vvv ('lfbw - syncing vault (force=%s)' % (force ))
268+ self ._api_call ('sync' , method = 'POST' )
269+ result = self ._api_call ('list/object/items' )
270+ self ._cache ['items' ] = result ['data' ]['data' ]
271+ self ._cache ['sync_timestamp' ] = time .time ()
272+ display .vvv ('lfbw - sync complete, cached %d items' % (len (self ._cache ['items' ])))
273+ self ._save_cache ()
193274
194275
195276 def get_items (self , name , username = None , folder_id = None , collection_id = None , organization_id = None ):
@@ -236,18 +317,11 @@ def get_items(self, name, username=None, folder_id=None, collection_id=None, org
236317 if isinstance (organization_id , str ) and len (organization_id .strip ()) == 0 :
237318 organization_id = None
238319
239- params = urllib .parse .urlencode (
240- {
241- 'search' : name ,
242- },
243- quote_via = urllib .parse .quote ,
244- )
245- result = self ._api_call ('list/object/items?%s' % (params ))
246-
247- # make sure that all the given parameters exactly match the requested one, as `bw` is not that precise (only performs a search)
248- # we are not using the filter parameters of the `bw` utility, as they perform an OR operation, but we want AND
320+ display .vvv ('lfbw - searching cache for name="%s", username="%s"' % (name , username ))
249321 matching_items = []
250- for item in result ['data' ]['data' ]:
322+ for item in self ._cache ['items' ]:
323+ if item .get ('type' ) != 1 :
324+ continue # skip non-login items (cards, secure notes, identities)
251325 if item ['name' ] == name \
252326 and (item ['login' ]['username' ] == username ) \
253327 and (item .get ('folderId' ) == folder_id ) \
@@ -260,13 +334,20 @@ def get_items(self, name, username=None, folder_id=None, collection_id=None, org
260334 and (item .get ('organizationId' ) == organization_id ):
261335 matching_items .append (item )
262336
337+ display .vvv ('lfbw - found %d matching item(s)' % (len (matching_items )))
263338 return matching_items
264339
265340
266341 def get_item_by_id (self , item_id ):
267342 """Get an item by ID from Bitwarden. Returns the item or None. Throws an exception if the id leads to unambiguous results.
268343 """
269-
344+ display .vvv ('lfbw - looking up item by id=%s' % (item_id ))
345+ for item in self ._cache ['items' ]:
346+ if item .get ('id' ) == item_id :
347+ display .vvv ('lfbw - found item in cache' )
348+ return item
349+ # fallback to API if not found in cache (item could have been created externally)
350+ display .vvv ('lfbw - item not in cache, falling back to API' )
270351 result = self ._api_call ('object/item/%s' % (item_id ))
271352 return result ['data' ]
272353
@@ -299,9 +380,7 @@ def get_template_item_login_uri(self, uris):
299380 """
300381 login_uris = []
301382 if uris :
302- # To create uris, fetch the JSON structure for that.
303- result = self ._api_call ('object/template/item.login.uri' )
304- template = result ['data' ]['template' ]
383+ template = self ._get_template ('item.login.uri' )
305384 for uri in uris :
306385 login_uri = template .copy () # make sure we are not editing the same object repeatedly
307386 login_uri ['uri' ] = uri
@@ -322,9 +401,7 @@ def get_template_item_login(self, username=None, password=None, login_uris=None)
322401 "totp": "JBSWY3DPEHPK3PXP"
323402 }
324403 """
325- # To create a login item, fetch the JSON structure for that.
326- result = self ._api_call ('object/template/item.login' )
327- login = result ['data' ]['template' ]
404+ login = self ._get_template ('item.login' )
328405 login ['password' ] = password
329406 login ['totp' ] = ''
330407 login ['uris' ] = login_uris or []
@@ -354,10 +431,7 @@ def get_template_item(self, name, login=None, notes=None, organization_id=None,
354431 "reprompt": 0
355432 }
356433 """
357- # To create an item later on, fetch the item JSON structure, and fill in the appropriate
358- # values.
359- result = self ._api_call ('object/template/item' )
360- item = result ['data' ]['template' ]
434+ item = self ._get_template ('item' )
361435 item ['collectionIds' ] = collection_ids
362436 item ['folderId' ] = folder_id
363437 item ['login' ] = login
@@ -371,27 +445,33 @@ def get_template_item(self, name, login=None, notes=None, organization_id=None,
371445 def create_item (self , item ):
372446 """Creates an item object in Bitwarden.
373447 """
448+ display .vvv ('lfbw - creating item "%s"' % (item .get ('name' , '' )))
374449 result = self ._api_call ('object/item' , method = 'POST' , body = item )
450+ self .sync (force = True )
375451 return result ['data' ]
376452
377453
378454 def edit_item (self , item , item_id ):
379455 """Edits an item object in Bitwarden.
380456 """
457+ display .vvv ('lfbw - editing item %s' % (item_id ))
381458 result = self ._api_call ('object/item/%s' % (item_id ), method = 'PUT' , body = item )
459+ self .sync (force = True )
382460 return result ['data' ]
383461
384462
385463 def add_attachment (self , item_id , attachment_path ):
386464 """Adds the file at `attachment_path` to the item specified by `item_id`
387465 """
466+ display .vvv ('lfbw - adding attachment "%s" to item %s' % (attachment_path , item_id ))
388467
389468 body = {
390469 'file' : {
391470 'filename' : attachment_path ,
392471 },
393472 }
394473 result = self ._api_call ('attachment?itemId=%s' % (item_id ), method = 'POST' , body = body , body_format = 'form-multipart' )
474+ self .sync (force = True )
395475 return result
396476
397477 @staticmethod
0 commit comments