diff --git a/CHANGELOG.md b/CHANGELOG.md --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Added + +- Add user detail popups to admin interface. +- Add functionality to remove users via the admin interface. + +### Changed + +- **BREAKING**: Bump MSRV to 1.82.0. + ## 0.2.1 (2024-12-22) ### Added diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "sandhole" version = "0.2.1" edition = "2021" -rust-version = "1.81.0" +rust-version = "1.82.0" description = "Expose HTTP/SSH/TCP services through SSH port forwarding." repository = "https://github.com/EpicEric/sandhole" homepage = "https://epiceric.github.io/sandhole/" diff --git a/Dockerfile b/Dockerfile --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,25 @@ -FROM rust:1.83.0-alpine3.20 AS builder -RUN apk add --no-cache musl-dev libressl-dev perl build-base +FROM --platform=$BUILDPLATFORM rust:1.83.0-alpine3.20 AS builder +ENV PKGCONFIG_SYSROOTDIR=/ +RUN apk add --no-cache musl-dev libressl-dev perl build-base zig +RUN cargo install --locked cargo-zigbuild +RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl WORKDIR /app COPY Cargo.toml Cargo.lock . RUN mkdir src \ && echo "fn main() {}" > src/main.rs \ && cargo fetch \ - && cargo build --release \ + && cargo zigbuild --release --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \ && rm src/main.rs COPY src ./src COPY README.md . -RUN cargo build --release +RUN cargo zigbuild --release --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl -FROM alpine:3.20 AS runner -COPY --from=builder /app/target/release/sandhole /usr/local/bin/sandhole -ENTRYPOINT [ "sandhole" ] +FROM --platform=$BUILDPLATFORM scratch AS binary +COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/sandhole /sandhole-linux-amd64 +COPY --from=builder /app/target/aarch64-unknown-linux-musl/release/sandhole /sandhole-linux-arm64 + +FROM scratch AS runner +ARG TARGETOS +ARG TARGETARCH +COPY --from=binary /sandhole-${TARGETOS}-${TARGETARCH} /sandhole +ENTRYPOINT [ "/sandhole" ] diff --git a/Dockerfile.ci b/Dockerfile.ci deleted file mode 100644 --- a/Dockerfile.ci +++ /dev/null @@ -1,25 +0,0 @@ -FROM --platform=$BUILDPLATFORM rust:1.83.0-alpine3.20 AS builder -ENV PKGCONFIG_SYSROOTDIR=/ -RUN apk add --no-cache musl-dev libressl-dev perl build-base zig -RUN cargo install --locked cargo-zigbuild -RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl -WORKDIR /app -COPY Cargo.toml Cargo.lock . -RUN mkdir src \ - && echo "fn main() {}" > src/main.rs \ - && cargo fetch \ - && cargo zigbuild --release --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \ - && rm src/main.rs -COPY src ./src -COPY README.md . -RUN cargo zigbuild --release --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl - -FROM --platform=$BUILDPLATFORM scratch AS binary -COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/sandhole /sandhole-linux-amd64 -COPY --from=builder /app/target/aarch64-unknown-linux-musl/release/sandhole /sandhole-linux-arm64 - -FROM scratch AS runner -ARG TARGETOS -ARG TARGETARCH -COPY --from=binary /sandhole-${TARGETOS}-${TARGETARCH} /sandhole -ENTRYPOINT [ "/sandhole" ] diff --git a/src/admin.rs b/src/admin.rs --- a/src/admin.rs +++ b/src/admin.rs @@ -10,23 +10,29 @@ use itertools::Itertools; use ratatui::{ buffer::Buffer, - layout::{Constraint, Layout, Margin, Rect}, + layout::{Constraint, Flex, Layout, Margin, Rect}, prelude::CrosstermBackend, style::{Color, Modifier, Style, Stylize}, symbols::border, text::{Line, Text}, widgets::{ - Block, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, - Table, TableState, Tabs, Widget, + Block, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, + StatefulWidget, Table, TableState, Tabs, Widget, Wrap, }, Terminal, TerminalOptions, Viewport, }; +use ssh_key::Fingerprint; use tokio::{ sync::{mpsc::UnboundedSender, watch}, time::sleep, }; -use crate::{droppable_handle::DroppableHandle, tcp_alias::TcpAlias, SandholeServer, SystemData}; +use crate::{ + droppable_handle::DroppableHandle, + fingerprints::{AuthenticationType, KeyData}, + tcp_alias::TcpAlias, + SandholeServer, SystemData, +}; struct BufferedSender { tx: UnboundedSender>, @@ -85,15 +91,23 @@ } } +enum AdminPrompt { + Infobox(String), + SelectUser(Vec, TableState), + UserDetails(String, Option<(Fingerprint, KeyData)>), + RemoveUser(String, Option<(Fingerprint, KeyData)>), +} + struct AdminState { server: Arc, is_pty: bool, tab: Tab, table_state: TableState, vertical_scroll: ScrollbarState, + prompt: Option, } -fn to_socket_addr_string(addr: SocketAddr) -> String { +fn to_socket_addr_string(addr: &SocketAddr) -> String { let ip = addr.ip().to_canonical(); if ip.is_ipv4() { format!("{}:{}", ip, addr.port()) @@ -111,6 +125,8 @@ let instructions = Line::from(vec![ " Change tab".into(), " ".blue().bold(), + " Details".into(), + " ".blue().bold(), " Quit".into(), " ".blue().bold(), ]); @@ -133,6 +149,7 @@ .border_set(border::PROPORTIONAL_TALL) .render(inner_area, buf); self.render_tab(inner_area.inner(Margin::new(2, 1)), buf); + self.render_prompt(area, buf); } else { let text = Text::from(vec![ Line::from( @@ -146,6 +163,91 @@ } } + fn render_prompt(&mut self, area: Rect, buf: &mut Buffer) { + if let Some(ref mut prompt) = self.prompt { + let vertical = Layout::vertical([Constraint::Percentage(50)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Length(60)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + let block = Block::bordered().black().on_white(); + let inner = block.inner(area); + Widget::render(Clear, area, buf); + match prompt { + AdminPrompt::Infobox(text) => { + let block = block.title_bottom(Line::raw(" Close ").centered()); + let text = Paragraph::new(text.as_str()) + .centered() + .wrap(Wrap { trim: true }); + Widget::render(block, area, buf); + Widget::render(text, inner, buf); + } + AdminPrompt::SelectUser(users, table_state) => { + let block = block + .title(Line::raw("Connected users")) + .title_bottom(Line::raw(" Details ").centered()); + let users = Table::new( + users.iter().map(|user| Row::new([user.as_str()])), + [Constraint::Fill(1)], + ) + .row_highlight_style(Style::new().black().on_blue()); + Widget::render(block, area, buf); + StatefulWidget::render(users, inner, buf, table_state); + } + AdminPrompt::UserDetails(user, data) => { + let block = block.title(Line::raw("User details")).title_bottom( + Line::raw( + if data + .as_ref() + .is_none_or(|(_, data)| data.auth == AuthenticationType::User) + { + " Close Remove " + } else { + " Close " + }, + ) + .centered(), + ); + let (user_type, comment) = data + .as_ref() + .map(|(_, data)| { + ( + format!("Type: {}", data.auth), + format!("Key comment: {}", data.comment), + ) + }) + .unwrap_or(("Type: User".into(), "(authenticated with password)".into())); + let text = Paragraph::new(vec![ + Line::from(user.as_str()).centered(), + Line::from(user_type).centered(), + Line::from(comment).centered(), + ]) + .wrap(Wrap { trim: true }); + Widget::render(block, area, buf); + Widget::render(text, inner, buf); + } + AdminPrompt::RemoveUser(user, data) => { + let block = block + .title(Line::raw("Remove user?")) + .title_bottom(Line::raw(" Cancel Confirm ").centered()); + let text = Paragraph::new(vec![ + Line::from("Are you sure you want to remove the following user?") + .centered(), + Line::from(user.as_str()).bold().centered(), + Line::from(if data.is_some() { + "They will lose all forwarding permissions!" + } else { + "They might still be able to reconnect via the login API!" + }) + .centered(), + ]) + .wrap(Wrap { trim: true }); + Widget::render(block, area, buf); + Widget::render(text, inner, buf); + } + }; + } + } + // Render the selected tab's contents fn render_tab(&mut self, area: Rect, buf: &mut Buffer) { let color = self.tab.color(); @@ -153,17 +255,20 @@ Tab::Http => { let data = self.server.http_data.read().unwrap().clone(); self.vertical_scroll = self.vertical_scroll.content_length(data.len()); - let rows = data.into_iter().map(|(host, (connections, req_per_min))| { - let len = connections.len() as u16; - let (peers, users): (Vec<_>, Vec<_>) = connections.into_iter().unzip(); - Row::new(vec![ - host, - req_per_min.to_string(), - users.iter().join("\n"), - peers.into_iter().map(to_socket_addr_string).join("\n"), - ]) - .height(len) - }); + let rows: Vec> = data + .iter() + .map(|(host, (connections, req_per_min))| { + let len = connections.len() as u16; + let (peers, users): (Vec<_>, Vec<_>) = connections.iter().unzip(); + Row::new(vec![ + host.clone(), + req_per_min.to_string(), + users.iter().join("\n"), + peers.iter().map(to_socket_addr_string).join("\n"), + ]) + .height(len) + }) + .collect(); let constraints = [ Constraint::Min(25), Constraint::Length(7), @@ -183,16 +288,19 @@ Tab::Ssh => { let data = self.server.ssh_data.read().unwrap().clone(); self.vertical_scroll = self.vertical_scroll.content_length(data.len()); - let rows = data.into_iter().map(|(host, connections)| { - let len = connections.len() as u16; - let (peers, users): (Vec<_>, Vec<_>) = connections.into_iter().unzip(); - Row::new(vec![ - host, - users.iter().join("\n"), - peers.into_iter().map(to_socket_addr_string).join("\n"), - ]) - .height(len) - }); + let rows: Vec> = data + .iter() + .map(|(host, connections)| { + let len = connections.len() as u16; + let (peers, users): (Vec<_>, Vec<_>) = connections.iter().unzip(); + Row::new(vec![ + host.clone(), + users.iter().join("\n"), + peers.iter().map(to_socket_addr_string).join("\n"), + ]) + .height(len) + }) + .collect(); let constraints = [ Constraint::Min(25), Constraint::Length(50), @@ -211,19 +319,20 @@ Tab::Tcp => { let data = self.server.tcp_data.read().unwrap().clone(); self.vertical_scroll = self.vertical_scroll.content_length(data.len()); - let rows = data - .into_iter() + let rows: Vec> = data + .iter() .map(|(TcpAlias(alias, port), connections)| { let len = connections.len() as u16; - let (peers, users): (Vec<_>, Vec<_>) = connections.into_iter().unzip(); + let (peers, users): (Vec<_>, Vec<_>) = connections.iter().unzip(); Row::new(vec![ - alias, + alias.clone(), port.to_string(), users.iter().join("\n"), - peers.into_iter().map(to_socket_addr_string).join("\n"), + peers.iter().map(to_socket_addr_string).join("\n"), ]) .height(len) - }); + }) + .collect(); let constraints = [ Constraint::Min(25), Constraint::Length(5), @@ -326,6 +435,7 @@ is_pty: false, table_state: Default::default(), vertical_scroll: Default::default(), + prompt: None, }, })); let interface_clone = Arc::clone(&interface); @@ -370,7 +480,6 @@ let mut interface = self.interface.lock().unwrap(); interface.terminal.resize(rect)?; interface.state.is_pty = true; - drop(interface); } let _ = self.change_notifier.send(()); Ok(()) @@ -393,7 +502,6 @@ } interface.state.table_state = Default::default(); interface.state.vertical_scroll = Default::default(); - drop(interface); } let _ = self.change_notifier.send(()); } @@ -415,47 +523,255 @@ } interface.state.table_state = Default::default(); interface.state.vertical_scroll = Default::default(); - drop(interface); } let _ = self.change_notifier.send(()); } // Move down in the selected tab's table pub(crate) fn move_down(&mut self) { - { + let notify = { let mut interface = self.interface.lock().unwrap(); - interface.state.table_state.select_next(); - interface.state.vertical_scroll = interface - .state - .vertical_scroll - .position(interface.state.table_state.selected().unwrap()); - drop(interface); + match interface.state.prompt { + Some(AdminPrompt::SelectUser(_, ref mut state)) => { + state.select_next(); + true + } + Some(_) => false, + None => { + interface.state.table_state.select_next(); + interface.state.vertical_scroll = interface + .state + .vertical_scroll + .position(interface.state.table_state.selected().unwrap()); + true + } + } + }; + if notify { + let _ = self.change_notifier.send(()); } - let _ = self.change_notifier.send(()); } // Move up in the selected tab's table pub(crate) fn move_up(&mut self) { - { + let notify = { let mut interface = self.interface.lock().unwrap(); - interface.state.table_state.select_previous(); - interface.state.vertical_scroll = interface - .state - .vertical_scroll - .position(interface.state.table_state.selected().unwrap()); - drop(interface); + match interface.state.prompt { + Some(AdminPrompt::SelectUser(_, ref mut state)) => { + state.select_previous(); + true + } + Some(_) => false, + None => { + interface.state.table_state.select_previous(); + interface.state.vertical_scroll = interface + .state + .vertical_scroll + .position(interface.state.table_state.selected().unwrap()); + true + } + } + }; + if notify { + let _ = self.change_notifier.send(()); } - let _ = self.change_notifier.send(()); } - // Cancel current selection in the table + // Cancel current selection in the table or prompt pub(crate) fn cancel(&mut self) { - { + let notify = { let mut interface = self.interface.lock().unwrap(); - interface.state.table_state = Default::default(); - interface.state.vertical_scroll = Default::default(); - drop(interface); + match interface.state.prompt { + Some(_) => { + interface.state.prompt = None; + true + } + None => { + if interface.state.table_state.selected().is_some() { + interface.state.table_state = Default::default(); + interface.state.vertical_scroll = Default::default(); + true + } else { + false + } + } + } + }; + if notify { + let _ = self.change_notifier.send(()); } - let _ = self.change_notifier.send(()); + } + + // Confirm current selection, which might be an entry in a table or a prompt + pub(crate) fn enter(&mut self) { + let notify = { + let mut interface = self.interface.lock().unwrap(); + match interface.state.prompt.take() { + Some(AdminPrompt::Infobox(_)) => { + interface.state.prompt = None; + true + } + Some(AdminPrompt::RemoveUser(user, data)) => { + let mut text = "User removed successfully!".into(); + if let Some(fingerprint) = data.map(|(fingerprint, _)| fingerprint) { + if let Err(err) = interface + .state + .server + .fingerprints_validator + .remove_user_key(&fingerprint) + { + text = format!("Error: {}", err); + } + if let Some(sessions) = interface + .state + .server + .sessions_publickey + .lock() + .unwrap() + .remove(&fingerprint) + { + sessions.values().for_each(|tx| { + let _ = tx.send(()); + }); + } + } else if let Some(sessions) = interface + .state + .server + .sessions_password + .lock() + .unwrap() + .remove(&user) + { + sessions.values().for_each(|tx| { + let _ = tx.send(()); + }); + } + interface.state.prompt = Some(AdminPrompt::Infobox(text)); + true + } + Some(AdminPrompt::SelectUser(users, table_state)) => { + if let Some(user) = table_state + .selected() + .and_then(|selected| users.get(selected)) + { + let user = user.clone(); + let fingerprint = user.parse().ok(); + let key_data = fingerprint.as_ref().and_then(|fingerprint| { + interface + .state + .server + .fingerprints_validator + .get_data_for_fingerprint(fingerprint) + }); + interface.state.prompt = + Some(AdminPrompt::UserDetails(user, fingerprint.zip(key_data))); + true + } else { + interface.state.prompt = Some(AdminPrompt::SelectUser(users, table_state)); + false + } + } + Some(prompt) => { + interface.state.prompt = Some(prompt); + false + } + None => { + if let Some(row) = interface.state.table_state.selected() { + let users: Option> = match interface.state.tab { + Tab::Http => interface + .state + .server + .http_data + .read() + .unwrap() + .values() + .nth(row) + .map(|value| value.0.values().cloned().collect()), + Tab::Ssh => interface + .state + .server + .ssh_data + .read() + .unwrap() + .values() + .nth(row) + .map(|value| value.values().cloned().collect()), + Tab::Tcp => interface + .state + .server + .tcp_data + .read() + .unwrap() + .values() + .nth(row) + .map(|value| value.values().cloned().collect()), + }; + match users { + None => { + interface.state.prompt = + Some(AdminPrompt::Infobox("No users found!".into())); + } + Some(users) if users.is_empty() => { + interface.state.prompt = + Some(AdminPrompt::Infobox("No users found!".into())); + } + Some(mut users) if users.len() == 1 => { + let user = users.remove(0); + let fingerprint = user.parse().ok(); + let key_data = fingerprint.as_ref().and_then(|fingerprint| { + interface + .state + .server + .fingerprints_validator + .get_data_for_fingerprint(fingerprint) + }); + interface.state.prompt = + Some(AdminPrompt::UserDetails(user, fingerprint.zip(key_data))); + } + Some(users) => { + interface.state.prompt = + Some(AdminPrompt::SelectUser(users, TableState::default())); + } + } + true + } else { + interface.state.prompt = + Some(AdminPrompt::Infobox("No row selected!".into())); + true + } + } + } + }; + if notify { + let _ = self.change_notifier.send(()); + } + } + + pub(crate) fn delete(&mut self) { + let notify = { + let mut interface = self.interface.lock().unwrap(); + match interface.state.prompt.take() { + Some(AdminPrompt::UserDetails(user, data)) => { + if data + .as_ref() + .is_none_or(|(_, data)| data.auth == AuthenticationType::User) + { + interface.state.prompt = Some(AdminPrompt::RemoveUser(user, data)); + true + } else { + interface.state.prompt = Some(AdminPrompt::UserDetails(user, data)); + false + } + } + Some(prompt) => { + interface.state.prompt = Some(prompt); + false + } + None => false, + } + }; + if notify { + let _ = self.change_notifier.send(()); + } } } diff --git a/src/connections.rs b/src/connections.rs --- a/src/connections.rs +++ b/src/connections.rs @@ -9,7 +9,7 @@ use dashmap::DashMap; #[cfg(test)] use mockall::automock; -use rand::seq::SliceRandom; +use rand::{seq::SliceRandom, thread_rng}; use crate::{ config::LoadBalancing, @@ -127,12 +127,11 @@ K: Borrow, Q: Hash + Eq + ?Sized, { - let mut rng = rand::thread_rng(); self.map.get(key).and_then(|handler| { handler .value() .as_slice() - .choose(&mut rng) + .choose(&mut thread_rng()) .map(|ConnectionMapEntry { handler, .. }| Clone::clone(handler)) }) } diff --git a/src/error.rs b/src/error.rs --- a/src/error.rs +++ b/src/error.rs @@ -16,4 +16,6 @@ LoadBalancingAlreadyBound, #[error("Quota reached for user")] QuotaReached, + #[error("No matching user key")] + NoMatchingUserKey, } diff --git a/src/fingerprints.rs b/src/fingerprints.rs --- a/src/fingerprints.rs +++ b/src/fingerprints.rs @@ -1,12 +1,18 @@ -use std::{collections::BTreeSet, path::PathBuf, sync::Arc, time::Duration}; +use std::{ + collections::BTreeMap, + fs::remove_file, + path::PathBuf, + sync::{Arc, RwLock}, + time::Duration, +}; -use crate::{directory::watch_directory, droppable_handle::DroppableHandle}; +use crate::{directory::watch_directory, droppable_handle::DroppableHandle, error::ServerError}; use log::{error, warn}; use notify::RecommendedWatcher; use ssh_key::{Fingerprint, HashAlg, PublicKey}; use tokio::{ fs::{read_dir, read_to_string}, - sync::{oneshot, RwLock}, + sync::oneshot, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -19,10 +25,27 @@ Admin, } +impl std::fmt::Display for AuthenticationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + AuthenticationType::None => "None", + AuthenticationType::User => "User", + AuthenticationType::Admin => "Admin", + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct KeyData { + pub(crate) file: PathBuf, + pub(crate) comment: String, + pub(crate) auth: AuthenticationType, +} + #[derive(Debug)] pub(crate) struct FingerprintsValidator { - user_fingerprints: Arc>>, - admin_fingerprints: Arc>>, + user_fingerprints: Arc>>, + admin_fingerprints: Arc>>, _user_join_handle: DroppableHandle<()>, _admin_join_handle: DroppableHandle<()>, _watchers: [RecommendedWatcher; 2], @@ -34,11 +57,11 @@ user_keys_directory: PathBuf, admin_keys_directory: PathBuf, ) -> anyhow::Result { - let user_fingerprints = Arc::new(RwLock::new(BTreeSet::new())); + let user_fingerprints = Arc::new(RwLock::new(BTreeMap::new())); let (user_watcher, mut user_rx) = watch_directory::(user_keys_directory.as_path())?; user_rx.mark_changed(); - let admin_fingerprints = Arc::new(RwLock::new(BTreeSet::new())); + let admin_fingerprints = Arc::new(RwLock::new(BTreeMap::new())); let (admin_watcher, mut admin_rx) = watch_directory::(admin_keys_directory.as_path())?; admin_rx.mark_changed(); @@ -51,7 +74,7 @@ if user_rx.changed().await.is_err() { break; } - let mut user_set = BTreeSet::new(); + let mut user_set = BTreeMap::new(); match read_dir(user_keys_directory.as_path()).await { Ok(mut read_dir) => { while let Ok(Some(entry)) = read_dir.next_entry().await { @@ -60,7 +83,16 @@ user_set.extend( data.lines() .flat_map(|line| PublicKey::from_openssh(line).ok()) - .map(|key| key.fingerprint(HashAlg::Sha256)), + .map(|key| { + ( + key.fingerprint(HashAlg::Sha256), + KeyData { + file: entry.path(), + comment: key.comment().into(), + auth: AuthenticationType::User, + }, + ) + }), ); } Err(err) => { @@ -72,7 +104,7 @@ } } } - *user_fingerprints_clone.write().await = user_set; + *user_fingerprints_clone.write().unwrap() = user_set; } Err(err) => { error!( @@ -96,7 +128,7 @@ if admin_rx.changed().await.is_err() { break; } - let mut admin_set = BTreeSet::new(); + let mut admin_set = BTreeMap::new(); match read_dir(admin_keys_directory.as_path()).await { Ok(mut read_dir) => { while let Ok(Some(entry)) = read_dir.next_entry().await { @@ -105,7 +137,16 @@ admin_set.extend( data.lines() .flat_map(|line| PublicKey::from_openssh(line).ok()) - .map(|key| key.fingerprint(HashAlg::Sha256)), + .map(|key| { + ( + key.fingerprint(HashAlg::Sha256), + KeyData { + file: entry.path(), + comment: key.comment().into(), + auth: AuthenticationType::Admin, + }, + ) + }), ); } Err(err) => { @@ -117,7 +158,7 @@ } } } - *admin_fingerprints_clone.write().await = admin_set; + *admin_fingerprints_clone.write().unwrap() = admin_set; } Err(err) => { error!( @@ -143,16 +184,45 @@ } // Find the right authentication type for a given fingerprint - pub(crate) async fn authenticate_fingerprint( - &self, - fingerprint: &Fingerprint, - ) -> AuthenticationType { - if self.admin_fingerprints.read().await.contains(fingerprint) { + pub(crate) fn authenticate_fingerprint(&self, fingerprint: &Fingerprint) -> AuthenticationType { + if self + .admin_fingerprints + .read() + .unwrap() + .contains_key(fingerprint) + { AuthenticationType::Admin - } else if self.user_fingerprints.read().await.contains(fingerprint) { + } else if self + .user_fingerprints + .read() + .unwrap() + .contains_key(fingerprint) + { AuthenticationType::User } else { AuthenticationType::None + } + } + + pub(crate) fn get_data_for_fingerprint(&self, fingerprint: &Fingerprint) -> Option { + if let Some(key_data) = self.admin_fingerprints.read().unwrap().get(fingerprint) { + Some(key_data.clone()) + } else { + self.user_fingerprints + .read() + .unwrap() + .get(fingerprint) + .cloned() + } + } + + pub(crate) fn remove_user_key(&self, fingerprint: &Fingerprint) -> anyhow::Result<()> { + if let Some(KeyData { file, .. }) = + self.user_fingerprints.write().unwrap().remove(fingerprint) + { + Ok(remove_file(file)?) + } else { + Err(ServerError::NoMatchingUserKey.into()) } } } @@ -195,27 +265,19 @@ .unwrap(); assert_eq!( - validator - .authenticate_fingerprint(&admin_key.fingerprint(HashAlg::Sha256)) - .await, + validator.authenticate_fingerprint(&admin_key.fingerprint(HashAlg::Sha256)), AuthenticationType::Admin ); assert_eq!( - validator - .authenticate_fingerprint(&key_one.fingerprint(HashAlg::Sha256)) - .await, + validator.authenticate_fingerprint(&key_one.fingerprint(HashAlg::Sha256)), AuthenticationType::User ); assert_eq!( - validator - .authenticate_fingerprint(&key_two.fingerprint(HashAlg::Sha256)) - .await, + validator.authenticate_fingerprint(&key_two.fingerprint(HashAlg::Sha256)), AuthenticationType::User ); assert_eq!( - validator - .authenticate_fingerprint(&unknown_key.fingerprint(HashAlg::Sha256)) - .await, + validator.authenticate_fingerprint(&unknown_key.fingerprint(HashAlg::Sha256)), AuthenticationType::None ); } diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -3,10 +3,10 @@ //! use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashMap}, marker::PhantomData, net::{IpAddr, SocketAddr}, - sync::{Arc, RwLock}, + sync::{atomic::AtomicUsize, Arc, Mutex, RwLock}, time::Duration, }; @@ -23,6 +23,7 @@ use russh_keys::decode_secret_key; use rustls::ServerConfig; use rustls_acme::is_tls_alpn_challenge; +use ssh_key::Fingerprint; use sysinfo::{CpuRefreshKind, MemoryRefreshKind, Networks, RefreshKind, System}; use tcp::TcpHandler; use tcp_alias::TcpAlias; @@ -32,7 +33,7 @@ io::AsyncWriteExt, net::{TcpListener, TcpStream}, pin, - sync::oneshot, + sync::watch, time::sleep, }; use tokio_rustls::LazyConfigAcceptor; @@ -70,8 +71,6 @@ mod tcp_alias; mod telemetry; -type DataTable = Arc>>; - struct HttpReactor { certificates: Arc, telemetry: Arc, @@ -93,7 +92,13 @@ cpu_usage: f32, } +type SessionMap = HashMap>; +type DataTable = Arc>>; + pub(crate) struct SandholeServer { + pub(crate) session_id: AtomicUsize, + pub(crate) sessions_password: Mutex>, + pub(crate) sessions_publickey: Mutex>, pub(crate) http: Arc, HttpReactor>>, pub(crate) ssh: Arc>>, pub(crate) tcp: Arc, Arc>>, @@ -326,6 +331,9 @@ ..Default::default() }); let mut sandhole = Arc::new(SandholeServer { + session_id: AtomicUsize::new(0), + sessions_password: Mutex::default(), + sessions_publickey: Mutex::default(), http: Arc::clone(&http_connections), ssh: ssh_connections, tcp: tcp_connections, @@ -515,7 +523,7 @@ server: &mut Arc, ) { let config = Arc::clone(config); - let (tx, mut rx) = oneshot::channel::<()>(); + let (tx, mut rx) = watch::channel(()); let handler = server.new_client(address, tx); tokio::spawn(async move { let mut session = match russh::server::run_stream(config, stream, handler).await { @@ -531,7 +539,8 @@ warn!("Connection with {} closed with error: {}", address, err); } } - Ok(_) = &mut rx => { + Ok(_) = rx.changed() => { + info!("Disconnecting client {}...", address); let _ = session.handle().disconnect(russh::Disconnect::ByApplication, "".into(), "English".into()).await; }, } diff --git a/src/ssh.rs b/src/ssh.rs --- a/src/ssh.rs +++ b/src/ssh.rs @@ -3,7 +3,7 @@ collections::{BTreeSet, HashMap, HashSet}, fmt::Display, net::SocketAddr, - sync::Arc, + sync::{atomic::Ordering, Arc}, }; use crate::{ @@ -23,13 +23,13 @@ use log::{debug, info, warn}; use russh::{ server::{Auth, Handler, Msg, Session}, - Channel, ChannelId, ChannelStream, Disconnect, MethodSet, + Channel, ChannelId, ChannelStream, MethodSet, }; use russh_keys::PublicKey; use ssh_key::{Fingerprint, HashAlg}; use tokio::{ io::{copy_bidirectional, AsyncWriteExt}, - sync::{mpsc, oneshot, Mutex, RwLock}, + sync::{mpsc, watch, RwLock}, time::{sleep, timeout}, }; @@ -153,9 +153,7 @@ } enum AuthenticatedData { - None { - cancelation_tx: Arc>>>, - }, + None, Proxy, User { user_data: Box, @@ -178,10 +176,12 @@ } pub(crate) struct ServerHandler { - _timeout_handle: Option>, + id: usize, + timeout_handle: Option>, peer: SocketAddr, user: Option, key_fingerprint: Option, + cancelation_tx: watch::Sender<()>, auth_data: AuthenticatedData, tx: mpsc::UnboundedSender>, rx: Option>>, @@ -193,7 +193,7 @@ fn new_client( &mut self, peer_address: SocketAddr, - cancelation_tx: oneshot::Sender<()>, + cancelation_tx: watch::Sender<()>, ) -> ServerHandler; } @@ -202,18 +202,19 @@ fn new_client( &mut self, peer_address: SocketAddr, - cancelation_tx: oneshot::Sender<()>, + cancelation_tx: watch::Sender<()>, ) -> ServerHandler { + let id = self.session_id.fetch_add(1, Ordering::AcqRel); info!("{} connected", peer_address); let (tx, rx) = mpsc::unbounded_channel(); ServerHandler { - _timeout_handle: None, + id, + timeout_handle: None, peer: peer_address, user: None, key_fingerprint: None, - auth_data: AuthenticatedData::None { - cancelation_tx: Arc::new(Mutex::new(Some(cancelation_tx))), - }, + cancelation_tx, + auth_data: AuthenticatedData::None, tx, rx: Some(rx), open_session_join_handle: None, @@ -273,6 +274,13 @@ .await { if is_authenticated { + self.server + .sessions_password + .lock() + .unwrap() + .entry(user.into()) + .or_default() + .insert(self.id, self.cancelation_tx.clone()); self.user = Some(user.into()); self.auth_data = AuthenticatedData::User { user_data: Box::new(UserData::new(TokenHolder::User( @@ -309,28 +317,26 @@ let authentication = self .server .fingerprints_validator - .authenticate_fingerprint(self.key_fingerprint.as_ref().unwrap()) - .await; + .authenticate_fingerprint(self.key_fingerprint.as_ref().unwrap()); match authentication { AuthenticationType::None => { // Start timer for user to do local port forwarding. // Otherwise, the connection will be canceled upon expiration - let AuthenticatedData::None { ref cancelation_tx } = self.auth_data else { - warn!("{} ({}) is already authenticated", user, self.peer); - return Ok(Auth::Reject { - proceed_with_methods: None, - }); - }; - let cancelation_tx = Arc::clone(cancelation_tx); + let cancelation_tx = self.cancelation_tx.clone(); let timeout = self.server.idle_connection_timeout; - self._timeout_handle = Some(DroppableHandle(tokio::spawn(async move { + self.timeout_handle = Some(DroppableHandle(tokio::spawn(async move { sleep(timeout).await; - if let Some(cancelation_tx) = cancelation_tx.lock().await.take() { - let _ = cancelation_tx.send(()); - } + let _ = cancelation_tx.send(()); }))); } AuthenticationType::User => { + self.server + .sessions_publickey + .lock() + .unwrap() + .entry(fingerprint) + .or_default() + .insert(self.id, self.cancelation_tx.clone()); self.auth_data = AuthenticatedData::User { user_data: Box::new(UserData::new(TokenHolder::User( UserIdentification::PublicKey(fingerprint), @@ -358,11 +364,11 @@ &mut self, _channel: ChannelId, data: &[u8], - session: &mut Session, + _session: &mut Session, ) -> Result<(), Self::Error> { // Sending Ctrl+C ends the session and disconnects the client if data == [3] { - session.disconnect(Disconnect::ByApplication, "", "English")?; + let _ = self.cancelation_tx.send(()); return Ok(()); } debug!("received data {:?}", data); @@ -382,6 +388,10 @@ b"\x1b[B" | b"j" => admin_interface.move_down(), // Esc b"\x1b" => admin_interface.cancel(), + // Enter + b"\r" => admin_interface.enter(), + // Delete + b"\x1b[3~" => admin_interface.delete(), _ => (), } } @@ -895,8 +905,7 @@ ) .await { - if let AuthenticatedData::None { ref cancelation_tx } = self.auth_data { - cancelation_tx.lock().await.take(); + if self.timeout_handle.take().is_some() { self.auth_data = AuthenticatedData::Proxy; } let _ = handler.log_channel().send( @@ -930,8 +939,7 @@ ) .await { - if let AuthenticatedData::None { ref cancelation_tx } = self.auth_data { - cancelation_tx.lock().await.take(); + if self.timeout_handle.take().is_some() { self.auth_data = AuthenticatedData::Proxy; } let _ = handler.log_channel().send( @@ -968,8 +976,7 @@ ) .await { - if let AuthenticatedData::None { ref cancelation_tx } = self.auth_data { - cancelation_tx.lock().await.take(); + if self.timeout_handle.take().is_some() { self.auth_data = AuthenticatedData::Proxy; } let _ = handler.log_channel().send( @@ -1008,11 +1015,28 @@ match self.auth_data { AuthenticatedData::User { .. } | AuthenticatedData::Admin { .. } => { let server = Arc::clone(&self.server); + let id = self.id; let peer = self.peer; tokio::task::spawn_blocking(move || { server.ssh.remove_by_address(&peer); server.http.remove_by_address(&peer); server.tcp.remove_by_address(&peer); + server + .sessions_password + .lock() + .unwrap() + .retain(|_, session| { + session.remove(&id); + !session.is_empty() + }); + server + .sessions_publickey + .lock() + .unwrap() + .retain(|_, session| { + session.remove(&id); + !session.is_empty() + }); }); } AuthenticatedData::Proxy | AuthenticatedData::None { .. } => (), diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,6 @@ - name: Build and push uses: docker/build-push-action@v6 with: - file: Dockerfile.ci push: true platforms: | linux/amd64 @@ -66,7 +65,6 @@ - name: Export binaries uses: docker/build-push-action@v6 with: - file: Dockerfile.ci target: binary outputs: type=local,dest=out - name: Upload artifact diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -9,7 +9,7 @@ contents: read env: - RUST_MSRV: 1.81.0 + RUST_MSRV: 1.82.0 jobs: rustfmt: diff --git a/book/src/logo.png b/book/src/logo.png --- a/book/src/logo.png +++ b/book/src/logo.png