Merge pull request #10166 from hildlabs/remote-servers-ssh

Launcher: a server on another computer, run over SSH
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:25:59 +00:00
commit c932d4bec1
4 files changed
+352 -8

No files matched your search

+38 -7
View File
@@ -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)
}
+178
View File
@@ -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(&quote(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;
+131
View File
@@ -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"));
}
+5 -1
View File
@@ -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)
})();