mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #10166 from hildlabs/remote-servers-ssh
Launcher: a server on another computer, run over SSH
This commit is contained in:
4 files changed
+352
-8
No files matched your search
+38
-7
@@ -28,13 +28,30 @@ pub const EULA_URL: &str = "https://aka.ms/MinecraftEULA";
|
||||
|
||||
// ----- The list of servers -----------------------------------------------------------------------
|
||||
|
||||
/// One server the launcher knows: its name and its folder.
|
||||
/// One server the launcher knows: its name and its folder, on this computer or on `host`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub dir: PathBuf,
|
||||
/// The other computer it runs on, reached over SSH (`user@host`, or a `Host` from `~/.ssh/config`);
|
||||
/// `dir` is then the folder there ([`remote`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// `horizon-server --dir <dir> <args>`, here or over SSH on the entry's computer.
|
||||
pub fn command(&self, args: &[&str]) -> Result<Command, String> {
|
||||
match &self.host {
|
||||
Some(host) => remote::command(host, &self.dir.to_string_lossy(), args),
|
||||
None => command(&self.dir, args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "servers/remote.rs"]
|
||||
pub mod remote;
|
||||
|
||||
/// The game mode a server's `server.toml` sets (`[world] game_mode`), read from the file
|
||||
/// without asking `horizon-server`: the Servers page's list says it for each stopped server.
|
||||
pub fn game_mode(dir: &Path) -> Option<String> {
|
||||
@@ -51,13 +68,16 @@ fn list_file() -> PathBuf {
|
||||
backend::data_dir().join("servers.json")
|
||||
}
|
||||
|
||||
/// The servers, in the order they were added; folders that are gone are left out.
|
||||
/// The servers, in the order they were added; folders on this computer that are gone are left out.
|
||||
pub fn list() -> Vec<Entry> {
|
||||
let entries: Vec<Entry> = fs::read_to_string(list_file())
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default();
|
||||
entries.into_iter().filter(|e| e.dir.is_dir()).collect()
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|e| e.host.is_some() || e.dir.is_dir())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_list(entries: &[Entry]) -> Result<(), String> {
|
||||
@@ -79,15 +99,22 @@ fn save_list(entries: &[Entry]) -> Result<(), String> {
|
||||
/// Adds a server folder to the list (once).
|
||||
pub fn add(entry: Entry) -> Result<(), String> {
|
||||
let mut all = list();
|
||||
all.retain(|e| e.dir != entry.dir);
|
||||
all.retain(|e| (&e.host, &e.dir) != (&entry.host, &entry.dir));
|
||||
all.push(entry);
|
||||
save_list(&all)
|
||||
}
|
||||
|
||||
/// Takes a server off the list; its folder stays.
|
||||
/// Takes a server on this computer off the list; its folder stays.
|
||||
pub fn forget(dir: &Path) -> Result<(), String> {
|
||||
let mut all = list();
|
||||
all.retain(|e| e.dir != dir);
|
||||
all.retain(|e| e.host.is_some() || e.dir != dir);
|
||||
save_list(&all)
|
||||
}
|
||||
|
||||
/// Takes a server on another computer off the list; nothing there changes.
|
||||
pub fn forget_remote(host: &str, dir: &Path) -> Result<(), String> {
|
||||
let mut all = list();
|
||||
all.retain(|e| e.host.as_deref() != Some(host) || e.dir != dir);
|
||||
save_list(&all)
|
||||
}
|
||||
|
||||
@@ -708,7 +735,11 @@ pub fn create(spec: &NewServer, tag: Option<&str>, log: &dyn Fn(String)) -> Resu
|
||||
&settings.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(undo)?;
|
||||
let entry = Entry { name, dir };
|
||||
let entry = Entry {
|
||||
name,
|
||||
dir,
|
||||
host: None,
|
||||
};
|
||||
add(entry.clone()).map_err(Problem::plain)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! A server on another computer (a VPS, a home server), reached over SSH: the launcher runs the same
|
||||
//! `horizon-server` commands there as `ssh HOST horizon-server --dir DIR ...`, with the system's `ssh` and the
|
||||
//! player's own keys and `~/.ssh/config`. `BatchMode` keeps ssh from asking for a password, so the launcher
|
||||
//! never sees or stores one. The other computer runs Linux or macOS (a POSIX shell runs the command there).
|
||||
|
||||
use super::Status;
|
||||
use std::ffi::OsStr;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// The options every connection takes: never prompt, give up on a dead address in 10 s, and notice a
|
||||
/// dropped connection within a minute.
|
||||
const OPTIONS: [&str; 8] = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"ServerAliveCountMax=4",
|
||||
];
|
||||
|
||||
/// `install.sh` puts `horizon-server` in `~/.local/bin`, which a command run over ssh (no login shell)
|
||||
/// often doesn't have on its PATH.
|
||||
const PATH: &str = "PATH=\"$HOME/.local/bin:$PATH\"";
|
||||
|
||||
/// What the check prints when the folder holds no `server.toml`.
|
||||
const NO_SERVER: &str = "horizon-server: no server in this folder";
|
||||
|
||||
/// Checks what the player typed as the computer: `user@host`, `host`, or a `Host` name from
|
||||
/// `~/.ssh/config`. Nothing that ssh could read as an option or that holds spaces.
|
||||
pub fn check_host(host: &str) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err(
|
||||
"Type the computer: user@address, or a Host name from your ~/.ssh/config.".into(),
|
||||
);
|
||||
}
|
||||
if host.starts_with('-') || host.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return Err(format!("{host:?} isn't a computer ssh can connect to."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The server's folder there, as the remote shell gets it: `~/x` is written `x`, since a command run
|
||||
/// over ssh starts in the home folder.
|
||||
fn remote_dir(dir: &str) -> Result<&str, String> {
|
||||
let dir = dir.trim();
|
||||
let dir = dir.strip_prefix("~/").unwrap_or(dir);
|
||||
if dir.is_empty() || dir == "~" || dir.chars().any(char::is_control) {
|
||||
return Err("Type the server's folder on that computer, like /srv/minecraft.".into());
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// One argument for a POSIX shell, in single quotes.
|
||||
pub fn quote(arg: &str) -> String {
|
||||
format!("'{}'", arg.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// ssh's arguments for `horizon-server --dir <dir> <args>` on `host`.
|
||||
pub fn ssh_args(host: &str, dir: &str, args: &[&str]) -> Result<Vec<String>, String> {
|
||||
check_host(host)?;
|
||||
let dir = remote_dir(dir)?;
|
||||
let mut line = format!("{PATH} exec horizon-server --dir {}", quote(dir));
|
||||
for arg in args {
|
||||
line.push(' ');
|
||||
line.push_str("e(arg));
|
||||
}
|
||||
let mut out: Vec<String> = OPTIONS.iter().map(|s| s.to_string()).collect();
|
||||
out.extend(["-T".into(), "--".into(), host.into(), line]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `horizon-server --dir <dir> <args>` on `host`, through `ssh`.
|
||||
pub fn command(host: &str, dir: &str, args: &[&str]) -> Result<Command, String> {
|
||||
command_with("ssh", host, dir, args)
|
||||
}
|
||||
|
||||
fn command_with(
|
||||
ssh: impl AsRef<OsStr>,
|
||||
host: &str,
|
||||
dir: &str,
|
||||
args: &[&str],
|
||||
) -> Result<Command, String> {
|
||||
let mut cmd = crate::process::command(ssh);
|
||||
cmd.args(ssh_args(host, dir, args)?);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Connects and asks the server there for its status, as the Add dialog does before it adds a server.
|
||||
pub fn check(host: &str, dir: &str) -> Result<Status, String> {
|
||||
check_with("ssh", host, dir)
|
||||
}
|
||||
|
||||
fn check_with(ssh: impl AsRef<OsStr>, host: &str, dir: &str) -> Result<Status, String> {
|
||||
let mut args = ssh_args(host, dir, &["status", "--json"])?;
|
||||
// `status` answers "stopped" for any folder: say so when there is no server in it at all.
|
||||
let folder = quote(remote_dir(dir)?);
|
||||
let line = args.pop().unwrap_or_default();
|
||||
args.push(format!(
|
||||
"[ -f {folder}/server.toml ] || {{ echo \"{NO_SERVER}\" >&2; exit 3; }}; {line}"
|
||||
));
|
||||
let output = crate::process::command(ssh)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
"This computer has no ssh program. It comes with Windows 10 and later (Settings › \
|
||||
Optional features › OpenSSH Client), macOS and Linux."
|
||||
.to_string()
|
||||
}
|
||||
_ => format!("Couldn't run ssh: {e}"),
|
||||
})?;
|
||||
let out = String::from_utf8_lossy(&output.stdout);
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
return Err(explain(host, dir, output.status.code(), &err));
|
||||
}
|
||||
let value: serde_json::Value = out
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|l| serde_json::from_str(l).ok())
|
||||
.ok_or_else(|| format!("horizon-server on {host} answered something unexpected."))?;
|
||||
Ok(Status::parse(&value))
|
||||
}
|
||||
|
||||
/// What went wrong, in plain words, from ssh's exit code and what it or the remote shell printed.
|
||||
pub fn explain(host: &str, dir: &str, code: Option<i32>, stderr: &str) -> String {
|
||||
let has = |s: &str| stderr.contains(s);
|
||||
if has(NO_SERVER) {
|
||||
return format!(
|
||||
"There's no server in {dir} on {host}. Check the folder, or set one up there with \
|
||||
horizon-server init."
|
||||
);
|
||||
}
|
||||
if has("Permission denied") {
|
||||
return format!(
|
||||
"{host} didn't accept your SSH key. Set one up once with ssh-copy-id {host} (or your \
|
||||
host's control panel), then try again."
|
||||
);
|
||||
}
|
||||
if has("Host key verification failed") || has("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
||||
return format!(
|
||||
"ssh doesn't know {host}'s key yet, or it changed. Connect once in a terminal with \
|
||||
ssh {host} to check and accept it."
|
||||
);
|
||||
}
|
||||
if has("Could not resolve hostname") {
|
||||
return format!("There's no computer called {host}. Check the address.");
|
||||
}
|
||||
if has("timed out") || has("Connection refused") || has("No route to host") {
|
||||
return format!("Can't reach {host}. It may be off, or its SSH port closed.");
|
||||
}
|
||||
if code == Some(127) || has("command not found") {
|
||||
return format!(
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says."
|
||||
);
|
||||
}
|
||||
if code == Some(255) {
|
||||
let said = stderr.lines().rev().find(|l| !l.trim().is_empty());
|
||||
return format!(
|
||||
"Couldn't connect to {host}{}",
|
||||
said.map_or_else(|| ".".to_string(), |s| format!(": {}", s.trim()))
|
||||
);
|
||||
}
|
||||
let said = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|l| l.strip_prefix("horizon-server: "))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("horizon-server on {host} failed in {dir}."));
|
||||
super::capitalised(&said)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remote_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_command_runs_horizon_server_in_the_folder_there() {
|
||||
let args = ssh_args(
|
||||
"mc@vps-1",
|
||||
"/srv/minecraft",
|
||||
&["config", "set", "world.motd=Hi there"],
|
||||
)
|
||||
.unwrap();
|
||||
let (line, rest) = args.split_last().unwrap();
|
||||
assert_eq!(&rest[rest.len() - 3..], ["-T", "--", "mc@vps-1"]);
|
||||
assert!(rest.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
|
||||
assert_eq!(
|
||||
line,
|
||||
"PATH=\"$HOME/.local/bin:$PATH\" exec horizon-server --dir '/srv/minecraft' 'config' 'set' \
|
||||
'world.motd=Hi there'"
|
||||
);
|
||||
// A folder in the home folder: the command starts there.
|
||||
let args = ssh_args("box", "~/servers/smp", &["start"]).unwrap();
|
||||
assert!(args.last().unwrap().contains("--dir 'servers/smp' 'start'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_remote_shell_gets_stays_one_argument_each() {
|
||||
assert_eq!(quote("plain"), "'plain'");
|
||||
assert_eq!(quote("it's"), r"'it'\''s'");
|
||||
assert_eq!(quote("$(rm -rf ~); `x`"), "'$(rm -rf ~); `x`'");
|
||||
let args = ssh_args("box", "/srv/a b", &["cmd", "say it's $HOME"]).unwrap();
|
||||
assert!(args
|
||||
.last()
|
||||
.unwrap()
|
||||
.ends_with(r"--dir '/srv/a b' 'cmd' 'say it'\''s $HOME'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_computer_name_is_taken_as_the_host() {
|
||||
for host in ["[email protected]", "vps-fra-1", "me@[2001:db8::1]"] {
|
||||
assert!(check_host(host).is_ok(), "{host}");
|
||||
}
|
||||
for host in ["", "-oProxyCommand=evil", "a b", "x\ny"] {
|
||||
assert!(check_host(host).is_err(), "{host:?}");
|
||||
assert!(ssh_args(host, "/srv", &["status"]).is_err());
|
||||
}
|
||||
assert!(ssh_args("box", "", &["status"]).is_err());
|
||||
assert!(ssh_args("box", "~", &["status"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problems_say_what_to_do() {
|
||||
let says = |code, err: &str| explain("mc@vps", "/srv/mc", Some(code), err);
|
||||
assert!(says(255, "mc@vps: Permission denied (publickey).").contains("ssh-copy-id mc@vps"));
|
||||
assert!(says(255, "Host key verification failed.").contains("accept it"));
|
||||
assert!(says(
|
||||
255,
|
||||
"ssh: Could not resolve hostname vps: Name or service not known"
|
||||
)
|
||||
.contains("no computer called"));
|
||||
assert!(says(
|
||||
255,
|
||||
"ssh: connect to host vps port 22: Connection timed out"
|
||||
)
|
||||
.contains("Can't reach"));
|
||||
assert!(says(127, "sh: 1: exec: horizon-server: not found").contains("has no horizon-server"));
|
||||
assert!(says(3, NO_SERVER).contains("no server in /srv/mc"));
|
||||
assert_eq!(
|
||||
says(
|
||||
255,
|
||||
"kex_exchange_identification: read: Connection reset by peer"
|
||||
),
|
||||
"Couldn't connect to mc@vps: kex_exchange_identification: read: Connection reset by peer"
|
||||
);
|
||||
assert_eq!(
|
||||
says(
|
||||
1,
|
||||
"horizon-server: the game is starting or stopping: try again in a moment"
|
||||
),
|
||||
"The game is starting or stopping: try again in a moment"
|
||||
);
|
||||
}
|
||||
|
||||
/// The check, with a stand-in for ssh that runs the remote line in a local shell.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_check_reads_the_status_there() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = std::env::temp_dir().join(format!("horizon-remote-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let bin = root.join("bin");
|
||||
let server = root.join("srv");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::create_dir_all(&server).unwrap();
|
||||
// ssh: the last argument is the remote line.
|
||||
let ssh = root.join("ssh");
|
||||
std::fs::write(
|
||||
&ssh,
|
||||
format!(
|
||||
"#!/bin/sh\nfor a; do line=$a; done\nPATH={}:/usr/bin:/bin; export PATH\nexec sh -c \"$line\"\n",
|
||||
bin.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let program = bin.join("horizon-server");
|
||||
std::fs::write(
|
||||
&program,
|
||||
"#!/bin/sh\necho '{\"phase\":\"running\",\"sample\":{\"game\":{\"players\":[{\"name\":\"Alex\"}],\"max_players\":20}}}'\n",
|
||||
)
|
||||
.unwrap();
|
||||
for f in [&ssh, &program] {
|
||||
std::fs::set_permissions(f, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let dir = server.to_string_lossy();
|
||||
let err = check_with(&ssh, "box", &dir).unwrap_err();
|
||||
assert!(err.contains("There's no server in"), "{err}");
|
||||
std::fs::write(server.join("server.toml"), "").unwrap();
|
||||
let status = check_with(&ssh, "box", &dir).unwrap();
|
||||
assert_eq!(status.phase, "running");
|
||||
assert_eq!(status.players[0].name, "Alex");
|
||||
assert_eq!(status.max_players, Some(20));
|
||||
std::fs::remove_file(&program).unwrap();
|
||||
let err = check_with(&ssh, "box", &dir).unwrap_err();
|
||||
assert!(err.contains("has no horizon-server"), "{err}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_written_before_remote_servers_still_read() {
|
||||
let old: super::super::Entry = serde_json::from_str(r#"{"name":"A","dir":"/x"}"#).unwrap();
|
||||
assert_eq!(old.host, None);
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("host"));
|
||||
}
|
||||
@@ -1486,7 +1486,11 @@ impl Launcher {
|
||||
if !dir.join("server.toml").is_file() {
|
||||
servers::run(&dir, &["init", "--yes"])?;
|
||||
}
|
||||
let entry = Entry { name, dir };
|
||||
let entry = Entry {
|
||||
name,
|
||||
dir,
|
||||
host: None,
|
||||
};
|
||||
servers::add(entry.clone()).map_err(Problem::plain)?;
|
||||
Ok(entry)
|
||||
})();
|
||||
|
||||
Reference in new issue
Block a user