Skip to content

Commit

Permalink
Merge pull request #7 from spencerwooo:keep-alive
Browse files Browse the repository at this point in the history
Daemon mode for `bitsrun`
  • Loading branch information
spencerwooo committed Dec 4, 2023
2 parents 8d1d22d + f456f97 commit a5956c7
Show file tree
Hide file tree
Showing 9 changed files with 375 additions and 198 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ tabled = { version = "0.14", features = ["color"] }
humansize = "2.1"
chrono-humanize = "0.2"
chrono = "0.4"
log = "0.4.20"
pretty_env_logger = "0.5.0"

[profile.release]
strip = "symbols"
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ bitsrun: <ip> (<username>) is online

```console
$ bitsrun --help
A headless login and logout CLI app for 10.0.0.55 at BIT
A headless login and logout CLI for 10.0.0.55 at BIT

Usage: bitsrun [OPTIONS] [COMMAND]

Expand All @@ -67,6 +67,7 @@ Commands:
logout Logout from the campus network
status Check device login status
config-paths List all possible config file paths
keep-alive Poll the server with login requests to keep the session alive
help Print this message or the help of the given subcommand(s)

Options:
Expand All @@ -86,11 +87,13 @@ To save your credentials and configurations, create config file `bit-user.json`
{
"username": "<username>",
"password": "<password>",
"dm": true
"dm": true,
"poll_interval": 3600
}
```

**`dm` is for specifying whether the current device is a dumb terminal, and requires logging out through the alternative endpoint. Set to `true` (no quotes!) if the device you are working with is a dumb terminal.**
- **`dm` is for specifying whether the current device is a dumb terminal, and requires logging out through the alternative endpoint. Set to `true` (no quotes!) if the device you are working with is a dumb terminal.**
- `poll_interval` is an optional field for specifying the interval (in seconds) of polling login requests. Default is `3600` seconds (1 hour). Used by `bitsrun keep-alive` only.

Available config file paths can be listed with:

Expand Down
10 changes: 10 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub enum Commands {

/// List all possible config file paths
ConfigPaths,

/// Poll the server with login requests to keep the session alive
KeepAlive(DaemonArgs),
}

#[derive(Args)]
Expand Down Expand Up @@ -63,3 +66,10 @@ pub struct ClientArgs {
#[arg(short, long)]
pub force: bool,
}

#[derive(Args)]
pub struct DaemonArgs {
/// Path to the config file
#[arg(short, long)]
pub config: Option<String>,
}
2 changes: 1 addition & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ impl SrunClient {
}

if raw_text.len() < 8 {
bail!("logout response too short: `{}`", raw_text)
bail!("challenge response too short: `{}`", raw_text)
}
let raw_json = &raw_text[6..raw_text.len() - 1];
let parsed_json = serde_json::from_str::<SrunChallenge>(raw_json).with_context(|| {
Expand Down
119 changes: 119 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use std::env;
use std::fs;

use anyhow::anyhow;
use anyhow::Error;
use anyhow::Result;
use owo_colors::OwoColorize;
use owo_colors::Stream::Stdout;

/// Enumerate possible paths to user config file (platform specific)
///
/// On Windows:
/// * `~\AppData\Roaming\bitsrun\bit-user.json`
///
/// On Linux:
/// * `$XDG_CONFIG_HOME/bitsrun/bit-user.json`
/// * `~/.config/bitsrun/bit-user.json`
/// * `~/.config/bit-user.json`
///
/// On macOS:
/// * `$HOME/Library/Preferences/bitsrun/bit-user.json`
/// * `$HOME/.config/bit-user.json`
/// * `$HOME/.config/bitsrun/bit-user.json`
///
/// Additionally, `bitsrun` will search for config file in the current working directory.
pub fn enumerate_config_paths() -> Vec<String> {
let mut paths = Vec::new();

// Windows
if env::consts::OS == "windows" {
if let Some(appdata) = env::var_os("APPDATA") {
paths.push(format!(
"{}\\bitsrun\\bit-user.json",
appdata.to_str().unwrap()
));
}
}

// Linux (and macOS)
if let Some(home) = env::var_os("XDG_CONFIG_HOME").or_else(|| env::var_os("HOME")) {
paths.push(format!("{}/.config/bit-user.json", home.to_str().unwrap()));
paths.push(format!(
"{}/.config/bitsrun/bit-user.json",
home.to_str().unwrap()
));
}

// macOS
if env::consts::OS == "macos" {
if let Some(home) = env::var_os("HOME") {
paths.push(format!(
"{}/Library/Preferences/bitsrun/bit-user.json",
home.to_str().unwrap()
));
}
}

// current working directory
paths.push("bit-user.json".into());
paths
}

/// Config file validation
pub fn validate_config_file(config_path: &Option<String>) -> Result<String, Error> {
let mut validated_config_path = String::new();
match &config_path {
Some(path) => validated_config_path = path.to_owned(),
None => {
for path in enumerate_config_paths() {
if fs::metadata(&path).is_ok() {
validated_config_path = path;
break;
}
}
}
}
let meta = fs::metadata(&validated_config_path)?;
if !meta.is_file() {
return Err(anyhow!(
"`{}` is not a file",
&validated_config_path.if_supports_color(Stdout, |t| t.underline())
));
}
// file should only be read/writeable by the owner alone, i.e., 0o600
// note: this check is only performed on unix systems
#[cfg(unix)]
fn check_permissions(config: &String, meta: &std::fs::Metadata) -> Result<(), anyhow::Error> {
use std::os::unix::fs::MetadataExt;
if meta.mode() & 0o777 != 0o600 {
return Err(anyhow!(
"`{}` has too open permissions {}, aborting!\n\
{}: set permissions to {} with `chmod 600 {}`",
&config.if_supports_color(Stdout, |t| t.underline()),
(meta.mode() & 0o777)
.to_string()
.if_supports_color(Stdout, |t| t.on_red()),
"tip".if_supports_color(Stdout, |t| t.green()),
"600".if_supports_color(Stdout, |t| t.on_cyan()),
&config
));
}
Ok(())
}
#[cfg(windows)]
#[allow(unused)]
fn check_permissions(_config: &str, _meta: &std::fs::Metadata) -> Result<(), anyhow::Error> {
// Windows doesn't support Unix-style permissions, so we'll just return Ok here.
Ok(())
}
check_permissions(&validated_config_path, &meta)?;
if validated_config_path.is_empty() {
return Err(anyhow!(
"file `{}` not found, available paths can be found with `{}`",
"bit-user.json".if_supports_color(Stdout, |t| t.underline()),
"bitsrun config-paths".if_supports_color(Stdout, |t| t.cyan())
));
}
Ok(validated_config_path)
}
112 changes: 112 additions & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use crate::client::SrunClient;
use crate::config;

use std::fs;

use anyhow::Context;
use anyhow::Result;
use log::info;
use log::warn;
use owo_colors::OwoColorize;
use owo_colors::Stream::Stdout;

use reqwest::Client;
use serde::Deserialize;

use tokio::signal::ctrl_c;
use tokio::time::Duration;

#[derive(Debug, Deserialize)]
pub struct SrunDaemon {
username: String,
password: String,
dm: bool,
// polls every 1 hour by default
poll_interval: Option<u64>,
}

impl SrunDaemon {
pub fn new(config_path: Option<String>) -> Result<SrunDaemon> {
let finalized_cfg = config::validate_config_file(&config_path)?;

// in daemon mode, bitsrun must be able to read all required fields from the config file,
// including `username`, `password`, and `dm`.
let daemon_cfg_str = fs::read_to_string(&finalized_cfg).with_context(|| {
format!(
"failed to read config file `{}`",
&finalized_cfg.if_supports_color(Stdout, |t| t.underline())
)
})?;
let daemon_cfg =
serde_json::from_str::<SrunDaemon>(&daemon_cfg_str).with_context(|| {
format!(
"failed to parse config file `{}`",
&finalized_cfg.if_supports_color(Stdout, |t| t.underline())
)
})?;

Ok(daemon_cfg)
}

pub async fn start(&self, http_client: Client) -> Result<()> {
// set logger to INFO level by default
pretty_env_logger::formatted_builder()
.filter_level(log::LevelFilter::Info)
.init();

// set default polling intervals every 1 hour
let poll_interval = self.poll_interval.unwrap_or(3600);

// warn if polling interval is too short
if poll_interval < 60 * 10 {
warn!("polling interval is too short, please set it to at least 10 minutes (600s)");
}

// start daemon
let mut srun_ticker = tokio::time::interval(Duration::from_secs(poll_interval));
let srun = SrunClient::new(
self.username.clone(),
self.password.clone(),
Some(http_client),
None,
Some(self.dm),
)
.await?;

info!(
"starting daemon ({}) with polling interval={}s",
self.username, poll_interval,
);

loop {
let tick = srun_ticker.tick();
let login = srun.login(true, false);

tokio::select! {
_ = tick => {
match login.await {
Ok(resp) => {
match resp.error.as_str() {
"ok" => {
info!("{} ({}): login success, {}", resp.client_ip, self.username, resp.suc_msg.unwrap_or_default());
}
_ => {
warn!("{} ({}): login failed, {}", resp.client_ip, self.username, resp.error);
}
}
}
Err(e) => {
warn!("{}: login failed: {}", self.username, e);
}
}
}
_ = ctrl_c() => {
info!("{}: gracefully exiting", self.username);
break;
}
}
}

Ok(())
}
}

0 comments on commit a5956c7

Please sign in to comment.