This commit is contained in:
Robin Appelman 2021-03-27 01:13:11 +01:00
commit 04b08706ef
9 changed files with 2069 additions and 4 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
config.toml

1446
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,16 @@ version = "0.1.0"
authors = ["Robin Appelman <robin@icewind.nl>"] authors = ["Robin Appelman <robin@icewind.nl>"]
edition = "2018" edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
toml = "0.5"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
camino = "1"
petname = "1"
thrussh = "0.32"
thrussh-keys = "0.20"
futures-util = "0.3"

14
config.example.toml Normal file
View file

@ -0,0 +1,14 @@
[server]
rcon = "xxxx"
password = "xxxx"
demostf_key = "xxxx" # optional
logstf_key = "xxxx" # optional
config_league = "etf2l" # optional, defaults to etf2l
config_mode = "6v6" # optional, defaults to 6v6
name = "MyCoolServer" # optional, defaults to Spire
tv_name = "MyCoolSTV" # optional, defaults to SpireTV
[vultr]
api_key = "xxx"
region = "ams" # see https://api.vultr.com/v2/regions for a list of regions
plan = "vc2-1c-2gb" # optional, defaults to vc2-1c-2gb (2GB, $10/month) see https://api.vultr.com/v2/plans for a lis of plan

90
src/cloud/mod.rs Normal file
View file

@ -0,0 +1,90 @@
mod ssh;
pub mod vultr;
use crate::cloud::ssh::SshError;
use crate::config::ServerConfig;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use std::net::IpAddr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CloudError {
#[error("Invalid credentials")]
Unauthorized,
#[error("Specified server not found")]
ServerNotFound,
#[error("Network error: {0}")]
Network(#[from] NetworkError),
#[error("Network response from server: {0}")]
InvalidResponse(#[from] ResponseError),
#[error("Server boot timed out")]
StartTimeout,
#[error("Error while trying to connect trough ssh: {0}")]
Ssh(#[from] SshError),
}
/// Intentionally opaque error
#[derive(Debug, Error)]
#[error("{0}")]
pub struct NetworkError(reqwest::Error);
impl CloudError {
fn from_status_code(status: StatusCode) -> Result<()> {
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return Err(CloudError::Unauthorized);
}
Ok(())
}
}
/// Intentionally opaque error
#[derive(Debug, Error)]
pub enum ResponseError {
#[error("{0}")]
JSON(reqwest::Error),
#[error("Unexpected response {0}")]
Other(String),
}
impl From<reqwest::Error> for NetworkError {
fn from(e: reqwest::Error) -> Self {
NetworkError(e)
}
}
impl From<reqwest::Error> for ResponseError {
fn from(e: reqwest::Error) -> Self {
ResponseError::JSON(e)
}
}
pub type Result<T, E = CloudError> = std::result::Result<T, E>;
#[async_trait]
pub trait Cloud {
/// List all running servers on this cloud
async fn list(&self) -> Result<Vec<Server>>;
/// Create a new server with the given parameter
async fn spawn(&self) -> Result<Created>;
/// Destroy a given server
async fn kill(&self, id: &str) -> Result<()>;
/// Wait until the server has an ip
async fn wait_for_ip(&self, id: &str) -> Result<Server>;
/// Setup the tf2 server on the instance
async fn setup(&self, id: &str, password: &str, config: &ServerConfig) -> Result<Server>;
}
#[derive(Debug)]
pub struct Server {
pub id: String,
pub created: DateTime<Utc>,
pub ip: IpAddr,
}
#[derive(Debug)]
pub struct Created {
pub id: String,
pub password: String,
}

102
src/cloud/ssh.rs Normal file
View file

@ -0,0 +1,102 @@
use futures_util::future::{self};
use std::net::IpAddr;
use std::sync::Arc;
use thiserror::Error;
use thrussh::client::Handle;
use thrussh::*;
use thrussh_keys::key::PublicKey;
struct Client {}
#[derive(Debug, Error)]
pub enum SshError {
#[error(transparent)]
Other(#[from] SshErrorImpl),
#[error("Invalid credentials")]
Unauthorized,
#[error("Connection timeout")]
ConnectionTimeout,
#[error("Disconnected by server")]
Disconnected,
}
#[derive(Debug, Error)]
#[error(transparent)]
pub struct SshErrorImpl(thrussh::Error);
impl From<thrussh::Error> for SshError {
fn from(e: thrussh::Error) -> Self {
match e {
thrussh::Error::Disconnect => SshError::Disconnected,
thrussh::Error::HUP => SshError::Disconnected,
thrussh::Error::ConnectionTimeout => SshError::ConnectionTimeout,
thrussh::Error::IO(io) if io.raw_os_error() == Some(110) => SshError::ConnectionTimeout,
e => SshError::Other(SshErrorImpl(e)),
}
}
}
impl client::Handler for Client {
type Error = SshError;
type FutureBool = future::Ready<Result<(Self, bool), SshError>>;
type FutureUnit = future::Ready<Result<(Self, client::Session), SshError>>;
fn finished_bool(self, b: bool) -> Self::FutureBool {
future::ready(Ok((self, b)))
}
fn finished(self, session: client::Session) -> Self::FutureUnit {
future::ready(Ok((self, session)))
}
fn check_server_key(self, server_public_key: &PublicKey) -> Self::FutureBool {
println!("check_server_key: {:?}", server_public_key);
self.finished_bool(true)
}
fn channel_open_confirmation(
self,
channel: ChannelId,
_max_packet_size: u32,
_window_size: u32,
session: client::Session,
) -> Self::FutureUnit {
println!("channel_open_confirmation: {:?}", channel);
self.finished(session)
}
fn data(self, channel: ChannelId, data: &[u8], session: client::Session) -> Self::FutureUnit {
println!(
"data on channel {:?}: {:?}",
channel,
std::str::from_utf8(data)
);
self.finished(session)
}
}
pub struct SshSession {
handle: Handle<Client>,
}
impl SshSession {
pub async fn open(ip: IpAddr, password: &str) -> Result<Self, SshError> {
let config = thrussh::client::Config::default();
let config = Arc::new(config);
let sh = Client {};
let mut handle = thrussh::client::connect(config, (ip, 22), sh).await?;
if handle.authenticate_password("root", password).await? {
Ok(SshSession { handle })
} else {
Err(SshError::Unauthorized)
}
}
pub async fn exec<S: Into<String>>(&mut self, cmd: S) -> Result<(), SshError> {
let mut channel = self.handle.channel_open_session().await?;
println!("exec");
channel.exec(true, cmd).await?;
println!("exec'd");
if let Some(msg) = channel.wait().await {
println!("{:?}", msg)
}
Ok(())
}
}

259
src/cloud/vultr.rs Normal file
View file

@ -0,0 +1,259 @@
use crate::cloud::ssh::{SshError, SshSession};
use crate::cloud::{Cloud, CloudError, Created, NetworkError, ResponseError, Result, Server};
use crate::config::ServerConfig;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use petname::petname;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::time::Duration;
use tokio::time::{sleep, timeout};
pub struct Vultr {
region: String,
plan: String,
token: String,
client: Client,
}
impl Vultr {
pub fn new(token: String, region: String, plan: String) -> Self {
Vultr {
token,
region,
plan,
client: Client::default(),
}
}
}
#[async_trait]
impl Cloud for Vultr {
async fn list(&self) -> Result<Vec<Server>> {
let response = self
.client
.get("https://api.vultr.com/v2/instances")
.bearer_auth(&self.token)
.send()
.await
.map_err(NetworkError::from)?;
CloudError::from_status_code(response.status())?;
let response: VultrListResponse = response.json().await.map_err(ResponseError::from)?;
Ok(response
.instances
.into_iter()
.filter(|instance| instance.tag == "spire")
.map(Server::from)
.collect())
}
async fn spawn(&self) -> Result<Created> {
let response = self
.client
.post("https://api.vultr.com/v2/instances")
.bearer_auth(&self.token)
.json(&VultrCreateParams {
region: self.region.as_str(),
plan: self.plan.as_str(),
tag: "spire",
label: petname(2, "-"),
app_id: self.get_app_id("docker").await?,
})
.send()
.await
.map_err(NetworkError::from)?;
CloudError::from_status_code(response.status())?;
if response.status().is_success() {
let response: VultrCreateResponse =
response.json().await.map_err(ResponseError::from)?;
Ok(response.instance.into())
} else {
Err(ResponseError::Other(response.text().await.map_err(NetworkError::from)?).into())
}
}
async fn kill(&self, _id: &str) -> Result<()> {
todo!()
}
async fn wait_for_ip(&self, id: &str) -> Result<Server> {
let instance = loop {
let instance = self.get_instance(id).await?;
if !instance.main_ip.is_unspecified() {
break instance;
} else {
sleep(Duration::from_millis(500)).await;
}
};
Ok(instance.into())
}
async fn setup(
&self,
id: &str,
password: &str,
config: &ServerConfig,
) -> Result<Server, CloudError> {
let server = self.wait_for_ip(id).await?;
let ip = server.ip;
let mut ssh = timeout(Duration::from_secs(5 * 60), async move {
loop {
sleep(Duration::from_secs(1)).await;
match SshSession::open(ip, password).await {
Ok(ssh) => return Ok(ssh),
Err(SshError::ConnectionTimeout) => {}
Err(e) => return Err(e),
}
}
})
.await
.map_err(|_| CloudError::StartTimeout)??;
println!("connected");
ssh.exec("docker pull spiretf/docker-spire-server").await?;
println!("pulled");
ssh.exec(format!(
"docker run --name spire -d \
-e NAME={name} -e TV_NAME={tv_name} -e PASSWORD={password} -e RCON_PASSWORD={rcon} \
-e DEMOSTF_APIKEY={demostf} -e LOGSTF_APIKEY={logstf} \
-e CONFIG_LEAGUE={league} -e CONFIG_MODE={mode} \
-p 27015:27015 -p 27021:27021 -p 27015:27015/udp -p 27020:27020/udp -p 27025:27025 \
-p 28015:27015 -p 28015:27015/udp -p 27115:27015 -p 27115:27015/udp -p 27215:27015 \
-p 27215:27015/udp -p 27315:27015 -p 27315:27015/udp -p 27415:27015 -p 27415:27015/udp \
-p 27515:27015 -p 27515:27015/udp -p 27615:27015 -p 27615:27015/udp -p 27715:27015 \
-p 27715:27015/udp -p 27815:27015 -p 27815:27015/udp -p 27915:27015 -p 27915:27015/udp \
{image}
",
name = config.name,
tv_name = config.tv_name,
password = config.password,
rcon = config.rcon,
demostf = config
.demostf_key
.as_ref()
.map(String::as_str)
.unwrap_or_default(),
logstf = config
.logstf_key
.as_ref()
.map(String::as_str)
.unwrap_or_default(),
league = config.config_league,
mode = config.config_mode,
image = config.image
))
.await?;
Ok(server)
}
}
impl Vultr {
async fn get_app_id(&self, short_name: &str) -> Result<u16> {
let response = self
.client
.get("https://api.vultr.com/v2/applications")
.send()
.await
.map_err(NetworkError::from)?;
let response: VultrApplicationsResponse =
response.json().await.map_err(ResponseError::from)?;
Ok(response
.applications
.into_iter()
.find_map(|application| (application.short_name == short_name).then(|| application.id))
.ok_or_else(|| {
ResponseError::Other(format!("Application \"{}\" not found", short_name))
})?)
}
async fn get_instance(&self, id: &str) -> Result<VultrInstanceResponse> {
let response = self
.client
.get(format!("https://api.vultr.com/v2/instances/{}", id))
.bearer_auth(&self.token)
.send()
.await
.map_err(NetworkError::from)?;
CloudError::from_status_code(response.status())?;
let response: VultrGetResponse = response.json().await.map_err(ResponseError::from)?;
Ok(response.instance)
}
}
#[derive(Serialize)]
struct VultrCreateParams<'a> {
region: &'a str,
plan: &'a str,
tag: &'a str,
label: String,
app_id: u16,
}
#[derive(Debug, Deserialize)]
struct VultrListResponse {
instances: Vec<VultrInstanceResponse>,
}
#[derive(Debug, Deserialize)]
struct VultrGetResponse {
instance: VultrInstanceResponse,
}
#[derive(Debug, Deserialize)]
struct VultrCreateResponse {
instance: VultrCreatedInstanceResponse,
}
#[derive(Debug, Deserialize)]
struct VultrInstanceResponse {
id: String,
os: String,
ram: u64,
main_ip: IpAddr,
region: String,
vcpu_count: u16,
date_created: DateTime<Utc>,
tag: String,
}
#[derive(Debug, Deserialize)]
struct VultrCreatedInstanceResponse {
id: String,
default_password: String,
}
impl From<VultrInstanceResponse> for Server {
fn from(instance: VultrInstanceResponse) -> Self {
Server {
id: instance.id,
created: instance.date_created,
ip: instance.main_ip,
}
}
}
impl From<VultrCreatedInstanceResponse> for Created {
fn from(instance: VultrCreatedInstanceResponse) -> Self {
Created {
id: instance.id,
password: instance.default_password,
}
}
}
#[derive(Debug, Deserialize)]
struct VultrApplicationsResponse {
applications: Vec<VultrApplicationResponse>,
}
#[derive(Debug, Deserialize)]
struct VultrApplicationResponse {
id: u16,
short_name: String,
}

105
src/config.rs Normal file
View file

@ -0,0 +1,105 @@
use crate::cloud::vultr::Vultr;
use crate::cloud::Cloud;
use camino::Utf8PathBuf;
use serde::Deserialize;
use std::fs::read_to_string;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to open \"{0}\"")]
Open(Utf8PathBuf),
#[error("Malformed toml: {0}")]
Toml(#[from] TomlError),
#[error("No cloud provider configured")]
NoProvider,
}
/// Intentionally opaque error
#[derive(Debug, Error)]
#[error("{0}")]
pub struct TomlError(toml::de::Error);
impl From<toml::de::Error> for TomlError {
fn from(e: toml::de::Error) -> Self {
TomlError(e)
}
}
#[derive(Deserialize, Debug)]
pub struct Config {
pub vultr: Option<VultrConfig>,
pub server: ServerConfig,
}
impl Config {
pub fn from_file<P: AsRef<Path> + Into<Utf8PathBuf>>(path: P) -> Result<Self, ConfigError> {
let content = read_to_string(path.as_ref()).map_err(|_| ConfigError::Open(path.into()))?;
Ok(toml::from_str(&content).map_err(TomlError::from)?)
}
pub fn cloud(&self) -> Result<Box<dyn Cloud>, ConfigError> {
if let Some(vultr) = &self.vultr {
Ok(Box::new(Vultr::new(
vultr.api_key.clone(),
vultr.region.clone(),
vultr.plan.clone(),
)))
} else {
Err(ConfigError::NoProvider)
}
}
}
#[derive(Deserialize, Debug)]
pub struct ServerConfig {
pub rcon: String,
pub password: String,
#[serde(default = "server_default_image")]
pub image: String,
pub demostf_key: Option<String>,
pub logstf_key: Option<String>,
#[serde(default = "server_default_league")]
pub config_league: String,
#[serde(default = "server_default_mode")]
pub config_mode: String,
#[serde(default = "server_default_name")]
pub name: String,
#[serde(default = "server_default_tv_name")]
pub tv_name: String,
}
fn server_default_image() -> String {
String::from("spiretf/docker-spire-server")
}
fn server_default_name() -> String {
String::from("Spire")
}
fn server_default_tv_name() -> String {
String::from("SpireTV")
}
fn server_default_league() -> String {
String::from("etf2l")
}
fn server_default_mode() -> String {
String::from("6v6")
}
#[derive(Deserialize, Debug)]
pub struct VultrConfig {
pub api_key: String,
/// See https://api.vultr.com/v2/regions for a list of plans
pub region: String,
/// See https://api.vultr.com/v2/plans for a list of plans
#[serde(default = "vultr_default_plan")]
pub plan: String,
}
fn vultr_default_plan() -> String {
String::from("vc2-1c-2gb")
}

View file

@ -1,3 +1,41 @@
fn main() { pub mod cloud;
println!("Hello, world!"); pub mod config;
use crate::cloud::CloudError;
use crate::config::{Config, ConfigError};
use std::env::args;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Error while interacting with cloud provider: {0}")]
Cloud(#[from] CloudError),
#[error("Error while loading configuration: {0}")]
Config(#[from] ConfigError),
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let mut args = args();
let bin = args.next().unwrap();
let config = match args.next() {
Some(file) => Config::from_file(file)?,
None => {
eprintln!("Usage {} <config.toml>", bin);
return Ok(());
}
};
let cloud = config.cloud()?;
let created = dbg!(cloud.spawn().await?);
let server = cloud.wait_for_ip(&created.id).await?;
println!("IP: {}", server.ip);
println!("Password: {}", created.password);
dbg!(
cloud
.setup(&created.id, &created.password, &config.server)
.await?
);
Ok(())
} }