Files

692 lines
24 KiB
Rust

use anyhow::{Context, Result, ensure};
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ssh2::{ExtendedData, Session};
use std::{
collections::BTreeMap,
fs,
io::{Read, Write},
net::{TcpStream, ToSocketAddrs},
path::{Path, PathBuf},
time::Duration,
};
use zeroize::Zeroizing;
#[derive(Clone)]
pub struct Credentials {
pub host: String,
pub port: u16,
pub user: String,
pub password: Zeroizing<String>,
}
#[derive(Clone, Debug)]
pub struct Probe {
pub endpoint: String,
pub fingerprint: String,
pub known: bool,
pub previous_fingerprint: Option<String>,
}
impl Probe {
pub fn changed(&self) -> bool {
self.previous_fingerprint
.as_ref()
.is_some_and(|old| old != &self.fingerprint)
}
fn confirm(&self, actual: &str, hosts: &Hosts) -> Result<()> {
ensure!(
actual == self.fingerprint,
"SSH 指纹与刚才检查的不一致,请重新检查 SSH 连接"
);
ensure!(
hosts
.keys
.get(&self.endpoint)
.map(|saved| normalized_saved_fingerprint(saved))
== self.previous_fingerprint,
"已保存的 SSH 指纹记录发生变化,请重新检查 SSH 连接"
);
Ok(())
}
}
pub struct Connection {
pub session: Session,
pub host: String,
pub user: String,
pub version: String,
pub installation: crate::maintenance::Installation,
}
#[derive(Default, Serialize, Deserialize)]
struct Hosts {
keys: BTreeMap<String, String>,
}
fn hosts_file() -> Result<PathBuf> {
Ok(ProjectDirs::from("org", "Framely", "Installer")
.context("无法确定配置目录")?
.config_dir()
.join("known-hosts.json"))
}
fn load_hosts() -> Result<Hosts> {
load_hosts_from(&hosts_file()?)
}
fn load_hosts_from(path: &Path) -> Result<Hosts> {
if path.exists() {
Ok(serde_json::from_slice(&fs::read(path)?)?)
} else {
Ok(Hosts::default())
}
}
fn handshake(host: &str, port: u16) -> Result<Session> {
ensure!(
!host.is_empty() && !host.contains(['\n', '\r']),
"设备地址无效"
);
let addresses: Vec<_> = (host, port).to_socket_addrs()?.collect();
let stream = addresses
.iter()
.find_map(|a| TcpStream::connect_timeout(a, Duration::from_secs(3)).ok())
.context("SSH 无法连接,请检查 IP 和开发者模式")?;
let mut session = Session::new()?;
session.set_tcp_stream(stream);
session.set_timeout(10000);
session.handshake()?;
Ok(session)
}
fn fingerprint(session: &Session) -> Result<String> {
let (key, _) = session.host_key().context("SSH 主机未提供公钥")?;
Ok(format!(
"SHA256:{}",
STANDARD_NO_PAD.encode(Sha256::digest(key))
))
}
fn normalized_saved_fingerprint(saved: &str) -> String {
if let Some(hex_digest) = saved.strip_prefix("SHA256:")
&& hex_digest.len() == 64
&& let Ok(bytes) = hex::decode(hex_digest)
{
return format!("SHA256:{}", STANDARD_NO_PAD.encode(bytes));
}
saved.to_owned()
}
pub fn probe(host: &str, port: u16) -> Result<Probe> {
let session = handshake(host, port)?;
let fingerprint = fingerprint(&session)?;
let hosts = load_hosts()?;
let endpoint = format!("{host}:{port}");
Ok(observed_probe(endpoint, fingerprint, &hosts))
}
fn observed_probe(endpoint: String, fingerprint: String, hosts: &Hosts) -> Probe {
let previous_fingerprint = hosts
.keys
.get(&endpoint)
.map(|saved| normalized_saved_fingerprint(saved));
let known = previous_fingerprint.as_ref() == Some(&fingerprint);
Probe {
endpoint,
fingerprint,
known,
previous_fingerprint,
}
}
pub fn connect(
credentials: Credentials,
confirmed: &Probe,
progress: &mut dyn FnMut(String),
) -> Result<Connection> {
ensure!(
credentials.user != "root"
&& !credentials.user.is_empty()
&& credentials
.user
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)),
"Steam 用户名无效"
);
ensure!(
!credentials.password.contains(['\n', '\r']),
"密码不能包含换行符"
);
ensure!(!credentials.password.is_empty(), "请输入设备登录密码");
ensure!(
confirmed.endpoint == format!("{}:{}", credentials.host, credentials.port),
"设备地址已变化,请重新检查 SSH 连接"
);
progress("正在建立 SSH 连接…".into());
let session = handshake(&credentials.host, credentials.port).context("无法建立 SSH 连接")?;
// The Connect button confirms this exact observation, including a changed
// key. Recheck before sending credentials; never accept a second change.
confirmed.confirm(&fingerprint(&session)?, &load_hosts()?)?;
progress("正在验证用户名和密码…".into());
session
.userauth_password(&credentials.user, &credentials.password)
.context("SSH 登录失败:请检查用户名和密码,并确认已开启开发者模式")?;
ensure!(session.authenticated(), "SSH 登录失败");
progress("正在检查设备型号…".into());
let identity = exec(
&session,
"printf '%s\\n' \"$(hostname)\" \"$(uname -s)\" \"$(uname -m)\"",
None,
&mut |_| {},
)?;
let fields: Vec<_> = identity.lines().collect();
ensure!(
fields.len() >= 3 && fields[1] == "Linux" && fields[2] == "aarch64",
"目标设备不是 Linux ARM64 Steam Frame"
);
progress("正在检查管理员权限…".into());
// Check sudo credentials without echoing the password or putting it in arguments.
exec(
&session,
"sudo -S -p '' -k -v",
Some(&credentials.password),
&mut |_| {},
)
.context("SSH 已登录,但管理员权限验证失败:请检查此账号的 sudo 权限及密码")?;
progress("正在读取已安装版本…".into());
let installation = read_installation(&session, &credentials.password)?;
let version = installation
.current_version
.as_deref()
.unwrap_or(if installation.present {
"安装需要修复"
} else {
"未安装"
});
progress("正在保存设备指纹…".into());
remember_probe(&hosts_file()?, confirmed)?;
Ok(Connection {
session,
host: credentials.host,
user: credentials.user,
version: format!("{} · {}", fields[0], version.trim()),
installation,
})
}
fn remember_probe(path: &Path, confirmed: &Probe) -> Result<()> {
let parent = path.parent().context("无效的指纹配置路径")?;
fs::create_dir_all(parent).context("无法创建指纹配置目录,请检查本机目录权限")?;
let mut hosts = load_hosts_from(path)?;
confirmed.confirm(&confirmed.fingerprint, &hosts)?;
hosts
.keys
.insert(confirmed.endpoint.clone(), confirmed.fingerprint.clone());
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(&serde_json::to_vec_pretty(&hosts)?)?;
file.as_file().sync_all()?;
file.persist(path)
.context("无法保存设备指纹,请检查本机配置目录权限")?;
Ok(())
}
fn read_installation(
session: &Session,
password: &str,
) -> Result<crate::maintenance::Installation> {
let script = r#"import json, os, pathlib
root = pathlib.Path('/home/.framely/state')
if not root.is_dir(): root = pathlib.Path('/var/lib/framely')
def version(name):
file = root / name / 'VERSION'
return file.read_text().strip() or None if file.is_file() else None
print(json.dumps({'present': os.path.lexists(root / 'current'), 'currentVersion': version('current'), 'previousVersion': version('previous')}))
"#;
let output = exec(
session,
&format!("sudo -S -p '' -- python3 -c {}", quote(script)),
Some(password),
&mut |_| {},
)
.context("SSH 已登录,但无法读取安装状态")?;
serde_json::from_str(output.trim()).context("设备安装状态无效")
}
pub fn quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn exec(
session: &Session,
command: &str,
password: Option<&str>,
log: &mut dyn FnMut(String),
) -> Result<String> {
exec_with_progress(session, command, password, log, None)
}
fn exec_with_progress(
session: &Session,
command: &str,
password: Option<&str>,
log: &mut dyn FnMut(String),
mut progress: Option<&mut dyn FnMut(crate::progress::Progress)>,
) -> Result<String> {
let mut channel = session.channel_session()?;
channel.handle_extended_data(ExtendedData::Merge)?;
channel.exec(command)?;
if let Some(password) = password {
channel.write_all(password.as_bytes())?;
channel.write_all(b"\n")?;
channel.flush()?;
}
channel.send_eof()?;
let mut result = String::new();
let mut chunk = [0; 4096];
let mut decoder = crate::progress::DeviceProgress::default();
loop {
let n = channel.read(&mut chunk)?;
if n == 0 {
break;
}
let line = String::from_utf8_lossy(&chunk[..n]).to_string();
// A remote process must never cause credentials to appear in UI logs.
let line = password
.filter(|s| !s.is_empty())
.map_or(line.clone(), |p| line.replace(p, "[密码已隐藏]"));
let line = if let Some(report) = progress.as_deref_mut() {
decoder.push(&line, report).join("")
} else {
line
};
if !line.is_empty() {
log(line.clone());
}
if result.len() < 128 * 1024 {
result.push_str(&line);
}
}
if progress.is_some() {
let tail = decoder.finish();
if !tail.is_empty() {
log(tail.clone());
result.push_str(&tail);
}
}
channel.wait_close()?;
ensure!(
channel.exit_status()? == 0,
"远程操作失败:{}",
result.trim()
);
Ok(result)
}
fn upload(
session: &Session,
path: &Path,
remote: &str,
offset: u64,
total: u64,
progress: &mut dyn FnMut(crate::progress::Progress),
) -> Result<()> {
let size = path.metadata()?.len();
let sftp = session.sftp()?;
let mut output = sftp.create(Path::new(remote))?;
let mut input = fs::File::open(path)?;
crate::progress::copy(
&mut input,
&mut output,
size,
crate::progress::Stage::Transfer,
&path.file_name().unwrap_or_default().to_string_lossy(),
&mut |mut value| {
value.completed += offset;
value.total = Some(total);
progress(value);
},
)?;
output.close()?;
Ok(())
}
pub struct Operation<'a> {
pub action: &'a str,
pub archive: Option<&'a Path>,
pub sums: Option<&'a Path>,
pub repo: &'a str,
}
pub fn operate(
connection: &Connection,
password: Zeroizing<String>,
operation: Operation<'_>,
log: &mut dyn FnMut(String),
progress: &mut dyn FnMut(crate::progress::Progress),
) -> Result<()> {
let Operation {
action,
archive,
sums,
repo,
} = operation;
ensure!(
["install", "update", "repair", "rollback", "uninstall"].contains(&action),
"未知操作"
);
super::release::validate_repo(repo)?;
let session = &connection.session;
session.set_timeout(15 * 60 * 1000);
let installation = read_installation(session, &password)?;
ensure!(
installation.allows(action),
"设备安装状态已变化,请重新连接并选择可用操作"
);
if action == "update" {
if let Some(name) = archive.and_then(|p| p.file_name()).and_then(|s| s.to_str()) {
ensure!(
!installation.same_package(name),
"该版本已安装,请使用修复安装"
);
}
}
let dir = exec(
session,
"umask 077; mktemp -d /home/$(id -un)/.framely-installer-XXXXXXXX",
None,
log,
)?
.trim()
.to_owned();
ensure!(
dir.starts_with(&format!("/home/{}/.framely-installer-", connection.user))
&& !dir.contains(['\n', '\r']),
"临时目录无效"
);
let result = (|| -> Result<()> {
let total = super::BOOTSTRAP.len() as u64
+ archive
.map(|p| p.metadata().map(|m| m.len()))
.transpose()?
.unwrap_or(0)
+ sums
.map(|p| p.metadata().map(|m| m.len()))
.transpose()?
.unwrap_or(0);
let mut transfer =
crate::progress::Progress::new(crate::progress::Stage::Transfer, "传输安装引擎");
transfer.total = Some(total);
progress(transfer.clone());
let sftp = session.sftp()?;
let script = format!("{dir}/bootstrap.py");
let mut file = sftp.create(Path::new(&script))?;
file.write_all(super::BOOTSTRAP)?;
file.close()?;
transfer.completed = super::BOOTSTRAP.len() as u64;
progress(transfer);
let mut args = format!(
"sudo -S -p '' -k -- env FRAMELY_GITHUB_AUTO_FALLBACK={} python3 -u {} {} --yes --progress-json --repo {} --user {}",
if crate::proxy::current().github_auto_fallback { "1" } else { "0" },
quote(&script),
quote(action),
quote(repo),
quote(&connection.user)
);
if let (Some(archive), Some(sums)) = (archive, sums) {
super::release::verify(archive, sums)?;
let name = archive
.file_name()
.and_then(|s| s.to_str())
.context("压缩包名无效")?;
let target = format!("{dir}/{name}");
upload(
session,
archive,
&target,
super::BOOTSTRAP.len() as u64,
total,
progress,
)?;
let checksums = format!("{dir}/SHA256SUMS");
upload(
session,
sums,
&checksums,
super::BOOTSTRAP.len() as u64 + archive.metadata()?.len(),
total,
progress,
)?;
args += &format!(
" --archive {} --checksums {}",
quote(&target),
quote(&checksums)
);
} else {
ensure!(
!matches!(action, "install" | "update"),
"安装或更新需要已校验的发行包"
);
}
let mut installing =
crate::progress::Progress::new(crate::progress::Stage::Install, "设备端校验安装包");
installing.step = Some((1, 5));
progress(installing);
exec_with_progress(session, &args, Some(&password), log, Some(progress))?;
let mut activating = crate::progress::Progress::new(
crate::progress::Stage::Install,
"检查系统服务并清理临时文件",
);
activating.step = Some((5, 5));
progress(activating);
if action != "uninstall" {
exec(
session,
"systemctl is-active framely.service && systemctl is-enabled framely.service framely-session.service",
None,
log,
)?;
}
Ok(())
})();
// Remove uploaded packages even if hooks or installation failed.
let cleanup = exec(
session,
&format!("rm -rf -- {}", quote(&dir)),
None,
&mut |_| {},
);
session.set_timeout(10000);
result?;
cleanup?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoting_preserves_shell_literals() {
assert_eq!(quote("a'b;$()"), "'a'\\''b;$()'");
}
#[test]
fn old_saved_fingerprints_remain_comparable() {
let digest = [0_u8; 32];
let standard = format!("SHA256:{}", STANDARD_NO_PAD.encode(digest));
assert_eq!(
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode(digest))),
standard
);
assert_eq!(normalized_saved_fingerprint(&standard), standard);
assert_ne!(
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode([1_u8; 32]))),
standard
);
let hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), format!("SHA256:{}", hex::encode(digest)))]),
};
let probe = observed_probe("frame:22".into(), standard.clone(), &hosts);
assert!(probe.known && !probe.changed());
probe.confirm(&standard, &hosts).unwrap();
}
#[test]
fn changed_fingerprint_can_be_confirmed_without_losing_other_devices() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let old = "SHA256:old";
let new = "SHA256:new";
let hosts = Hosts {
keys: BTreeMap::from([
("frame:22".into(), old.into()),
("other:2222".into(), "SHA256:other".into()),
]),
};
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
let fresh = observed_probe("fresh:22".into(), new.into(), &hosts);
assert!(!fresh.known && !fresh.changed() && fresh.previous_fingerprint.is_none());
let changed = observed_probe("frame:22".into(), new.into(), &hosts);
assert!(!changed.known && changed.changed());
assert_eq!(changed.previous_fingerprint.as_deref(), Some(old));
// Merely inspecting or cancelling leaves the original record intact.
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
assert!(changed.confirm("SHA256:changed-again", &hosts).is_err());
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
changed.confirm(new, &hosts).unwrap();
remember_probe(&path, &changed).unwrap();
let saved = load_hosts_from(&path).unwrap();
assert_eq!(saved.keys["frame:22"], new);
assert_eq!(saved.keys["other:2222"], "SHA256:other");
let next = observed_probe("frame:22".into(), new.into(), &saved);
assert!(next.known && !next.changed());
next.confirm(new, &saved).unwrap();
}
#[test]
fn stale_fingerprint_confirmation_does_not_replace_a_newer_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let mut hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), "SHA256:old".into())]),
};
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &hosts);
hosts
.keys
.insert("frame:22".into(), "SHA256:confirmed-elsewhere".into());
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
assert!(pending.confirm(&pending.fingerprint, &hosts).is_err());
assert!(remember_probe(&path, &pending).is_err());
assert_eq!(
load_hosts_from(&path).unwrap().keys["frame:22"],
"SHA256:confirmed-elsewhere"
);
}
#[test]
fn confirmed_fingerprint_is_bound_to_the_checked_address_and_port() {
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &Hosts::default());
for (host, port) in [("other", 22), ("frame", 2222)] {
let mut stages = vec![];
let error = connect(
Credentials {
host: host.into(),
port,
user: "steamos".into(),
password: Zeroizing::new("test".into()),
},
&pending,
&mut |stage| stages.push(stage),
)
.err()
.unwrap();
assert!(error.to_string().contains("设备地址已变化"));
assert!(stages.is_empty());
}
}
#[test]
#[ignore = "Requires an explicitly supplied SSH device and trusted OpenSSH fingerprints"]
fn real_device_fingerprint_matches_openssh() {
let host = std::env::var("FRAMELY_TEST_HOST").expect("Set FRAMELY_TEST_HOST");
let expected =
std::env::var("FRAMELY_TEST_FINGERPRINTS").expect("Set trusted OpenSSH fingerprints");
let probe = probe(&host, 22).expect("SSH handshake");
assert!(
expected
.split_whitespace()
.any(|value| value == probe.fingerprint),
"{}",
probe.fingerprint
);
}
#[test]
#[ignore = "Requires an explicitly supplied SSH device; attempts one intentionally invalid login"]
fn real_device_reports_authentication_failure() {
let host = std::env::var("FRAMELY_TEST_HOST").expect("Set FRAMELY_TEST_HOST");
let probe = probe(&host, 22).expect("SSH handshake");
let mut stages = Vec::new();
let result = connect(
Credentials {
host,
port: 22,
user: "steamos".into(),
password: Zeroizing::new("framely-invalid-password-test-9c45bc39".into()),
},
&probe,
&mut |stage| stages.push(stage),
);
let error = match result {
Ok(_) => panic!("Invalid test password unexpectedly accepted"),
Err(error) => format!("{error:#}"),
};
assert!(error.contains("SSH 登录失败"), "{error}");
assert!(
stages
.iter()
.any(|stage| stage.contains("验证用户名和密码"))
);
eprintln!("stages={stages:?}; error={error}");
}
}
/// Collect directly over SSH; neither the web panel nor the daemon is required.
pub fn export_logs(
connection: &Connection,
password: &str,
destination: &Path,
logs: &[String],
) -> Result<()> {
use base64::engine::general_purpose::STANDARD;
ensure!(
!password.is_empty() && !password.contains(['\n', '\r']),
"请输入有效的设备登录密码"
);
connection.session.set_timeout(60_000);
let script = include_str!("../../tools/export-diagnostics.py");
// Keep archive output private and binary-safe. The ordinary text logger
// masks password substrings, which could corrupt a base64 archive.
let mut channel = connection.session.channel_session()?;
let local_log = logs.join("\n").replace(password, "[密码已隐藏]");
let recent: String = local_log
.chars()
.rev()
.take(64 * 1024)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
channel.exec(&format!(
"sudo -S -p '' -- python3 -c {} --installer-log-base64 {}",
quote(script),
quote(&STANDARD.encode(recent))
))?;
channel.write_all(password.as_bytes())?;
channel.write_all(b"\n")?;
channel.flush()?;
channel.send_eof()?;
let mut output = String::new();
Read::by_ref(&mut channel)
.take(12 * 1024 * 1024)
.read_to_string(&mut output)?;
let mut error = String::new();
channel.stderr().take(4096).read_to_string(&mut error)?;
channel.wait_close()?;
ensure!(
channel.exit_status()? == 0,
"日志收集失败:{}",
error.replace(password, "[密码已隐藏]")
);
let archive: serde_json::Value =
serde_json::from_str(output.trim()).context("日志包响应无效")?;
let bytes = STANDARD.decode(archive["data"].as_str().context("日志包缺少数据")?)?;
ensure!(
bytes.len() <= 9 * 1024 * 1024 && bytes.starts_with(b"PK\x03\x04"),
"日志包格式无效"
);
let parent = destination
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut file = tempfile::NamedTempFile::new_in(parent).context("无法创建日志包")?;
file.write_all(&bytes).context("无法保存日志包")?;
file.as_file().sync_all()?;
file.persist(destination).context("无法保存日志包")?;
Ok(())
}