update_ssh_keys/
lib.rs

1// Copyright 2017 CoreOS, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! update-ssh-keys library
16//!
17//! this library provides an interface for manipulating the authorized keys
18//! directory. in particular, it provides functionality for
19//! * listing authorized keys
20//! * adding authorized keys
21//! * removing an authorized key by name
22//! * disabling an authorized key by name
23//!
24//! when the authorized keys directory is first opened, a file in the users home
25//! directory is locked. This lock is observed by this library and the golang
26//! analogue. When the directory is no longer being manipulated, the lock is
27//! released. See `AuthorizedKeys::open` for details.
28
29#![allow(deprecated)]
30
31#[macro_use]
32extern crate error_chain;
33extern crate fs2;
34extern crate openssh_keys;
35extern crate users;
36
37pub mod errors {
38    error_chain! {
39        links {
40            ParseError(::openssh_keys::errors::Error, ::openssh_keys::errors::ErrorKind);
41        }
42        foreign_links {
43            Io(::std::io::Error);
44        }
45        errors {
46            KeysDisabled(name: String) {
47                description("keys are disabled")
48                display("keys with name '{}' are disabled", name)
49            }
50            KeysExist(name: String) {
51                description("keys already exist")
52                display("keys with name '{}' already exist", name)
53            }
54            NoKeysFound(ssh_dir: String) {
55                description("no keys found")
56                display("update-ssh-keys: no keys found in {}", ssh_dir)
57            }
58        }
59    }
60}
61
62use errors::*;
63use fs2::FileExt;
64use openssh_keys::PublicKey;
65use std::collections::HashMap;
66use std::fs::{self, File};
67use std::io::{BufRead, BufReader, Read, Write};
68use std::path::{Path, PathBuf};
69use users::os::unix::UserExt;
70use users::{switch, User};
71
72const SSH_DIR: &str = ".ssh";
73const AUTHORIZED_KEYS_DIR: &str = "authorized_keys.d";
74const AUTHORIZED_KEYS_FILE: &str = "authorized_keys";
75const PRESERVED_KEYS_FILE: &str = "old_authorized_keys";
76const LOCK_FILE: &str = ".authorized_keys.d.lock";
77const STAGE_FILE: &str = ".authorized_keys.d.stage_file";
78const STAGE_DIR: &str = ".authorized_keys.d.stage_dir";
79const STAGE_OLD_DIR: &str = ".authorized_keys.d.old";
80
81fn lock_file(user: &User) -> PathBuf {
82    user.home_dir().join(LOCK_FILE)
83}
84
85fn default_ssh_dir(user: &User) -> PathBuf {
86    user.home_dir().join(SSH_DIR)
87}
88
89fn authorized_keys_dir<P: AsRef<Path>>(ssh_dir: P) -> PathBuf {
90    ssh_dir.as_ref().join(AUTHORIZED_KEYS_DIR)
91}
92
93fn authorized_keys_file<P: AsRef<Path>>(ssh_dir: P) -> PathBuf {
94    ssh_dir.as_ref().join(AUTHORIZED_KEYS_FILE)
95}
96
97fn stage_dir<P: AsRef<Path>>(ssh_dir: P) -> PathBuf {
98    ssh_dir.as_ref().join(STAGE_DIR)
99}
100
101fn stage_old_dir<P: AsRef<Path>>(ssh_dir: P) -> PathBuf {
102    ssh_dir.as_ref().join(STAGE_OLD_DIR)
103}
104
105fn stage_file<P: AsRef<Path>>(ssh_dir: P) -> PathBuf {
106    ssh_dir.as_ref().join(STAGE_FILE)
107}
108
109fn switch_user(user: &User) -> Result<switch::SwitchUserGuard> {
110    switch::switch_user_group(user.uid(), user.primary_group_id())
111        .chain_err(|| "failed to switch user/group")
112}
113
114#[derive(Debug)]
115struct FileLock {
116    pub lock: File,
117}
118
119impl Drop for FileLock {
120    fn drop(&mut self) {
121        self.unlock().unwrap();
122    }
123}
124
125impl FileLock {
126    fn try_new(path: &Path) -> Result<Self> {
127        Ok(FileLock {
128            lock: File::create(path)
129                .chain_err(|| format!("failed to create lock file: {:?}", path))?,
130        })
131    }
132
133    fn lock(&self) -> Result<()> {
134        self.lock
135            .lock_exclusive()
136            .chain_err(|| "failed to lock file")
137    }
138
139    fn unlock(&self) -> Result<()> {
140        self.lock.unlock().chain_err(|| "failed to unlock file")
141    }
142}
143
144#[derive(Debug)]
145pub struct AuthorizedKeys {
146    pub ssh_dir: PathBuf,
147    pub keys: HashMap<String, AuthorizedKeySet>,
148    pub user: User,
149    lock: FileLock,
150}
151
152impl Drop for AuthorizedKeys {
153    fn drop(&mut self) {}
154}
155
156#[derive(Clone, Debug, Default)]
157pub struct AuthorizedKeySet {
158    pub filename: String,
159    pub disabled: bool,
160    pub keys: Vec<AuthorizedKeyEntry>,
161}
162
163#[derive(Clone, Debug)]
164pub enum AuthorizedKeyEntry {
165    Valid { key: PublicKey },
166    Invalid { key: String },
167}
168
169/// `truncate_dir` empties a directory and resets it's permission to the current
170/// umask. If the directory doesn't exists, it creates it. If the path exists
171/// but it's a file, it deletes the file and creates a directory there instead.
172fn truncate_dir<P: AsRef<Path>>(dir: P) -> Result<()> {
173    let dir = dir.as_ref();
174
175    if dir.exists() {
176        if dir.is_dir() {
177            fs::remove_dir_all(dir)
178                .chain_err(|| format!("failed to remove existing directory '{:?}'", dir))?;
179        } else if dir.is_file() {
180            fs::remove_file(dir)
181                .chain_err(|| format!("failed to remove existing file '{:?}'", dir))?;
182        } else {
183            return Err(format!(
184                "failed to remove existing path '{:?}': not a file or directory",
185                dir
186            )
187            .into());
188        }
189    }
190
191    fs::create_dir_all(dir).chain_err(|| format!("failed to create directory '{:?}'", dir))
192}
193
194/// `replace_dir` moves old to new safely.
195///
196/// It takes the following steps to do so:
197/// 1. Truncating stage, in case a stale staging directory is still around.
198/// 1. Moving new to stage.
199/// 1. Moving old to new.
200/// 1. Truncating stage again to clean up.
201/// If new doesn't exist, it simply renames old to new. if new is a file, it
202/// deletes the file and moves the directory. If old doesn't exist, nothing
203/// happens. If old is a file and not a directory, nothing happens.
204fn replace_dir<P: AsRef<Path>>(old: P, new: P, stage: P) -> Result<()> {
205    let old = old.as_ref();
206    let new = new.as_ref();
207    let stage = stage.as_ref();
208
209    if old.exists() && old.is_dir() {
210        // sync the old directory to ensure our changes have been persisted
211        let old_as_file = File::open(old)
212            .chain_err(|| format!("failed to open old dir '{}' for syncing", old.display()))?;
213        old_as_file
214            .sync_all()
215            .chain_err(|| format!("failed to sync old dir '{}'", old.display()))?;
216
217        truncate_dir(stage)?;
218        if new.exists() {
219            fs::rename(new, stage)
220                .chain_err(|| format!("failed to move '{:?}' to '{:?}'", new, stage))?;
221        }
222        fs::rename(old, new).chain_err(|| format!("failed to move '{:?}' to '{:?}'", old, new))?;
223
224        let parent_path = new
225            .parent()
226            .ok_or_else(|| format!("failed to sync parent directory of '{}'", new.display()))?;
227        let parent_dir = File::open(parent_path)
228            .chain_err(|| format!("failed to open dir '{}' for syncing", parent_path.display()))?;
229        parent_dir
230            .sync_all()
231            .chain_err(|| format!("failed to sync dir '{}'", parent_path.display()))?;
232
233        truncate_dir(stage)?;
234    }
235
236    Ok(())
237}
238
239impl AuthorizedKeys {
240    pub fn authorized_keys_dir(&self) -> PathBuf {
241        authorized_keys_dir(&self.ssh_dir)
242    }
243
244    pub fn authorized_keys_file(&self) -> PathBuf {
245        authorized_keys_file(&self.ssh_dir)
246    }
247
248    pub fn stage_dir(&self) -> PathBuf {
249        stage_dir(&self.ssh_dir)
250    }
251
252    fn stage_old_dir(&self) -> PathBuf {
253        stage_old_dir(&self.ssh_dir)
254    }
255
256    pub fn stage_file(&self) -> PathBuf {
257        stage_file(&self.ssh_dir)
258    }
259
260    /// write writes all authorized_keys.d changes onto disk. it writes the
261    /// current state to a staging directory and then moves that staging
262    /// directory to the authorized_keys.d path.
263    pub fn write(&self) -> Result<()> {
264        // switch users
265        let _guard = switch_user(&self.user)?;
266
267        // get our staging directory
268        let stage_dir = self.stage_dir();
269        truncate_dir(&stage_dir).chain_err(|| {
270            format!(
271                "failed to create staging directory '{}'",
272                stage_dir.display()
273            )
274        })?;
275
276        // write all the keys to the staging directory
277        for keyset in self.keys.values() {
278            let keyfilename = stage_dir.join(&keyset.filename);
279            let mut keyfile = File::create(&keyfilename)
280                .chain_err(|| format!("failed to create file '{:?}'", keyfilename))?;
281            // if the keyset is disabled, skip it. we still want to have a
282            // zero-sized file with it's name though to signal that it's
283            // disabled.
284            if keyset.disabled {
285                continue;
286            }
287            for key in &keyset.keys {
288                match *key {
289                    AuthorizedKeyEntry::Valid { ref key } => writeln!(keyfile, "{}", key)
290                        .chain_err(|| format!("failed to write to file '{:?}'", keyfilename))?,
291                    AuthorizedKeyEntry::Invalid { ref key } => writeln!(keyfile, "{}", key)
292                        .chain_err(|| format!("failed to write to file '{:?}'", keyfilename))?,
293                }
294            }
295
296            keyfile
297                .sync_all()
298                .chain_err(|| format!("failed to sync file '{:?}'", keyfilename))?;
299        }
300
301        replace_dir(
302            &stage_dir,
303            &self.authorized_keys_dir(),
304            &self.stage_old_dir(),
305        )
306    }
307
308    /// sync writes all the keys we have to authorized_keys. it writes the
309    /// current state to a staging file and then moves that staging file to the
310    /// authorized_keys path
311    pub fn sync(&self) -> Result<()> {
312        // if we have no keys, don't overwrite the authorized_keys file.
313        // if the user wants to delete all their ssh keys, we won't help them
314        if self.keys.is_empty() {
315            return Err(ErrorKind::NoKeysFound(format!("{:?}", self.authorized_keys_dir())).into());
316        }
317
318        // switch users
319        let _guard = switch_user(&self.user)?;
320
321        // get our staging directory
322        let stage_filename = self.stage_file();
323        let mut stage_file = File::create(&stage_filename).chain_err(|| {
324            format!(
325                "failed to create or truncate staging file '{:?}'",
326                stage_filename
327            )
328        })?;
329
330        // note that this file is auto-generated
331        writeln!(stage_file, "# auto-generated by update-ssh-keys")
332            .chain_err(|| format!("failed to write to file '{:?}'", stage_filename))?;
333
334        // write all the keys to the staging file
335        for keyset in self.keys.values() {
336            // if the keyset is disabled, skip it
337            if keyset.disabled {
338                continue;
339            }
340            for key in &keyset.keys {
341                // only write the key to authorized_keys if it is valid
342                if let AuthorizedKeyEntry::Valid { ref key } = *key {
343                    writeln!(stage_file, "{}", key)
344                        .chain_err(|| format!("failed to write to file '{:?}'", stage_filename))?;
345                }
346            }
347        }
348
349        stage_file
350            .sync_all()
351            .chain_err(|| format!("failed to sync file '{:?}'", stage_filename))?;
352        drop(stage_file);
353
354        // destroy the old authorized keys file and move the staging one to that
355        // location
356        fs::rename(&stage_filename, &self.authorized_keys_file()).chain_err(|| {
357            format!(
358                "failed to move '{:?}' to '{:?}'",
359                stage_filename,
360                self.authorized_keys_file()
361            )
362        })?;
363
364        let parent_path = stage_filename.parent().ok_or_else(|| {
365            format!(
366                "failed to sync parent directory of '{}'",
367                stage_filename.display()
368            )
369        })?;
370        let parent_dir_file = File::open(parent_path)
371            .chain_err(|| format!("failed to open '{}' for syncing", parent_path.display()))?;
372        parent_dir_file
373            .sync_all()
374            .chain_err(|| format!("failed to sync '{}'", parent_path.display()))?;
375
376        Ok(())
377    }
378
379    /// read_all_keys reads all of the authorized keys files in a given
380    /// directory. it returns an error if there is a nested directory, if any
381    /// file operations fail, or if it can't parse any of the authorized_keys
382    /// files
383    fn read_all_keys(dir: &Path) -> Result<HashMap<String, AuthorizedKeySet>> {
384        let dir_contents =
385            fs::read_dir(&dir).chain_err(|| format!("failed to read from directory {:?}", dir))?;
386        let mut keys = HashMap::new();
387        for entry in dir_contents {
388            let entry =
389                entry.chain_err(|| format!("failed to read entry in directory {:?}", dir))?;
390            let path = entry.path();
391            if path.is_dir() {
392                // if it's a directory, we don't know what to do
393                return Err(format!("'{:?}' is a directory", path).into());
394            } else {
395                let name = path
396                    .file_name()
397                    .ok_or_else(|| format!("failed to get filename for '{:?}'", path))?
398                    .to_str()
399                    .ok_or_else(|| format!("failed to convert filename '{:?}' to string", path))?;
400                let from =
401                    File::open(&path).chain_err(|| format!("failed to open file {:?}", path))?;
402                let keyset = AuthorizedKeys::read_keys(from)?;
403                keys.insert(
404                    name.to_string(),
405                    AuthorizedKeySet {
406                        filename: name.to_string(),
407                        disabled: keyset.is_empty(),
408                        keys: keyset,
409                    },
410                );
411            }
412        }
413        Ok(keys)
414    }
415
416    /// read_keys reads keys from a file in the authorized_keys file format,
417    /// as described by the sshd man page. it logs a warning if it fails to
418    /// parse any of the keys.
419    pub fn read_keys<R>(r: R) -> Result<Vec<AuthorizedKeyEntry>>
420    where
421        R: Read,
422    {
423        let keybuf = BufReader::new(r);
424        // authorized_keys files are newline-separated lists of public keys
425        let mut keys = vec![];
426        for key in keybuf.lines() {
427            let key = key.chain_err(|| "failed to read public key")?;
428            // skip any empty lines and any comment lines (prefixed with '#')
429            if !key.is_empty() && !(key.trim().starts_with('#')) {
430                match PublicKey::parse(&key) {
431                    Ok(pkey) => keys.push(AuthorizedKeyEntry::Valid { key: pkey }),
432                    Err(e) => {
433                        println!("warning: failed to parse public key \"{}\": {}, omitting from authorized_keys", key, e);
434                        keys.push(AuthorizedKeyEntry::Invalid { key })
435                    }
436                };
437            }
438        }
439        Ok(keys)
440    }
441
442    /// open creates a new authorized_keys object. if there is an existing
443    /// authorized_keys directory on disk it reads all the keys from that. if
444    /// there is no directory already and we are told to create it, we add the
445    /// existing authorized keys file as an entry, if it exists.
446    ///
447    /// before open actually does any of that, it switches it's uid for the span
448    /// of the function and then switched back. it also opens a file lock on the
449    /// directory that other instances of `update-ssh-keys` will respect. the
450    /// file lock will automatically close when this structure goes out of
451    /// scope. you can make sure it is unlocked by calling `drop` yourself in
452    /// cases where you think the memory may leak (like if you are tossing boxes
453    /// around etc).
454    ///
455    /// open blocks until it can grab the file lock.
456    ///
457    /// open returns an error if any file operations fail, if it failes to parse
458    /// any of the public keys in the existing files, if it failes to change
459    /// users, if it failes to grab the lock, or if create is false but the
460    /// directory doesn't exist.
461    pub fn open(user: User, create: bool, ssh_dir: Option<PathBuf>) -> Result<Self> {
462        // switch users
463        let _guard = switch_user(&user)?;
464        // make a new file lock and lock it
465        let lock = FileLock::try_new(&lock_file(&user))?;
466        lock.lock()?;
467
468        let ssh_dir = ssh_dir.unwrap_or_else(|| default_ssh_dir(&user));
469        let akd = authorized_keys_dir(&ssh_dir);
470
471        let keys = if akd.is_dir() {
472            // read the existing keysets from the dir
473            AuthorizedKeys::read_all_keys(&akd)?
474        } else if !akd.exists() && create {
475            // read the existing keyset from the file
476            let filename = authorized_keys_file(&ssh_dir);
477            if filename.exists() {
478                let file = File::open(&filename).chain_err(|| {
479                    format!("failed to open authorized keys file: '{:?}'", filename)
480                })?;
481                let mut keys = HashMap::new();
482                keys.insert(
483                    PRESERVED_KEYS_FILE.to_string(),
484                    AuthorizedKeySet {
485                        filename: PRESERVED_KEYS_FILE.to_string(),
486                        disabled: false,
487                        keys: AuthorizedKeys::read_keys(file)?,
488                    },
489                );
490                keys
491            } else {
492                // if the authorized_keys file doesn't exist, we don't start
493                // with any keys
494                HashMap::new()
495            }
496        } else {
497            // either the akd doesn't exist and create is false, or it exists
498            // and is not a directory
499            return Err(format!("'{:?}' doesn't exist or is not a directory", akd).into());
500        };
501
502        Ok(AuthorizedKeys {
503            ssh_dir,
504            user,
505            keys,
506            lock,
507        })
508    }
509
510    /// get_keys gets the authorized keyset with the provided name
511    pub fn get_keys(&self, name: &str) -> Option<&AuthorizedKeySet> {
512        self.keys.get(name)
513    }
514
515    /// get_all_keys returns the hashmap from name to keyset containing all the
516    /// keys we know about
517    pub fn get_all_keys(&self) -> &HashMap<String, AuthorizedKeySet> {
518        &self.keys
519    }
520
521    /// add_keys adds a list of public keys with the provide name. if replace is
522    /// true, it will replace existing keys. if force is true, it will replace
523    /// disabled keys.
524    ///
525    /// if the keys vector is empty, the function doesn't create an entry. empty
526    /// entries are reserved for representing disabled keysets.
527    ///
528    /// add_keys returns an error if the key already exists and replace is
529    /// false, or if the key is disabled and force is false
530    pub fn add_keys(
531        &mut self,
532        name: &str,
533        keys: Vec<AuthorizedKeyEntry>,
534        replace: bool,
535        force: bool,
536    ) -> Result<Vec<AuthorizedKeyEntry>> {
537        // if we are passed an empty vector of keys, don't create a file
538        if keys.is_empty() {
539            return Ok(vec![]);
540        }
541
542        if let Some(keyset) = self.keys.get(name) {
543            if keyset.disabled && !force {
544                return Err(ErrorKind::KeysDisabled(name.to_string()).into());
545            } else if !replace {
546                return Err(ErrorKind::KeysExist(name.to_string()).into());
547            }
548        }
549        self.keys.insert(
550            name.to_string(),
551            AuthorizedKeySet {
552                filename: name.to_string(),
553                disabled: false,
554                keys: keys.clone(),
555            },
556        );
557        Ok(keys)
558    }
559
560    /// remove_keys removes the keyset with the given name.
561    pub fn remove_keys(&mut self, name: &str) -> Vec<AuthorizedKeyEntry> {
562        self.keys.remove(name).unwrap_or_default().keys
563    }
564
565    /// disable_keys disables keys with the given name. they can't be added
566    /// again unless force is set to true when adding the set. disable_keys will
567    /// succeed in disabling the key even if the key doesn't currently exist.
568    pub fn disable_keys(&mut self, name: &str) -> Vec<AuthorizedKeyEntry> {
569        if let Some(keyset) = self.keys.get_mut(name) {
570            let keys = keyset.keys.clone();
571            keyset.disabled = true;
572            keyset.keys = vec![];
573            return keys;
574        }
575        self.keys.insert(
576            name.to_string(),
577            AuthorizedKeySet {
578                filename: name.to_string(),
579                disabled: true,
580                keys: vec![],
581            },
582        );
583        vec![]
584    }
585}