Skip to content

Commit c93e890

Browse files
committed
feat(plugins/bitwarden): add file-based item cache to reduce bw serve API calls
1 parent e793c53 commit c93e890

File tree

3 files changed

+109
-49
lines changed

3 files changed

+109
-49
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Added
2020

21+
* **plugin:bitwarden_item**: Add file-based item cache to reduce `bw serve` API calls, preventing crashes under load. Cache is stored in `$XDG_RUNTIME_DIR` (RAM-backed tmpfs) with `/tmp` fallback. Convert `is_unlocked` to a property to fix it never being called.
2122
* **role:freeipa_server**: Add `--diff` support for all FreeIPA modules and add `freeipa_server:configure` tag
2223
* **role:mariadb_server**: Add `mariadb_server__cnf_wsrep_log_conflicts` and `mariadb_server__cnf_wsrep_retry_autocommit` variables
2324
* **role:mariadb_server**: Add `mariadb_server__cnf_wsrep_gtid_mode` variable to configure `wsrep_gtid_mode` for Galera

plugins/lookup/bitwarden_item.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,6 @@
277277
sample: 'root'
278278
'''
279279

280-
import time
281-
282280
from ansible.errors import AnsibleError
283281
from ansible.plugins.lookup import LookupBase
284282
from ansible.utils.display import Display
@@ -290,9 +288,6 @@
290288
# https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#developing-lookup-plugins
291289
# inspired by the lookup plugins lastpass (same topic) and redis (more modern)
292290

293-
SYNC_INTERVAL = 60 # seconds
294-
SYNC_TIMESTAMP_FILE = '/tmp/lfops_bitwarden_sync_time'
295-
296291
class LookupModule(LookupBase):
297292

298293
def run(self, terms, variables=None, **kwargs):
@@ -302,23 +297,7 @@ def run(self, terms, variables=None, **kwargs):
302297
raise AnsibleError('Not logged into Bitwarden, or Bitwarden Vault is locked. Please run `bw login` and `bw unlock` first.')
303298
display.vvv('lfbwlp - run - bitwarden vault is unlocked')
304299

305-
timestamp = 0
306-
try:
307-
with open(SYNC_TIMESTAMP_FILE, 'r') as f:
308-
timestamp = float(f.read().strip())
309-
except (ValueError, IOError):
310-
pass # we just sync if an error occurs
311-
312-
if time.time() - timestamp >= SYNC_INTERVAL:
313-
display.vvv('lfbwlp - run - syncing the vault')
314-
bw.sync()
315-
timestamp = time.time()
316-
317-
try:
318-
with open(SYNC_TIMESTAMP_FILE, 'w') as f:
319-
f.write(str(timestamp))
320-
except IOError:
321-
display.vvv('lfbwlp - run - failed to write last sync time')
300+
bw.sync()
322301

323302
ret = []
324303
for term in terms:

plugins/module_utils/bitwarden.py

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
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
1112
import email.encoders
1213
import email.mime.application
1314
import email.mime.multipart
@@ -18,7 +19,8 @@
1819
import mimetypes
1920
import os
2021
import secrets
21-
import urllib.parse
22+
import tempfile
23+
import time
2224
from urllib.error import HTTPError, URLError
2325

2426
from ansible.module_utils.common.collections import Mapping
@@ -27,6 +29,9 @@
2729
from ansible.module_utils._text import to_bytes, to_native, to_text
2830
from ansible.module_utils.urls import (ConnectionError, SSLValidationError,
2931
open_url)
32+
from ansible.utils.display import Display
33+
34+
display = Display()
3035

3136

3237
def 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+
134144
class 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

Comments
 (0)