Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
# the sdk-manifests on windows-latest are messed up, so we need to update them
dotnet workload config --update-mode workload-set
dotnet workload update
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
if: runner.os == 'Windows'
- name: Run smoketests
run: python -m smoketests ${{ matrix.smoketest_args }}
- name: Stop containers (Linux)
Expand Down
36 changes: 36 additions & 0 deletions crates/client-api-messages/src/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,42 @@ pub enum InsertDomainResult {
OtherError(String),
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum SetDomainsResult {
Success,

/// The top level domain for the database name is registered, but the identity that you provided does
/// not have permission to insert the given database name. For example:
///
/// - `clockworklabs/bitcraft`
///
/// If you were trying to insert this database name, but the tld `clockworklabs` is
/// owned by an identity other than the identity that you provided, then you will receive
/// this error.
///
/// In order to set the domains for a database, you must also be the owner of that database.
PermissionDenied {
domain: DomainName,
},

/// Workaround for cloud, which can't extract the exact failing domain from
/// reducer errors.
PermissionDeniedOnAny {
domains: Box<[DomainName]>,
},

/// The database name or identity you provided does not exist.
DatabaseNotFound,

/// The caller doesn't own the database.
NotYourDatabase {
database: Identity,
},

/// Some unspecified error occurred.
OtherError(String),
}

#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PublishOp {
Expand Down
29 changes: 28 additions & 1 deletion crates/client-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use spacetimedb::identity::{AuthCtx, Identity};
use spacetimedb::json::client_api::StmtResultJson;
use spacetimedb::messages::control_db::{Database, HostType, Node, Replica};
use spacetimedb::sql;
use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, RegisterTldResult, Tld};
use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, RegisterTldResult, SetDomainsResult, Tld};
use spacetimedb_lib::ProductTypeElement;
use spacetimedb_paths::server::ModuleLogsDir;
use tokio::sync::watch;
Expand Down Expand Up @@ -231,6 +231,22 @@ pub trait ControlStateWriteAccess: Send + Sync {
domain: &DomainName,
database_identity: &Identity,
) -> anyhow::Result<InsertDomainResult>;

/// Replace all dns records pointing to `database_identity` with `domain_names`.
///
/// All existing names in the database and in `domain_names` must be
/// owned by `owner_identity` (i.e. their TLD must belong to `owner_identity`).
///
/// The `owner_identity` is typically also the owner of the database.
///
/// Note that passing an empty slice is legal, and will just remove any
/// existing dns records.
async fn replace_dns_records(
&self,
database_identity: &Identity,
owner_identity: &Identity,
domain_names: &[DomainName],
) -> anyhow::Result<SetDomainsResult>;
}

impl<T: ControlStateReadAccess + ?Sized> ControlStateReadAccess for Arc<T> {
Expand Down Expand Up @@ -316,6 +332,17 @@ impl<T: ControlStateWriteAccess + ?Sized> ControlStateWriteAccess for Arc<T> {
) -> anyhow::Result<InsertDomainResult> {
(**self).create_dns_record(identity, domain, database_identity).await
}

async fn replace_dns_records(
&self,
database_identity: &Identity,
owner_identity: &Identity,
domain_names: &[DomainName],
) -> anyhow::Result<SetDomainsResult> {
(**self)
.replace_dns_records(database_identity, owner_identity, domain_names)
.await
}
}

#[async_trait]
Expand Down
60 changes: 59 additions & 1 deletion crates/client-api/src/routes/database.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::str::FromStr;
use std::time::Duration;

use crate::auth::{
Expand All @@ -23,7 +24,7 @@ use spacetimedb::host::ReducerOutcome;
use spacetimedb::host::UpdateDatabaseResult;
use spacetimedb::identity::Identity;
use spacetimedb::messages::control_db::{Database, HostType};
use spacetimedb_client_api_messages::name::{self, DatabaseName, PublishOp, PublishResult};
use spacetimedb_client_api_messages::name::{self, DatabaseName, DomainName, PublishOp, PublishResult};
use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9;
use spacetimedb_lib::identity::AuthCtx;
use spacetimedb_lib::sats;
Expand Down Expand Up @@ -629,6 +630,59 @@ pub async fn add_name<S: ControlStateDelegate>(
Ok((code, axum::Json(response)))
}

#[derive(Deserialize)]
pub struct SetNamesParams {
name_or_identity: NameOrIdentity,
}

pub async fn set_names<S: ControlStateDelegate>(
State(ctx): State<S>,
Path(SetNamesParams { name_or_identity }): Path<SetNamesParams>,
Extension(auth): Extension<SpacetimeAuth>,
names: axum::Json<Vec<String>>,
) -> axum::response::Result<impl IntoResponse> {
let validated_names = names
.0
.into_iter()
.map(|s| DatabaseName::from_str(&s).map(DomainName::from).map_err(|e| (s, e)))
.collect::<Result<Vec<_>, _>>()
.map_err(|(input, e)| (StatusCode::BAD_REQUEST, format!("Error parsing `{input}`: {e}")))?;

let database_identity = name_or_identity.resolve(&ctx).await?;

let database = ctx.get_database_by_identity(&database_identity).map_err(log_and_500)?;
let Some(database) = database else {
return Ok((
StatusCode::NOT_FOUND,
axum::Json(name::SetDomainsResult::DatabaseNotFound),
));
};

if database.owner_identity != auth.identity {
return Ok((
StatusCode::UNAUTHORIZED,
axum::Json(name::SetDomainsResult::NotYourDatabase {
database: database.database_identity,
}),
));
}

let response = ctx
.replace_dns_records(&database_identity, &database.owner_identity, &validated_names)
.await
.map_err(log_and_500)?;
let status = match response {
name::SetDomainsResult::Success => StatusCode::OK,
name::SetDomainsResult::PermissionDenied { .. }
| name::SetDomainsResult::PermissionDeniedOnAny { .. }
| name::SetDomainsResult::NotYourDatabase { .. } => StatusCode::UNAUTHORIZED,
name::SetDomainsResult::DatabaseNotFound => StatusCode::NOT_FOUND,
name::SetDomainsResult::OtherError(_) => StatusCode::INTERNAL_SERVER_ERROR,
};

Ok((status, axum::Json(response)))
}

/// This struct allows the edition to customize `/database` routes more meticulously.
pub struct DatabaseRoutes<S> {
/// POST /database
Expand All @@ -643,6 +697,8 @@ pub struct DatabaseRoutes<S> {
pub names_get: MethodRouter<S>,
/// POST: /database/:name_or_identity/names
pub names_post: MethodRouter<S>,
/// PUT: /database/:name_or_identity/names
pub names_put: MethodRouter<S>,
/// GET: /database/:name_or_identity/identity
pub identity_get: MethodRouter<S>,
/// GET: /database/:name_or_identity/subscribe
Expand Down Expand Up @@ -670,6 +726,7 @@ where
db_delete: delete(delete_database::<S>),
names_get: get(get_names::<S>),
names_post: post(add_name::<S>),
names_put: put(set_names::<S>),
identity_get: get(get_identity::<S>),
subscribe_get: get(handle_websocket::<S>),
call_reducer_post: post(call::<S>),
Expand All @@ -691,6 +748,7 @@ where
.route("/", self.db_delete)
.route("/names", self.names_get)
.route("/names", self.names_post)
.route("/names", self.names_put)
.route("/identity", self.identity_get)
.route("/subscribe", self.subscribe_get)
.route("/call/:reducer", self.call_reducer_post)
Expand Down
107 changes: 106 additions & 1 deletion crates/standalone/src/control_db.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use anyhow::Context;
use sled::transaction::{
self, ConflictableTransactionError, ConflictableTransactionResult, TransactionError, TransactionResult,
Transactional, TransactionalTree,
};
use spacetimedb::energy;
use spacetimedb::identity::Identity;
use spacetimedb::messages::control_db::{Database, EnergyBalance, Node, Replica};

use spacetimedb_client_api_messages::name::{
DomainName, DomainParsingError, InsertDomainResult, RegisterTldResult, Tld, TldRef,
DomainName, DomainParsingError, InsertDomainResult, RegisterTldResult, SetDomainsResult, Tld, TldRef,
};
use spacetimedb_lib::bsatn;
use spacetimedb_paths::standalone::ControlDbDir;
Expand Down Expand Up @@ -180,6 +184,107 @@ impl ControlDb {
})
}

/// Replace all domains pointing to `database_identity` with `domain_names`.
///
/// That is, delete all existing names pointing to `database_identity`, then
/// create all `domain_names`, pointing to `database_identity`.
///
/// All existing names in the database and in `domain_names` must be
/// owned by `owner_identity`, i.e. their TLD must belong to `owner_identity`.
///
/// The `owner_identity` is typically also the owner of the database.
///
/// The operation is atomic -- either all `domain_names` are created and
/// existing ones deleted, or none.
pub fn spacetime_replace_domains(
&self,
database_identity: &Identity,
owner_identity: &Identity,
domain_names: &[DomainName],
) -> Result<SetDomainsResult> {
let database_identity_bytes = database_identity.to_byte_array();

let dns_tree = self.db.open_tree("dns")?;
let rev_tree = self.db.open_tree("reverse_dns")?;
let tld_tree = self.db.open_tree("top_level_domains")?;

/// Abort transaction with a user error.
#[derive(Debug)]
enum AbortWith {
Domain(SetDomainsResult),
Database(Error),
}

/// Decode the slice into a `Vec<DomainName>`.
/// Returns a transaction abort if decoding fails.
fn decode_domain_names(ivec: &[u8]) -> ConflictableTransactionResult<Vec<DomainName>, AbortWith> {
serde_json::from_slice(ivec).map_err(|e| {
log::error!("Control database corruption: invalid domain set in `reverse_dns` tree: {e}");
ConflictableTransactionError::Abort(AbortWith::Database(e.into()))
})
}

/// Find the owner of the `domain`'s TLD, if there is one.
/// Returns a transaction abort if the owner could not be decoded into
/// an [`Identity`].
fn domain_owner(
tlds: &TransactionalTree,
domain: &DomainName,
) -> ConflictableTransactionResult<Option<Identity>, AbortWith> {
tlds.get(domain.tld().to_lowercase().as_bytes())?
.as_ref()
.map(identity_from_le_ivec)
.transpose()
.map_err(|e| ConflictableTransactionError::Abort(AbortWith::Database(e)))
}

let trees = (&dns_tree, &rev_tree, &tld_tree);
let result: TransactionResult<(), AbortWith> =
Transactional::transaction(&trees, |(dns_tx, rev_tx, tld_tx)| {
// Remove all existing names.
if let Some(value) = rev_tx.get(database_identity_bytes)? {
for domain in decode_domain_names(&value)? {
if let Some(ref owner) = domain_owner(tld_tx, &domain)? {
if owner != owner_identity {
transaction::abort(AbortWith::Domain(SetDomainsResult::PermissionDenied {
domain: domain.clone(),
}))?;
}
}
dns_tx.remove(domain.to_lowercase().as_bytes())?;
}
rev_tx.remove(&database_identity_bytes)?;
}

// Insert the new names.
for domain in domain_names {
if let Some(ref owner) = domain_owner(tld_tx, domain)? {
if owner != owner_identity {
transaction::abort(AbortWith::Domain(SetDomainsResult::PermissionDenied {
domain: domain.clone(),
}))?;
}
}
tld_tx.insert(domain.tld().to_lowercase().as_bytes(), &owner_identity.to_byte_array())?;
dns_tx.insert(domain.to_lowercase().as_bytes(), &database_identity_bytes)?;
}
rev_tx.insert(&database_identity_bytes, serde_json::to_vec(domain_names).unwrap())?;

Ok::<_, ConflictableTransactionError<AbortWith>>(())
});

match result {
Ok(()) => Ok(SetDomainsResult::Success),
Err(e) => match e {
TransactionError::Storage(e) => Err(Error::Database(e)),
TransactionError::Abort(abort) => match abort {
AbortWith::Database(e) => Err(e),
AbortWith::Domain(res) => Ok(res),
},
},
}
}

/// Inserts a top level domain that will be owned by `owner_identity`.
///
/// # Arguments
Expand Down
26 changes: 25 additions & 1 deletion crates/standalone/src/control_db/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,31 @@ fn test_domain() -> anyhow::Result<()> {
reverse_lookup.first().map(ToString::to_string),
Some(domain.to_string())
);
assert_eq!(reverse_lookup, vec![domain]);
assert_eq!(reverse_lookup, vec![domain.clone()]);

// We can remove the domain records for Alice's database
let deleted = cdb.spacetime_replace_domains(&addr, &ALICE, &[]);
assert!(matches!(deleted, Ok(SetDomainsResult::Success)));

// The domain records are gone
let registered_addr = cdb.spacetime_dns(domain.as_ref())?;
assert_eq!(registered_addr, None);

// Reverse DNS should yield empty
let reverse_lookup = cdb.spacetime_reverse_dns(&addr)?;
assert_eq!(reverse_lookup, vec![]);

// Bob cannot register the TLD
let unauthorized = cdb
.spacetime_insert_domain(&addr, "this/is/bob".parse()?, *BOB, true)
.unwrap();
assert!(matches!(unauthorized, InsertDomainResult::PermissionDenied { .. }));

// Alice can add the domain back
let addr = Identity::ZERO;
let res = cdb.spacetime_insert_domain(&addr, domain.clone(), *ALICE, true)?;
assert!(matches!(res, InsertDomainResult::Success { .. }));

let _ = tmp.close().ok(); // force tmp to not be dropped until here

Ok(())
Expand Down
13 changes: 12 additions & 1 deletion crates/standalone/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use spacetimedb::messages::control_db::{Database, Node, Replica};
use spacetimedb::worker_metrics::WORKER_METRICS;
use spacetimedb_client_api::auth::{self, LOCALHOST};
use spacetimedb_client_api::{Host, NodeDelegate};
use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, RegisterTldResult, Tld};
use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, RegisterTldResult, SetDomainsResult, Tld};
use spacetimedb_paths::server::{ModuleLogsDir, PidFile, ServerDataDir};
use spacetimedb_paths::standalone::StandaloneDataDirExt;
use std::sync::Arc;
Expand Down Expand Up @@ -364,6 +364,17 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
.control_db
.spacetime_insert_domain(database_identity, domain.clone(), *owner_identity, true)?)
}

async fn replace_dns_records(
&self,
database_identity: &Identity,
owner_identity: &Identity,
domain_names: &[DomainName],
) -> anyhow::Result<SetDomainsResult> {
Ok(self
.control_db
.spacetime_replace_domains(database_identity, owner_identity, domain_names)?)
}
}

impl StandaloneEnv {
Expand Down
Loading
Loading