main #1
@@ -2,11 +2,11 @@ use crate::auth::AuthedClient;
|
|||||||
use crate::cmd::connection;
|
use crate::cmd::connection;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use rsh_types::{AttachIOFrame, OpReq, OpResp};
|
use rsh_types::{AttachIOFrame, OpReq, OpResp};
|
||||||
use russh::server::{Auth, Session};
|
use russh::server::{Auth, Session};
|
||||||
use russh::ChannelId;
|
use russh::ChannelId;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -45,18 +45,39 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load the operator's SSH key once. It serves as both the SSH host key and,
|
||||||
|
// via its public half, an always-allowed client key.
|
||||||
|
let operator_key = load_operator_key(&cfg)?;
|
||||||
|
|
||||||
|
// Build the set of client public keys allowed to connect: the backend's
|
||||||
|
// authorized_keys plus the operator's own key. Refuse to start otherwise —
|
||||||
|
// an empty allowlist would be an open, unauthenticated shell.
|
||||||
|
let allowed_fps = build_allowed_fingerprints(&client, &operator_key).await?;
|
||||||
|
if allowed_fps.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"no authorized keys available; refusing to start an open SSH server"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let allowed_fps = Arc::new(allowed_fps);
|
||||||
|
ui::print_info(&format!("{} authorized key(s) loaded", allowed_fps.len()));
|
||||||
|
|
||||||
let mut config = russh::server::Config {
|
let mut config = russh::server::Config {
|
||||||
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
|
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
|
||||||
auth_rejection_time: std::time::Duration::from_secs(1),
|
auth_rejection_time: std::time::Duration::from_secs(1),
|
||||||
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
|
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
|
||||||
|
// Only public-key auth is accepted; don't advertise password/none.
|
||||||
|
methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use a static ephemeral key for the SSH server
|
// Derive the SSH host key from the operator key, round-tripped through
|
||||||
let key_str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBxYVtR5Xn/kk9PM26c//B9JlvqBBv4EbkWr3jRI8CLfQAAAJjAfItSwHyL\nUgAAAAtzc2gtZWQyNTUxOQAAACBxYVtR5Xn/kk9PM26c//B9JlvqBBv4EbkWr3jRI8CLfQ\nAAAEA3iPbbQVsorPV8V36bsOZWFHyYzqAx4SIPBQsVWW4RdHFhW1Hlef+ST08zbpz/8H0m\nW+oEG/gRuRaveNEjwIt9AAAAD2V0eEBldHgtZGVza3RvcAECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----";
|
// OpenSSH encoding (russh uses a different ssh-key version than we do).
|
||||||
let key = russh::keys::PrivateKey::from_openssh(key_str)
|
let host_pem = operator_key
|
||||||
.map_err(|e| anyhow!("Failed to parse SSH host key: {}", e))?;
|
.to_openssh(ssh_key::LineEnding::LF)
|
||||||
config.keys.push(key);
|
.context("encode host key")?;
|
||||||
|
let host_key = russh::keys::PrivateKey::from_openssh(host_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow!("parse host key: {e}"))?;
|
||||||
|
config.keys.push(host_key);
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
|
|
||||||
let bind_addr = format!("{}:{}", listen_host, listen_port);
|
let bind_addr = format!("{}:{}", listen_host, listen_port);
|
||||||
@@ -74,6 +95,7 @@ pub async fn run(
|
|||||||
session_name: session_name.clone(),
|
session_name: session_name.clone(),
|
||||||
connection_id: conn_id,
|
connection_id: conn_id,
|
||||||
shell: shell.clone(),
|
shell: shell.clone(),
|
||||||
|
allowed_fps: allowed_fps.clone(),
|
||||||
clients: HashMap::new(),
|
clients: HashMap::new(),
|
||||||
pty_reqs: HashMap::new(),
|
pty_reqs: HashMap::new(),
|
||||||
};
|
};
|
||||||
@@ -85,11 +107,71 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject() -> Auth {
|
||||||
|
Auth::Reject {
|
||||||
|
proceed_with_methods: None,
|
||||||
|
partial_success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and (if needed) decrypt the operator's SSH private key.
|
||||||
|
fn load_operator_key(cfg: &Config) -> Result<ssh_key::PrivateKey> {
|
||||||
|
let key_path = cfg.ssh_key_path();
|
||||||
|
let raw = std::fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||||
|
let mut key = ssh_key::PrivateKey::from_openssh(&raw).context("parse openssh private key")?;
|
||||||
|
if key.is_encrypted() {
|
||||||
|
let pw = inquire::Password::new(&format!("Passphrase for {}:", key_path.display()))
|
||||||
|
.without_confirmation()
|
||||||
|
.prompt()
|
||||||
|
.map_err(|e| anyhow!("password prompt: {e}"))?;
|
||||||
|
key = key.decrypt(pw.as_bytes()).context("decrypt private key")?;
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 fingerprints of every public key allowed to connect: the backend's
|
||||||
|
/// authorized_keys plus the operator's own key.
|
||||||
|
async fn build_allowed_fingerprints(
|
||||||
|
client: &AuthedClient,
|
||||||
|
operator_key: &ssh_key::PrivateKey,
|
||||||
|
) -> Result<HashSet<String>> {
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
|
||||||
|
// The operator's own key is always allowed, even if not in authorized_keys.
|
||||||
|
let op_pub = operator_key
|
||||||
|
.public_key()
|
||||||
|
.to_openssh()
|
||||||
|
.context("encode operator pubkey")?;
|
||||||
|
if let Ok(pk) = russh::keys::PublicKey::from_openssh(&op_pub) {
|
||||||
|
set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backend's authorized_keys — the same trust set that gates operator auth.
|
||||||
|
match client.req(OpReq::KeysList).await? {
|
||||||
|
OpResp::Keys(lines) => {
|
||||||
|
for line in lines {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(pk) = russh::keys::PublicKey::from_openssh(line) {
|
||||||
|
set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpResp::Err(e) => return Err(anyhow!(e)),
|
||||||
|
other => return Err(anyhow!("unexpected: {other:?}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(set)
|
||||||
|
}
|
||||||
|
|
||||||
struct SessionHandler {
|
struct SessionHandler {
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
session_name: String,
|
session_name: String,
|
||||||
connection_id: u64,
|
connection_id: u64,
|
||||||
shell: Option<String>,
|
shell: Option<String>,
|
||||||
|
allowed_fps: Arc<HashSet<String>>,
|
||||||
|
|
||||||
clients: HashMap<ChannelId, Arc<AuthedClient>>,
|
clients: HashMap<ChannelId, Arc<AuthedClient>>,
|
||||||
pty_reqs: HashMap<ChannelId, (u16, u16)>,
|
pty_reqs: HashMap<ChannelId, (u16, u16)>,
|
||||||
@@ -184,20 +266,35 @@ impl SessionHandler {
|
|||||||
impl russh::server::Handler for SessionHandler {
|
impl russh::server::Handler for SessionHandler {
|
||||||
type Error = anyhow::Error;
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
_channel: russh::Channel<russh::server::Msg>,
|
||||||
|
reply: russh::server::ChannelOpenHandle,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
reply.accept().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn auth_publickey(
|
async fn auth_publickey(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &str,
|
_: &str,
|
||||||
_: &russh::keys::PublicKey,
|
key: &russh::keys::PublicKey,
|
||||||
) -> Result<Auth, Self::Error> {
|
) -> Result<Auth, Self::Error> {
|
||||||
|
let fp = key.fingerprint(russh::keys::HashAlg::Sha256).to_string();
|
||||||
|
if self.allowed_fps.contains(&fp) {
|
||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
|
} else {
|
||||||
|
Ok(reject())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_password(&mut self, _: &str, _: &str) -> Result<Auth, Self::Error> {
|
async fn auth_password(&mut self, _: &str, _: &str) -> Result<Auth, Self::Error> {
|
||||||
Ok(Auth::Accept)
|
Ok(reject())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_none(&mut self, _: &str) -> Result<Auth, Self::Error> {
|
async fn auth_none(&mut self, _: &str) -> Result<Auth, Self::Error> {
|
||||||
Ok(Auth::Accept)
|
Ok(reject())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pty_request(
|
async fn pty_request(
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ struct ShellArgs {
|
|||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
struct ServeSshArgs {
|
struct ServeSshArgs {
|
||||||
session: String,
|
session: String,
|
||||||
#[arg(short = 'h', long, default_value = "127.0.0.1")]
|
#[arg(short = 'H', long, default_value = "127.0.0.1")]
|
||||||
listen_host: String,
|
listen_host: String,
|
||||||
#[arg(short = 'p', long, default_value_t = 2222)]
|
#[arg(short = 'p', long, default_value_t = 2222)]
|
||||||
listen_port: u16,
|
listen_port: u16,
|
||||||
|
|||||||
Reference in New Issue
Block a user