Adds headless login support for the cli app.
This commit is contained in:
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -2410,6 +2410,7 @@ dependencies = [
|
|||||||
"nextcloud_client",
|
"nextcloud_client",
|
||||||
"open",
|
"open",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"rpassword",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3158,6 +3159,27 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|||||||
14
Tasks.md
14
Tasks.md
@@ -78,7 +78,6 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore |
|
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore |
|
||||||
| [NUT-019](#nut-019) | Implement Homebrew/Winget/Chocolatey Manifests | Triage | Chore |
|
| [NUT-019](#nut-019) | Implement Homebrew/Winget/Chocolatey Manifests | Triage | Chore |
|
||||||
| [NUT-020](#nut-020) | Write Documentation + Examples | Triage | Chore |
|
| [NUT-020](#nut-020) | Write Documentation + Examples | Triage | Chore |
|
||||||
| [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Triage | Feature |
|
|
||||||
| [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation |
|
| [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation |
|
||||||
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
|
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
|
||||||
| [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature |
|
| [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature |
|
||||||
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature |
|
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature |
|
||||||
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature |
|
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature |
|
||||||
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature |
|
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature |
|
||||||
|
| [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -497,20 +497,20 @@ Write documentation for installation, usage, examples, and API reference.
|
|||||||
- NUT-018
|
- NUT-018
|
||||||
|
|
||||||
|
|
||||||
<a id="nut-021" class="task" data-status="triage" data-task-type="feature"></a>
|
<a id="nut-021" class="task" data-status="done" data-task-type="feature"></a>
|
||||||
### Support Headless & SSH Remote Authentication Modes
|
### Support Headless & SSH Remote Authentication Modes
|
||||||
**ID:** NUT-021
|
**ID:** NUT-021
|
||||||
**Status:** Triage
|
**Status:** Fixed
|
||||||
**Type:** Feature
|
**Type:** Feature
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Ensure smooth authentication experiences when running the CLI over SSH or in headless environments where a local graphical browser cannot be launched automatically.
|
Ensure smooth authentication experiences when running the CLI over SSH or in headless environments where a local graphical browser cannot be launched automatically.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- [ ] Terminal URL fallback: print clickable Login Flow v2 URL in terminal when browser fails to launch
|
- [x] Terminal URL fallback: print clickable Login Flow v2 URL in terminal when browser fails to launch
|
||||||
- [ ] Add `--no-browser` flag to print URL and wait for authorization without attempting to open desktop browser
|
- [x] Add `--no-browser` flag to print URL and wait for authorization without attempting to open desktop browser
|
||||||
- [ ] Add interactive manual terminal prompt (`--manual`) for username and app password input
|
- [x] Add interactive manual terminal prompt (`--manual`) for username and app password input
|
||||||
- [ ] Add non-interactive flag inputs (`--username`, `--app-password`) for automated provisioning and CI/CD
|
- [x] Add non-interactive flag inputs (`--username`, `--app-password`) for automated provisioning and CI/CD
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
- NUT-006
|
- NUT-006
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ url = "2.5"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
indicatif = "0.17"
|
indicatif = "0.17"
|
||||||
|
rpassword = "7.3"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use clap::{Args, Parser, Subcommand};
|
|||||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, IsTerminal, Read};
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -27,7 +27,7 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Log in to a Nextcloud server using browser authorization (Login Flow v2)
|
/// Log in to a Nextcloud server using browser authorization (Login Flow v2) or credentials
|
||||||
Login(LoginArgs),
|
Login(LoginArgs),
|
||||||
|
|
||||||
/// Upload a file or stream to Nextcloud
|
/// Upload a file or stream to Nextcloud
|
||||||
@@ -53,6 +53,22 @@ struct LoginArgs {
|
|||||||
/// Set this account as the default active account
|
/// Set this account as the default active account
|
||||||
#[arg(short, long, default_value_t = true)]
|
#[arg(short, long, default_value_t = true)]
|
||||||
default: bool,
|
default: bool,
|
||||||
|
|
||||||
|
/// Do not automatically open a desktop browser; print authorization URL in terminal
|
||||||
|
#[arg(long)]
|
||||||
|
no_browser: bool,
|
||||||
|
|
||||||
|
/// Prompt interactively for username and app password in terminal
|
||||||
|
#[arg(short, long, conflicts_with_all = ["username", "app_password"])]
|
||||||
|
manual: bool,
|
||||||
|
|
||||||
|
/// Username for non-interactive / automated login
|
||||||
|
#[arg(short = 'u', long, requires = "app_password")]
|
||||||
|
username: Option<String>,
|
||||||
|
|
||||||
|
/// App password or token for non-interactive / automated login
|
||||||
|
#[arg(short = 'p', long, requires = "username")]
|
||||||
|
app_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug, Clone, Default)]
|
#[derive(Args, Debug, Clone, Default)]
|
||||||
@@ -199,24 +215,63 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle interactive browser login flow.
|
/// Handle interactive or non-interactive login flows.
|
||||||
async fn handle_login(args: LoginArgs) -> Result<()> {
|
async fn handle_login(args: LoginArgs) -> Result<()> {
|
||||||
let server_url = ClientConfig::normalize_url(&args.server_url)?;
|
let server_url = ClientConfig::normalize_url(&args.server_url)?;
|
||||||
let http = reqwest::Client::new();
|
let server_url_str = server_url.as_str();
|
||||||
|
|
||||||
println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url);
|
// 1. Non-interactive CLI flag input (--username & --app-password)
|
||||||
|
if let (Some(username), Some(password)) = (args.username.as_deref(), args.app_password.as_deref()) {
|
||||||
|
println!("\x1b[1;34m==>\x1b[0m Validating credentials for '{}' on {}...", username, server_url_str);
|
||||||
|
return save_and_verify_account(server_url_str, username, password, args.default, args.label).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Interactive manual terminal prompt (--manual)
|
||||||
|
if args.manual {
|
||||||
|
println!("\x1b[1;34m==>\x1b[0m Manual Nextcloud Login for {}", server_url_str);
|
||||||
|
print!(" Username: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut username = String::new();
|
||||||
|
io::stdin().read_line(&mut username)?;
|
||||||
|
let username = username.trim();
|
||||||
|
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(nextcloud_client::NextcloudError::Other("Username cannot be empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
print!(" App Password / Token: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let password = rpassword::read_password()
|
||||||
|
.map_err(|e| nextcloud_client::NextcloudError::Other(format!("Failed to read password: {e}")))?;
|
||||||
|
let password = password.trim();
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err(nextcloud_client::NextcloudError::Other("Password cannot be empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\x1b[1;34m==>\x1b[0m Verifying connection...");
|
||||||
|
return save_and_verify_account(server_url_str, username, password, args.default, args.label).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Browser-based Login Flow v2 (default)
|
||||||
|
let http = reqwest::Client::new();
|
||||||
|
println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url_str);
|
||||||
let flow = initiate_login_flow(&http, &server_url).await?;
|
let flow = initiate_login_flow(&http, &server_url).await?;
|
||||||
|
|
||||||
println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:");
|
println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:");
|
||||||
println!(" \x1b[1;36m{}\x1b[0m\n", flow.login);
|
println!(" \x1b[1;36m{}\x1b[0m\n", flow.login);
|
||||||
|
|
||||||
|
if !args.no_browser {
|
||||||
// Attempt to open the default desktop browser
|
// Attempt to open the default desktop browser
|
||||||
if open::that(&flow.login).is_err() {
|
if open::that(&flow.login).is_err() {
|
||||||
println!(" (Could not automatically launch browser. Please copy and open the link above.)");
|
println!(" (Could not automatically launch browser. Please copy and open the link above.)");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
println!(" (Headless mode: copy and paste the URL above into any browser)");
|
||||||
|
}
|
||||||
|
|
||||||
print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization...");
|
print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization...");
|
||||||
io::Write::flush(&mut io::stdout())?;
|
io::stdout().flush()?;
|
||||||
|
|
||||||
let poll_interval = Duration::from_secs(2);
|
let poll_interval = Duration::from_secs(2);
|
||||||
let timeout = Duration::from_secs(300); // 5 minute timeout
|
let timeout = Duration::from_secs(300); // 5 minute timeout
|
||||||
@@ -261,6 +316,37 @@ async fn handle_login(args: LoginArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn save_and_verify_account(
|
||||||
|
server_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
is_default: bool,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let config = ClientConfig::with_credentials(server_url, username, password)?;
|
||||||
|
let client = NextcloudClient::new(config)?;
|
||||||
|
|
||||||
|
// Verify authentication and connectivity
|
||||||
|
client.test_connection().await?;
|
||||||
|
|
||||||
|
let mut account = CredentialStore::save_account(server_url, username, password, is_default)?;
|
||||||
|
|
||||||
|
if let Some(lbl) = label {
|
||||||
|
account.label = Some(lbl);
|
||||||
|
let mut accounts = CredentialStore::list_accounts()?;
|
||||||
|
if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
|
||||||
|
a.label = account.label.clone();
|
||||||
|
}
|
||||||
|
CredentialStore::save_accounts(&accounts)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Successfully authenticated! Account '\x1b[1m{}\x1b[0m' saved securely.", account.id);
|
||||||
|
if account.is_default {
|
||||||
|
println!(" Set as default active account.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle file and stdin uploads.
|
/// Handle file and stdin uploads.
|
||||||
async fn handle_upload(args: UploadArgs) -> Result<()> {
|
async fn handle_upload(args: UploadArgs) -> Result<()> {
|
||||||
if (args.format.url_only || args.format.direct_url_only) && !args.share {
|
if (args.format.url_only || args.format.direct_url_only) && !args.share {
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ impl NextcloudClient {
|
|||||||
|
|
||||||
// Set Basic Auth header if credentials are provided
|
// Set Basic Auth header if credentials are provided
|
||||||
if let Some(ref creds) = config.credentials {
|
if let Some(ref creds) = config.credentials {
|
||||||
use reqwest::header::HeaderValue;
|
|
||||||
let raw_auth = format!("{}:{}", creds.username, creds.app_password);
|
let raw_auth = format!("{}:{}", creds.username, creds.app_password);
|
||||||
let encoded = base64_encode(raw_auth.as_bytes());
|
let encoded = base64_encode(raw_auth.as_bytes());
|
||||||
let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}"))
|
let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}"))
|
||||||
@@ -126,6 +125,32 @@ impl NextcloudClient {
|
|||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test server connectivity and verify user credentials via WebDAV PROPFIND.
|
||||||
|
pub async fn test_connection(&self) -> Result<ServerStatus> {
|
||||||
|
let status = self.check_status().await?;
|
||||||
|
|
||||||
|
if let Some(username) = self.username() {
|
||||||
|
let root_dav = self.webdav_url("")?;
|
||||||
|
let res = self
|
||||||
|
.http
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), root_dav)
|
||||||
|
.header("Depth", "0")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if res.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err(NextcloudError::AuthenticationFailed {
|
||||||
|
username: username.to_string(),
|
||||||
|
message: "Invalid credentials or unauthorized access".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.error_for_status()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
/// High-level orchestration method that uploads a file from disk and optionally generates a public share link.
|
/// High-level orchestration method that uploads a file from disk and optionally generates a public share link.
|
||||||
pub async fn upload_and_share<P: AsRef<Path>>(
|
pub async fn upload_and_share<P: AsRef<Path>>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ impl ClientConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new `ClientConfig` with username and app password credentials.
|
||||||
|
pub fn with_credentials(
|
||||||
|
server_url_str: &str,
|
||||||
|
username: impl Into<String>,
|
||||||
|
app_password: impl Into<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::new(
|
||||||
|
server_url_str,
|
||||||
|
Some(AccountCredentials::new(username, app_password)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set a custom request timeout.
|
/// Set a custom request timeout.
|
||||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
self.timeout = timeout;
|
self.timeout = timeout;
|
||||||
|
|||||||
Reference in New Issue
Block a user