remoting (#9680)
This PR provides some of the plumbing needed for a "remote" zed instance. The way this will work is: * From zed on your laptop you'll be able to manage a set of dev servers, each of which is identified by a token. * You'll run `zed --dev-server-token XXXX` to boot a remotable dev server. * From the zed on your laptop you'll be able to open directories and work on the projects on the remote server (exactly like collaboration works today). For now all this PR does is provide the ability for a zed instance to sign in using a "dev server token". The next steps will be: * Adding support to the collaboration protocol to instruct a dev server to "open" a directory and share it into a channel. * Adding UI to manage these servers and tokens (manually for now) Related #5347 Release Notes: - N/A --------- Co-authored-by: Nathan <nathan@zed.dev>
This commit is contained in:
parent
f56707e076
commit
cb4f868815
19 changed files with 582 additions and 296 deletions
|
@ -29,7 +29,7 @@ You can tell what is currently deployed with `./script/what-is-deployed`.
|
|||
To create a new migration:
|
||||
|
||||
```
|
||||
./script/sqlx migrate add <name>
|
||||
./script/create-migration <name>
|
||||
```
|
||||
|
||||
Migrations are run automatically on service start, so run `foreman start` again. The service will crash if the migrations fail.
|
||||
|
|
|
@ -400,3 +400,11 @@ CREATE TABLE hosted_projects (
|
|||
);
|
||||
CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id);
|
||||
CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL);
|
||||
|
||||
CREATE TABLE dev_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
||||
name TEXT NOT NULL,
|
||||
hashed_token TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id);
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE dev_servers (
|
||||
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
channel_id INT NOT NULL REFERENCES channels(id),
|
||||
name TEXT NOT NULL,
|
||||
hashed_token TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id);
|
|
@ -1,5 +1,6 @@
|
|||
use crate::{
|
||||
db::{self, AccessTokenId, Database, UserId},
|
||||
db::{self, dev_server, AccessTokenId, Database, DevServerId, UserId},
|
||||
rpc::Principal,
|
||||
AppState, Error, Result,
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
|
@ -19,11 +20,11 @@ use std::sync::OnceLock;
|
|||
use std::{sync::Arc, time::Instant};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Impersonator(pub Option<db::User>);
|
||||
|
||||
/// Validates the authorization header. This has two mechanisms, one for the ADMIN_TOKEN
|
||||
/// and one for the access tokens that we issue.
|
||||
/// Validates the authorization header and adds an Extension<Principal> to the request.
|
||||
/// Authorization: <user-id> <token>
|
||||
/// <token> can be an access_token attached to that user, or an access token of an admin
|
||||
/// or (in development) the string ADMIN:<config.api_token>.
|
||||
/// Authorization: "dev-server-token" <token>
|
||||
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
|
||||
let mut auth_header = req
|
||||
.headers()
|
||||
|
@ -37,7 +38,26 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
|
|||
})?
|
||||
.split_whitespace();
|
||||
|
||||
let user_id = UserId(auth_header.next().unwrap_or("").parse().map_err(|_| {
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
|
||||
let first = auth_header.next().unwrap_or("");
|
||||
if first == "dev-server-token" {
|
||||
let dev_server_token = auth_header.next().ok_or_else(|| {
|
||||
Error::Http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing dev-server-token token in authorization header".to_string(),
|
||||
)
|
||||
})?;
|
||||
let dev_server = verify_dev_server_token(dev_server_token, &state.db)
|
||||
.await
|
||||
.map_err(|e| Error::Http(StatusCode::UNAUTHORIZED, format!("{}", e)))?;
|
||||
|
||||
req.extensions_mut()
|
||||
.insert(Principal::DevServer(dev_server));
|
||||
return Ok::<_, Error>(next.run(req).await);
|
||||
}
|
||||
|
||||
let user_id = UserId(first.parse().map_err(|_| {
|
||||
Error::Http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing user id in authorization header".to_string(),
|
||||
|
@ -51,8 +71,6 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
|
|||
)
|
||||
})?;
|
||||
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
|
||||
// In development, allow impersonation using the admin API token.
|
||||
// Don't allow this in production because we can't tell who is doing
|
||||
// the impersonating.
|
||||
|
@ -76,18 +94,17 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
|
|||
.await?
|
||||
.ok_or_else(|| anyhow!("user {} not found", user_id))?;
|
||||
|
||||
let impersonator = if let Some(impersonator_id) = validate_result.impersonator_id {
|
||||
let impersonator = state
|
||||
if let Some(impersonator_id) = validate_result.impersonator_id {
|
||||
let admin = state
|
||||
.db
|
||||
.get_user_by_id(impersonator_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("user {} not found", impersonator_id))?;
|
||||
Some(impersonator)
|
||||
req.extensions_mut()
|
||||
.insert(Principal::Impersonated { user, admin });
|
||||
} else {
|
||||
None
|
||||
req.extensions_mut().insert(Principal::User(user));
|
||||
};
|
||||
req.extensions_mut().insert(user);
|
||||
req.extensions_mut().insert(Impersonator(impersonator));
|
||||
return Ok::<_, Error>(next.run(req).await);
|
||||
}
|
||||
}
|
||||
|
@ -213,6 +230,33 @@ pub async fn verify_access_token(
|
|||
})
|
||||
}
|
||||
|
||||
// a dev_server_token has the format <id>.<base64>. This is to make them
|
||||
// relatively easy to copy/paste around.
|
||||
pub async fn verify_dev_server_token(
|
||||
dev_server_token: &str,
|
||||
db: &Arc<Database>,
|
||||
) -> anyhow::Result<dev_server::Model> {
|
||||
let mut parts = dev_server_token.splitn(2, '.');
|
||||
let id = DevServerId(parts.next().unwrap_or_default().parse()?);
|
||||
let token = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("invalid dev server token format"))?;
|
||||
|
||||
let token_hash = hash_access_token(&token);
|
||||
let server = db.get_dev_server(id).await?;
|
||||
|
||||
if server
|
||||
.hashed_token
|
||||
.as_bytes()
|
||||
.ct_eq(token_hash.as_ref())
|
||||
.into()
|
||||
{
|
||||
Ok(server)
|
||||
} else {
|
||||
Err(anyhow!("wrong token for dev server"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use rand::thread_rng;
|
||||
|
|
|
@ -67,28 +67,29 @@ macro_rules! id_type {
|
|||
};
|
||||
}
|
||||
|
||||
id_type!(BufferId);
|
||||
id_type!(AccessTokenId);
|
||||
id_type!(BufferId);
|
||||
id_type!(ChannelBufferCollaboratorId);
|
||||
id_type!(ChannelChatParticipantId);
|
||||
id_type!(ChannelId);
|
||||
id_type!(ChannelMemberId);
|
||||
id_type!(MessageId);
|
||||
id_type!(ContactId);
|
||||
id_type!(DevServerId);
|
||||
id_type!(ExtensionId);
|
||||
id_type!(FlagId);
|
||||
id_type!(FollowerId);
|
||||
id_type!(HostedProjectId);
|
||||
id_type!(MessageId);
|
||||
id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
id_type!(ProjectCollaboratorId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(ReplicaId);
|
||||
id_type!(RoomId);
|
||||
id_type!(RoomParticipantId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(ProjectCollaboratorId);
|
||||
id_type!(ReplicaId);
|
||||
id_type!(ServerId);
|
||||
id_type!(SignupId);
|
||||
id_type!(UserId);
|
||||
id_type!(ChannelBufferCollaboratorId);
|
||||
id_type!(FlagId);
|
||||
id_type!(ExtensionId);
|
||||
id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
id_type!(HostedProjectId);
|
||||
|
||||
/// ChannelRole gives you permissions for both channels and calls.
|
||||
#[derive(
|
||||
|
|
|
@ -5,6 +5,7 @@ pub mod buffers;
|
|||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod dev_servers;
|
||||
pub mod extensions;
|
||||
pub mod hosted_projects;
|
||||
pub mod messages;
|
||||
|
|
18
crates/collab/src/db/queries/dev_servers.rs
Normal file
18
crates/collab/src/db/queries/dev_servers.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use sea_orm::EntityTrait;
|
||||
|
||||
use super::{dev_server, Database, DevServerId};
|
||||
|
||||
impl Database {
|
||||
pub async fn get_dev_server(
|
||||
&self,
|
||||
dev_server_id: DevServerId,
|
||||
) -> crate::Result<dev_server::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(dev_server::Entity::find_by_id(dev_server_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no dev server with id {}", dev_server_id))?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ pub mod channel_message;
|
|||
pub mod channel_message_mention;
|
||||
pub mod contact;
|
||||
pub mod contributor;
|
||||
pub mod dev_server;
|
||||
pub mod extension;
|
||||
pub mod extension_version;
|
||||
pub mod feature_flag;
|
||||
|
|
17
crates/collab/src/db/tables/dev_server.rs
Normal file
17
crates/collab/src/db/tables/dev_server.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use crate::db::{ChannelId, DevServerId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "dev_servers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: DevServerId,
|
||||
pub name: String,
|
||||
pub channel_id: ChannelId,
|
||||
pub hashed_token: String,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,7 @@
|
|||
use crate::{
|
||||
db::{tests::TestDb, NewUserParams, UserId},
|
||||
executor::Executor,
|
||||
rpc::{Server, ZedVersion, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
|
||||
rpc::{Principal, Server, ZedVersion, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
|
||||
AppState, Config, RateLimiter,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
|
@ -197,15 +197,20 @@ impl TestServer {
|
|||
.override_authenticate(move |cx| {
|
||||
cx.spawn(|_| async move {
|
||||
let access_token = "the-token".to_string();
|
||||
Ok(Credentials {
|
||||
Ok(Credentials::User {
|
||||
user_id: user_id.to_proto(),
|
||||
access_token,
|
||||
})
|
||||
})
|
||||
})
|
||||
.override_establish_connection(move |credentials, cx| {
|
||||
assert_eq!(credentials.user_id, user_id.0 as u64);
|
||||
assert_eq!(credentials.access_token, "the-token");
|
||||
assert_eq!(
|
||||
credentials,
|
||||
&Credentials::User {
|
||||
user_id: user_id.0 as u64,
|
||||
access_token: "the-token".into()
|
||||
}
|
||||
);
|
||||
|
||||
let server = server.clone();
|
||||
let db = db.clone();
|
||||
|
@ -230,9 +235,8 @@ impl TestServer {
|
|||
.spawn(server.handle_connection(
|
||||
server_conn,
|
||||
client_name,
|
||||
user,
|
||||
Principal::User(user),
|
||||
ZedVersion(SemanticVersion::new(1, 0, 0)),
|
||||
None,
|
||||
Some(connection_id_tx),
|
||||
Executor::Deterministic(cx.background_executor().clone()),
|
||||
))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue