mirror of
https://codeberg.org/spire/dispenser.git
synced 2026-06-03 18:14:06 +02:00
wip
This commit is contained in:
parent
83c7136354
commit
04b08706ef
9 changed files with 2069 additions and 4 deletions
90
src/cloud/mod.rs
Normal file
90
src/cloud/mod.rs
Normal 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
102
src/cloud/ssh.rs
Normal 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
259
src/cloud/vultr.rs
Normal 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
105
src/config.rs
Normal 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")
|
||||
}
|
||||
42
src/main.rs
42
src/main.rs
|
|
@ -1,3 +1,41 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
pub mod cloud;
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue