1use std::fs::{File, OpenOptions};
10use std::io::Write as _;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use crate::noise::Psk;
15use anyhow::{Context, Result};
16
17use crate::tenant::{EnrollCode, Tenant, TenantSet};
18
19pub type Edit<'a> = dyn FnMut(&TenantSet) -> Result<TenantSet> + 'a;
21
22pub trait TenantStore: Send + Sync {
23 fn snapshot(&self) -> Result<TenantSet>;
25
26 fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet>;
29
30 fn add_tenant(&self, tenant: Tenant) -> Result<()> {
31 self.replace_with(&mut |s| s.with_tenant(tenant.clone()))
32 .map(drop)
33 }
34
35 fn issue_code(&self, code: EnrollCode) -> Result<()> {
36 self.replace_with(&mut |s| s.with_code(code.clone()))
37 .map(drop)
38 }
39
40 fn revoke(&self, name: &str) -> Result<()> {
42 self.replace_with(&mut |s| s.without_tenant(name)).map(drop)
43 }
44
45 fn set_disabled(&self, name: &str, disabled: bool) -> Result<()> {
47 self.replace_with(&mut |s| s.with_disabled(name, disabled))
48 .map(drop)
49 }
50
51 fn redeem(&self, code_psk: &Psk, now_unix: u64, name: String, psk: Psk) -> Result<Tenant> {
54 let mut created = None;
55 self.replace_with(&mut |s| {
56 let (next, tenant) = s.redeem(code_psk, now_unix, name.clone(), psk)?;
57 created = Some(tenant);
58 Ok(next)
59 })?;
60 Ok(created.expect("redeem succeeded without producing a tenant"))
61 }
62}
63
64#[derive(Default)]
67pub struct MemoryStore(Mutex<TenantSet>);
68
69impl MemoryStore {
70 pub fn new(initial: TenantSet) -> Self {
71 Self(Mutex::new(initial))
72 }
73}
74
75impl TenantStore for MemoryStore {
76 fn snapshot(&self) -> Result<TenantSet> {
77 Ok(self.0.lock().unwrap().clone())
78 }
79
80 fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet> {
81 let mut guard = self.0.lock().unwrap();
82 let next = edit(&guard)?;
83 *guard = next.clone();
84 Ok(next)
85 }
86}
87
88pub struct FileStore {
92 path: PathBuf,
93}
94
95impl FileStore {
96 pub fn new(path: impl Into<PathBuf>) -> Self {
97 Self { path: path.into() }
98 }
99
100 pub fn path(&self) -> &Path {
101 &self.path
102 }
103
104 fn sidecar(&self, suffix: &str) -> PathBuf {
105 let mut name = self.path.as_os_str().to_owned();
106 name.push(suffix);
107 PathBuf::from(name)
108 }
109}
110
111impl TenantStore for FileStore {
112 fn snapshot(&self) -> Result<TenantSet> {
113 TenantSet::load(&self.path)
114 }
115
116 fn replace_with(&self, edit: &mut Edit<'_>) -> Result<TenantSet> {
117 let lock = OpenOptions::new()
118 .create(true)
119 .truncate(false)
120 .write(true)
121 .open(self.sidecar(".lock"))
122 .with_context(|| format!("opening lock file for {}", self.path.display()))?;
123 lock.lock().context("locking the tenant file")?;
124
125 let current = TenantSet::load(&self.path)?;
126 let next = edit(¤t)?;
127 let text = next.to_toml()?;
128
129 let tmp = self.sidecar(&format!(".tmp-{}", std::process::id()));
132 let written = write_private(&tmp, text.as_bytes())
133 .with_context(|| format!("writing {}", tmp.display()))
134 .and_then(|()| {
135 std::fs::rename(&tmp, &self.path)
136 .with_context(|| format!("replacing {}", self.path.display()))
137 });
138 if written.is_err() {
139 let _ = std::fs::remove_file(&tmp);
140 }
141 written?;
142 Ok(next)
144 }
145}
146
147fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
149 match std::fs::remove_file(path) {
152 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
153 _ => {}
154 }
155 let mut options = OpenOptions::new();
156 options.create_new(true).write(true);
157 #[cfg(unix)]
158 {
159 use std::os::unix::fs::OpenOptionsExt;
160 options.mode(0o600);
161 }
162 let mut file: File = options.open(path)?;
163 file.write_all(bytes)?;
164 file.sync_all()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::noise::derive_psk;
171 use std::sync::Arc;
172
173 fn tenant(name: &str, token: &str, sni: &str) -> Tenant {
174 Tenant {
175 name: name.into(),
176 psk: derive_psk(token),
177 sni: sni.into(),
178 max_conns: 8,
179 disabled: false,
180 }
181 }
182
183 fn code(token: &str, sni: &str, expires_at: Option<u64>) -> EnrollCode {
184 EnrollCode {
185 psk: derive_psk(token),
186 sni: sni.into(),
187 max_conns: 5,
188 expires_at,
189 }
190 }
191
192 fn conformance(store: &dyn TenantStore) {
194 assert_eq!(store.snapshot().unwrap(), TenantSet::default());
195
196 store.add_tenant(tenant("a", "ta", "a.x")).unwrap();
197 store.add_tenant(tenant("b", "tb", "b.x")).unwrap();
198 assert!(
199 store.add_tenant(tenant("a", "other", "z.x")).is_err(),
200 "duplicate name"
201 );
202 assert_eq!(store.snapshot().unwrap().tenants().len(), 2);
203
204 store.set_disabled("a", true).unwrap();
205 assert!(store.snapshot().unwrap().tenant("a").unwrap().disabled);
206 assert!(store.set_disabled("nope", true).is_err());
207
208 store.revoke("a").unwrap();
209 assert!(store.snapshot().unwrap().tenant("a").is_none());
210 assert!(store.snapshot().unwrap().tenant("b").is_some());
211 assert!(store.revoke("a").is_err());
212
213 store.issue_code(code("c1", "c.x", None)).unwrap();
215 let made = store
216 .redeem(&derive_psk("c1"), 0, "t-new".into(), derive_psk("fresh"))
217 .unwrap();
218 assert_eq!((made.sni.as_str(), made.max_conns), ("c.x", 5));
219 let snap = store.snapshot().unwrap();
220 assert!(snap.codes().is_empty());
221 assert_eq!(snap.tenant("t-new").unwrap().psk, derive_psk("fresh"));
222 assert!(
223 store
224 .redeem(&derive_psk("c1"), 0, "t-again".into(), derive_psk("x"))
225 .is_err(),
226 "a code works once"
227 );
228
229 store.issue_code(code("old", "o.x", Some(100))).unwrap();
231 let before = store.snapshot().unwrap();
232 assert!(
233 store
234 .redeem(&derive_psk("old"), 100, "t-old".into(), derive_psk("y"))
235 .is_err()
236 );
237 assert_eq!(store.snapshot().unwrap(), before);
238 }
239
240 fn concurrent_writers_lose_nothing(store: Arc<dyn TenantStore>) {
241 let threads: Vec<_> = (0..8)
242 .map(|t| {
243 let store = store.clone();
244 std::thread::spawn(move || {
245 for i in 0..10 {
246 let n = t * 10 + i;
247 store
248 .issue_code(code(&format!("tok-{n}"), &format!("h{n}.x"), None))
249 .unwrap();
250 }
251 })
252 })
253 .collect();
254 for t in threads {
255 t.join().unwrap();
256 }
257 assert_eq!(store.snapshot().unwrap().codes().len(), 80);
258 }
259
260 #[test]
261 fn memory_store_conforms() {
262 conformance(&MemoryStore::default());
263 concurrent_writers_lose_nothing(Arc::new(MemoryStore::default()));
264 }
265
266 #[test]
267 fn file_store_conforms() {
268 let dir = tempfile::tempdir().unwrap();
269 conformance(&FileStore::new(dir.path().join("tenants.toml")));
270 let dir = tempfile::tempdir().unwrap();
271 concurrent_writers_lose_nothing(Arc::new(FileStore::new(dir.path().join("t.toml"))));
272 }
273
274 #[test]
275 fn two_file_stores_on_one_file_do_not_lose_each_others_writes() {
276 let dir = tempfile::tempdir().unwrap();
278 let path = dir.path().join("t.toml");
279 let (a, b) = (FileStore::new(&path), FileStore::new(&path));
280 let ta = std::thread::spawn(move || {
281 for i in 0..20 {
282 a.issue_code(code(&format!("a{i}"), &format!("a{i}.x"), None))
283 .unwrap();
284 }
285 });
286 let tb = std::thread::spawn(move || {
287 for i in 0..20 {
288 b.issue_code(code(&format!("b{i}"), &format!("b{i}.x"), None))
289 .unwrap();
290 }
291 });
292 ta.join().unwrap();
293 tb.join().unwrap();
294 assert_eq!(FileStore::new(&path).snapshot().unwrap().codes().len(), 40);
295 }
296
297 #[test]
298 fn a_missing_file_is_an_empty_set_and_a_corrupt_one_is_an_error() {
299 let dir = tempfile::tempdir().unwrap();
300 let path = dir.path().join("t.toml");
301 let store = FileStore::new(&path);
302 assert_eq!(store.snapshot().unwrap(), TenantSet::default());
303
304 std::fs::write(&path, "this is [not valid").unwrap();
305 assert!(store.snapshot().is_err());
306 assert!(store.add_tenant(tenant("a", "t", "a.x")).is_err());
307 assert_eq!(
308 std::fs::read_to_string(&path).unwrap(),
309 "this is [not valid",
310 "a failed edit must not overwrite the file"
311 );
312 }
313
314 #[cfg(unix)]
315 #[test]
316 fn the_file_is_readable_by_its_owner_only() {
317 use std::os::unix::fs::PermissionsExt;
318 let dir = tempfile::tempdir().unwrap();
319 let path = dir.path().join("t.toml");
320 FileStore::new(&path)
321 .add_tenant(tenant("a", "t", "a.x"))
322 .unwrap();
323 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
324 assert_eq!(mode & 0o777, 0o600);
325 }
326}