Skip to main content

bitwarden_access_connector/
instance_lock.rs

1//! Keeps a second `bwac` from running with the same access connector token on one host.
2//!
3//! The lock lives where every account on the host can reach it, so it also catches a `bwac` started
4//! from a console under another account. The cost is that any local account can take the lock for a
5//! token first, since the API key ID is not secret, and keep the connector exiting with code 5. The
6//! lock only saves wasted work: the server hands each job to one connector, so two processes on one
7//! token can't both rotate it.
8
9use uuid::Uuid;
10
11/// Held for the life of the process. The operating system releases it when the process exits, even
12/// after a crash.
13pub struct InstanceLock {
14    _guard: platform::Guard,
15}
16
17/// Why [`acquire`] did not take the lock.
18#[derive(Debug, thiserror::Error)]
19pub enum InstanceLockError {
20    /// Another process on this host holds the lock for the same token.
21    #[error("another bwac process is already running with this access connector token")]
22    AlreadyRunning,
23    /// The check itself failed, so it is unknown whether another process is running.
24    #[error("could not check for another bwac process: {0}")]
25    Unavailable(std::io::Error),
26}
27
28/// Takes the host-wide lock for the token whose API key ID is `api_key_id`.
29pub fn acquire(api_key_id: Uuid) -> Result<InstanceLock, InstanceLockError> {
30    platform::acquire(&lock_name(api_key_id)).map(|guard| InstanceLock { _guard: guard })
31}
32
33fn lock_name(api_key_id: Uuid) -> String {
34    format!("bwac-{api_key_id}")
35}
36
37/// A lock file, locked with `File::try_lock`. It is never removed, since a process could be opening
38/// it at that moment.
39#[cfg(any(windows, target_os = "macos"))]
40mod platform {
41    use std::{
42        fs::{File, OpenOptions, TryLockError},
43        io,
44        path::PathBuf,
45    };
46
47    use super::InstanceLockError;
48
49    pub(super) struct Guard {
50        _file: File,
51    }
52
53    pub(super) fn acquire(name: &str) -> Result<Guard, InstanceLockError> {
54        let path = path(name).map_err(InstanceLockError::Unavailable)?;
55        let mut options = OpenOptions::new();
56        #[cfg(target_os = "macos")]
57        {
58            use std::os::unix::fs::OpenOptionsExt;
59            // `/var/tmp` is world-writable, so a planted symlink would otherwise make the connector
60            // create a file wherever it points.
61            const O_NOFOLLOW: i32 = 0x100;
62            options.custom_flags(O_NOFOLLOW);
63        }
64
65        // Another account's file can't be opened for writing, but read access is enough to lock it.
66        let opened = match options.clone().write(true).create(true).open(&path) {
67            Err(e) if e.kind() == io::ErrorKind::PermissionDenied => options.read(true).open(&path),
68            result => result,
69        };
70        let file = opened.map_err(InstanceLockError::Unavailable)?;
71        match file.try_lock() {
72            Ok(()) => Ok(Guard { _file: file }),
73            Err(TryLockError::WouldBlock) => Err(InstanceLockError::AlreadyRunning),
74            Err(TryLockError::Error(e)) => Err(InstanceLockError::Unavailable(e)),
75        }
76    }
77
78    /// Every account can create files in `%ProgramData%` and read other accounts' files there.
79    #[cfg(windows)]
80    pub(super) fn path(name: &str) -> io::Result<PathBuf> {
81        let dir = std::env::var_os("ProgramData")
82            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "ProgramData is not set"))?;
83        Ok(PathBuf::from(dir).join(format!("{name}.lock")))
84    }
85
86    /// Every account shares `/var/tmp`, and periodic cleanup sweeps only `/tmp`.
87    #[cfg(target_os = "macos")]
88    pub(super) fn path(name: &str) -> io::Result<PathBuf> {
89        Ok(PathBuf::from("/var/tmp").join(format!("{name}.lock")))
90    }
91}
92
93/// An abstract socket. It has no file, so the systemd unit's PrivateTmp and ProtectSystem can't
94/// hide it from a console run. Abstract sockets are per network namespace, so a process in another
95/// namespace (a container, for example) doesn't see the lock.
96#[cfg(target_os = "linux")]
97mod platform {
98    use std::{
99        io,
100        os::{
101            linux::net::SocketAddrExt,
102            unix::net::{SocketAddr, UnixListener},
103        },
104    };
105
106    use super::InstanceLockError;
107
108    pub(super) struct Guard {
109        _listener: UnixListener,
110    }
111
112    pub(super) fn acquire(name: &str) -> Result<Guard, InstanceLockError> {
113        let addr = SocketAddr::from_abstract_name(name).map_err(InstanceLockError::Unavailable)?;
114        match UnixListener::bind_addr(&addr) {
115            Ok(listener) => Ok(Guard {
116                _listener: listener,
117            }),
118            Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
119                Err(InstanceLockError::AlreadyRunning)
120            }
121            Err(e) => Err(InstanceLockError::Unavailable(e)),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn second_lock_for_the_same_token_is_refused() {
132        let id = Uuid::new_v4();
133        let held = acquire(id).expect("first lock");
134
135        assert!(matches!(
136            acquire(id),
137            Err(InstanceLockError::AlreadyRunning)
138        ));
139        drop(held);
140        remove_lock_file(id);
141    }
142
143    #[test]
144    fn different_tokens_do_not_conflict() {
145        let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
146        let first = acquire(a).expect("first lock");
147        let second = acquire(b).expect("second lock");
148
149        drop((first, second));
150        remove_lock_file(a);
151        remove_lock_file(b);
152    }
153
154    #[test]
155    fn dropping_the_lock_releases_it() {
156        let id = Uuid::new_v4();
157        drop(acquire(id).expect("first lock"));
158
159        drop(acquire(id).expect("lock after release"));
160        remove_lock_file(id);
161    }
162
163    #[cfg(target_os = "macos")]
164    #[test]
165    fn a_symlink_at_the_lock_path_is_not_followed() {
166        let id = Uuid::new_v4();
167        let target = std::env::temp_dir().join(format!("bwac-symlink-target-{id}"));
168        let link = platform::path(&lock_name(id)).expect("lock path");
169        std::os::unix::fs::symlink(&target, &link).expect("plant symlink");
170
171        let result = acquire(id);
172
173        let created = target.exists();
174        let _ = std::fs::remove_file(&link);
175        let _ = std::fs::remove_file(&target);
176        assert!(matches!(result, Err(InstanceLockError::Unavailable(_))));
177        assert!(!created);
178    }
179
180    #[cfg(any(windows, target_os = "macos"))]
181    fn remove_lock_file(id: Uuid) {
182        if let Ok(path) = platform::path(&lock_name(id)) {
183            let _ = std::fs::remove_file(path);
184        }
185    }
186
187    #[cfg(target_os = "linux")]
188    fn remove_lock_file(_: Uuid) {}
189}