Skip to content

Commit

Permalink
feat(gateway): add custom domains table and routing
Browse files Browse the repository at this point in the history
  • Loading branch information
akrantz01 committed Nov 7, 2022
1 parent 0e8ce8b commit d88aa43
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 1 deletion.
8 changes: 8 additions & 0 deletions common/src/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ pub enum ErrorKind {
ProjectAlreadyExists,
ProjectNotReady,
ProjectUnavailable,
CustomDomainNotFound,
InvalidCustomDomain,
CustomDomainAlreadyExists,
InvalidOperation,
Internal,
NotReady,
Expand Down Expand Up @@ -75,6 +78,11 @@ impl From<ErrorKind> for ApiError {
StatusCode::BAD_REQUEST,
"a project with the same name already exists",
),
ErrorKind::InvalidCustomDomain => (StatusCode::BAD_REQUEST, "invalid custom domain"),
ErrorKind::CustomDomainNotFound => (StatusCode::NOT_FOUND, "custom domain not found"),
ErrorKind::CustomDomainAlreadyExists => {
(StatusCode::BAD_REQUEST, "custom domain already in use")
}
ErrorKind::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ErrorKind::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ErrorKind::NotReady => (StatusCode::INTERNAL_SERVER_ERROR, "service not ready"),
Expand Down
7 changes: 7 additions & 0 deletions gateway/migrations/0001_custom_domains.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS custom_domains (
fqdn TEXT PRIMARY KEY,
project_name TEXT NOT NULL,
state JSON NOT NULL
);

CREATE INDEX IF NOT EXISTS custom_domains_fqdn_project_idx ON custom_domains (fqdn, project_name);
12 changes: 12 additions & 0 deletions gateway/src/custom_domain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomDomain {
// TODO: update custom domain states, these are just placeholders for now
Creating,
Verifying,
IssuingCertificate,
Ready,
Errored,
}
61 changes: 61 additions & 0 deletions gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::io;
use std::pin::Pin;
use std::str::FromStr;

use axum::headers::{Header, HeaderName, HeaderValue, Host};
use axum::http::uri::Authority;
use axum::response::{IntoResponse, Response};
use axum::Json;
use bollard::Docker;
Expand All @@ -22,6 +24,7 @@ use tracing::error;
pub mod api;
pub mod args;
pub mod auth;
pub mod custom_domain;
pub mod project;
pub mod proxy;
pub mod service;
Expand All @@ -30,6 +33,10 @@ pub mod worker;
use crate::service::{ContainerSettings, GatewayService};

static PROJECT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-zA-Z0-9\\-_]{3,64}$").unwrap());
static CUSTOM_DOMAIN: Lazy<Regex> = Lazy::new(|| {
Regex::new("^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])*\\.[a-z]+$")
.unwrap()
});

/// Server-side errors that do not have to do with the user runtime
/// should be [`Error`]s.
Expand Down Expand Up @@ -169,6 +176,60 @@ impl<'de> Deserialize<'de> for AccountName {
}
}

#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type, Serialize)]
#[sqlx(transparent)]
pub struct Fqdn(String);

impl FromStr for Fqdn {
type Err = Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
if CUSTOM_DOMAIN.is_match(s) {
Ok(Self(s.to_string()))
} else {
Err(Error::from_kind(ErrorKind::InvalidCustomDomain))
}
}
}

impl std::fmt::Display for Fqdn {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}

impl<'de> Deserialize<'de> for Fqdn {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)?
.parse()
.map_err(|_err| todo!())
}
}

impl Header for Fqdn {
fn name() -> &'static HeaderName {
Host::name()
}

fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
where
Self: Sized,
I: Iterator<Item = &'i HeaderValue>,
{
let host = Host::decode(values)?;
Ok(Fqdn(host.hostname().to_owned()))
}

fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
let authority = Authority::from_str(&self.0).unwrap();
let host = Host::from(authority);
host.encode(values);
}
}

pub trait Context<'c>: Send + Sync {
fn docker(&self) -> &'c Docker;

Expand Down
87 changes: 86 additions & 1 deletion gateway/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ use tracing::debug;

use crate::args::StartArgs;
use crate::auth::{Key, User};
use crate::custom_domain::CustomDomain;
use crate::project::{self, Project};
use crate::worker::Work;
use crate::{AccountName, Context, Error, ErrorKind, ProjectName, Service};
use crate::{AccountName, Context, Error, ErrorKind, Fqdn, ProjectName, Service};

pub static MIGRATIONS: Migrator = sqlx::migrate!("./migrations");
static PROXY_CLIENT: Lazy<ReverseProxy<HttpConnector<GaiResolver>>> =
Expand Down Expand Up @@ -193,6 +194,16 @@ impl GatewayService {
Self { provider, db }
}

pub async fn route_fqdn(&self, req: Request<Body>) -> Result<Response<Body>, Error> {
let fqdn = req
.headers()
.typed_get::<Fqdn>()
.ok_or_else(|| Error::from(ErrorKind::CustomDomainNotFound))?;
let project_name = self.project_name_for_custom_domain(&fqdn).await?;

self.route(&project_name, req).await
}

pub async fn route(
&self,
project_name: &ProjectName,
Expand Down Expand Up @@ -439,6 +450,42 @@ impl GatewayService {
})
}

pub async fn create_custom_domain(
&self,
project_name: ProjectName,
fqdn: Fqdn,
) -> Result<CustomDomain, Error> {
let state = SqlxJson(CustomDomain::Creating);

query("INSERT INTO custom_domains (fqdn, project_name, state) VALUES (?1, ?2, ?3)")
.bind(&fqdn)
.bind(&project_name)
.bind(&state)
.execute(&self.db)
.await
.map_err(|err| {
if let Some(db_err_code) = err.as_database_error().and_then(DatabaseError::code) {
if db_err_code == "1555" {
return Error::from(ErrorKind::CustomDomainAlreadyExists);
}
}

err.into()
})?;

Ok(state.0)
}

pub async fn project_name_for_custom_domain(&self, fqdn: &Fqdn) -> Result<ProjectName, Error> {
let project_name = query("SELECT project_name FROM custom_domains WHERE fqdn = ?1")
.bind(fqdn)
.fetch_optional(&self.db)
.await?
.map(|row| row.try_get("project_name").unwrap())
.ok_or_else(|| Error::from(ErrorKind::CustomDomainNotFound))?;
Ok(project_name)
}

fn context(&self) -> GatewayContext {
self.provider.context()
}
Expand Down Expand Up @@ -587,4 +634,42 @@ pub mod tests {

Ok(())
}

#[tokio::test]
async fn service_create_find_custom_domain() -> anyhow::Result<()> {
let world = World::new().await;
let svc = Arc::new(GatewayService::init(world.args(), world.fqdn(), world.pool()).await);

let account: AccountName = "neo".parse().unwrap();
let project_name: ProjectName = "matrix".parse().unwrap();
let domain: Fqdn = "neo.the.matrix".parse().unwrap();

svc.create_user(account.clone()).await.unwrap();

assert_err_kind!(
svc.project_name_for_custom_domain(&domain).await,
ErrorKind::CustomDomainNotFound
);

let _ = svc
.create_project(project_name.clone(), account.clone())
.await
.unwrap();

svc.create_custom_domain(project_name.clone(), domain.clone())
.await
.unwrap();

let project = svc.project_name_for_custom_domain(&domain).await.unwrap();

assert_eq!(project, project_name);

assert_err_kind!(
svc.create_custom_domain(project_name.clone(), domain.clone())
.await,
ErrorKind::CustomDomainAlreadyExists
);

Ok(())
}
}

0 comments on commit d88aa43

Please sign in to comment.