Repository: web3infra-foundation/mega (language: rust)

Issue:
[SSH Server] Replace Server-Side Key Generation and Storage with RustyVault
The certificates required by the current Mega SSH implementation are generated directly. This process should be replaced by utilizing RustyVault for key generation and storage.


Discussion / hints:

Fix patch:
diff --git a/common/src/config.rs b/common/src/config.rs
index 0a54ff69..be1833c4 100644
--- a/common/src/config.rs
+++ b/common/src/config.rs
@@ -13,7 +13,6 @@ pub struct Config {
     pub base_dir: PathBuf,
     pub log: LogConfig,
     pub database: DbConfig,
-    pub ssh: SshConfig,
     pub storage: StorageConfig,
     pub monorepo: MonoConfig,
     pub pack: PackConfig,
@@ -188,19 +187,6 @@ impl Default for DbConfig {
     }
 }
 
-#[derive(Serialize, Deserialize, Debug, Clone)]
-pub struct SshConfig {
-    pub ssh_key_path: PathBuf,
-}
-
-impl Default for SshConfig {
-    fn default() -> Self {
-        Self {
-            ssh_key_path: PathBuf::from("/tmp/.mega/ssh"),
-        }
-    }
-}
-
 #[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct StorageConfig {
     pub obs_access_key: String,
@@ -223,12 +209,14 @@ impl Default for StorageConfig {
 #[derive(Serialize, Deserialize, Debug, Clone)]
 pub struct MonoConfig {
     pub import_dir: PathBuf,
+    pub disable_http_push: bool,
 }
 
 impl Default for MonoConfig {
     fn default() -> Self {
         Self {
             import_dir: PathBuf::from("/third-part"),
+            disable_http_push: false,
         }
     }
 }
diff --git a/docker/config.toml b/docker/config.toml
index 4c4023cb..f9483ee5 100644
--- a/docker/config.toml
+++ b/docker/config.toml
@@ -36,9 +36,6 @@ min_connection = 8
 sqlx_logging = false
 
 
-[ssh]
-ssh_key_path = "${base_dir}/etc/ssh"
-
 [storage]
 
 obs_access_key = ""
@@ -56,6 +53,7 @@ obs_endpoint = "https://obs.cn-east-3.myhuaweicloud.com"
 ## Mega treats files under this directory as import repo and other directories as monorepo
 import_dir = "/third-part"
 
+disable_http_push = false
 
 [pack]
 # The maximum memory used by decode, Unit is GB
diff --git a/docker/mono-pg-dockerfile b/docker/mono-pg-dockerfile
index a28d02bd..4804d119 100644
--- a/docker/mono-pg-dockerfile
+++ b/docker/mono-pg-dockerfile
@@ -17,6 +17,6 @@ EXPOSE 5432
 
 # Add the database initialization script to the container
 # When the container starts, PostgreSQL will automatically execute all .sql files in the docker-entrypoint-initdb.d/ directory
-COPY ./sql/postgres/pg_20240910__init.sql /docker-entrypoint-initdb.d/
+COPY ./sql/postgres/pg_20240912__init.sql /docker-entrypoint-initdb.d/
 
 CMD ["postgres"]
diff --git a/docs/development.md b/docs/development.md
index 06a2c6d8..4243fc2d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -273,9 +273,6 @@ min_connection = 16
 sqlx_logging = false
 
 
-[ssh]
-ssh_key_path = "${base_dir}/ssh"
-
 [storage]
 
 obs_access_key = ""
@@ -293,6 +290,7 @@ obs_endpoint = "https://obs.cn-east-3.myhuaweicloud.com"
 ## Mega treats files under this directory as import repo and other directories as monorepo
 import_dir = "/third-part"
 
+disable_http_push = false
 
 [pack]
 # The maximum memory used by decode, Unit is GB
diff --git a/jupiter/callisto/src/ssh_keys.rs b/jupiter/callisto/src/ssh_keys.rs
index 2aa89450..a018f29b 100644
--- a/jupiter/callisto/src/ssh_keys.rs
+++ b/jupiter/callisto/src/ssh_keys.rs
@@ -9,6 +9,8 @@ pub struct Model {
     pub id: i64,
     pub user_id: i64,
     #[sea_orm(column_type = "Text")]
+    pub title: String,
+    #[sea_orm(column_type = "Text")]
     pub ssh_key: String,
     #[sea_orm(column_type = "Text")]
     pub finger: String,
diff --git a/jupiter/src/storage/init.rs b/jupiter/src/storage/init.rs
index c9dc052f..a04dd7be 100644
--- a/jupiter/src/storage/init.rs
+++ b/jupiter/src/storage/init.rs
@@ -51,7 +51,7 @@ async fn setup_sql(conn: &DatabaseConnection) -> Result<(), TransactionError<DbE
             let backend = txn.get_database_backend();
 
             // `include_str!` will expand the file while compiling, so `.sql` is not needed after that
-            const SETUP_SQL: &str = include_str!("../../../sql/sqlite/sqlite_20240910_init.sql");
+            const SETUP_SQL: &str = include_str!("../../../sql/sqlite/sqlite_20240912_init.sql");
             txn.execute(Statement::from_string(backend, SETUP_SQL)).await?;
             Ok(())
         })
diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs
index 332b23d9..a0fddaee 100644
--- a/jupiter/src/storage/user_storage.rs
+++ b/jupiter/src/storage/user_storage.rs
@@ -45,12 +45,14 @@ impl UserStorage {
     pub async fn save_ssh_key(
         &self,
         user_id: i64,
+        title: &str,
         ssh_key: &str,
         finger: &str,
     ) -> Result<(), MegaError> {
         let model = ssh_keys::Model {
             id: generate_id(),
             user_id,
+            title: title.to_owned(),
             ssh_key: ssh_key.to_owned(),
             finger: finger.to_owned(),
             created_at: chrono::Utc::now().naive_utc(),
diff --git a/mega/config.toml b/mega/config.toml
index f939074f..30364320 100644
--- a/mega/config.toml
+++ b/mega/config.toml
@@ -36,9 +36,6 @@ min_connection = 16
 sqlx_logging = false
 
 
-[ssh]
-ssh_key_path = "${base_dir}/ssh"
-
 [storage]
 
 obs_access_key = ""
@@ -56,6 +53,7 @@ obs_endpoint = "https://obs.cn-east-3.myhuaweicloud.com"
 ## Mega treats files under this directory as import repo and other directories as monorepo
 import_dir = "/third-part"
 
+disable_http_push = false
 
 [pack]
 # The maximum memory used by decode, Unit is GB
diff --git a/mono/Cargo.toml b/mono/Cargo.toml
index fc72008a..9038c37b 100644
--- a/mono/Cargo.toml
+++ b/mono/Cargo.toml
@@ -18,6 +18,7 @@ callisto = { workspace = true }
 jupiter = { workspace = true }
 ceres = { workspace = true }
 taurus = { workspace = true }
+vault = { workspace = true }
 
 anyhow = { workspace = true }
 axum = { workspace = true }
diff --git a/mono/config.toml b/mono/config.toml
index ac0dffa1..539d7cba 100644
--- a/mono/config.toml
+++ b/mono/config.toml
@@ -36,9 +36,6 @@ min_connection = 16
 sqlx_logging = false
 
 
-[ssh]
-ssh_key_path = "${base_dir}/ssh"
-
 [storage]
 
 obs_access_key = ""
@@ -56,6 +53,7 @@ obs_endpoint = "https://obs.cn-east-3.myhuaweicloud.com"
 ## Mega treats files under this directory as import repo and other directories as monorepo
 import_dir = "/third-part"
 
+disable_http_push = false
 
 [pack]
 # The maximum memory used by decode, Unit is GB
diff --git a/mono/src/api/user/model.rs b/mono/src/api/user/model.rs
index 03903f26..65794af6 100644
--- a/mono/src/api/user/model.rs
+++ b/mono/src/api/user/model.rs
@@ -4,12 +4,14 @@ use sea_orm::entity::prelude::*;
 
 #[derive(Debug, Deserialize)]
 pub struct AddSSHKey {
+    pub title: String,
     pub ssh_key: String,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct ListSSHKey {
     pub id: i64,
+    pub title: String,
     pub ssh_key: String,
     pub finger: String,
     pub created_at: DateTime,
@@ -19,6 +21,7 @@ impl From<ssh_keys::Model> for ListSSHKey {
     fn from(value: ssh_keys::Model) -> Self {
         Self {
             id: value.id,
+            title: value.title,
             ssh_key: value.ssh_key,
             finger: value.finger,
             created_at: value.created_at,
diff --git a/mono/src/api/user/user_router.rs b/mono/src/api/user/user_router.rs
index b082fb91..e2e93d2a 100644
--- a/mono/src/api/user/user_router.rs
+++ b/mono/src/api/user/user_router.rs
@@ -7,10 +7,10 @@ use russh_keys::parse_public_key_base64;
 
 use common::model::CommonResult;
 
-use crate::api::{error::ApiError, oauth::model::LoginUser};
 use crate::api::user::model::AddSSHKey;
 use crate::api::user::model::ListSSHKey;
 use crate::api::MonoApiServiceState;
+use crate::api::{error::ApiError, oauth::model::LoginUser};
 
 pub fn routers() -> Router<MonoApiServiceState> {
     Router::new()
@@ -32,20 +32,24 @@ async fn add_key(
     state: State<MonoApiServiceState>,
     Json(json): Json<AddSSHKey>,
 ) -> Result<Json<CommonResult<String>>, ApiError> {
-    let key_data = json
-        .ssh_key
-        .split_whitespace()
-        .nth(1)
-        .ok_or("Invalid key format")
-        .unwrap();
-
-    let key = parse_public_key_base64(key_data)?;
+    let ssh_key: Vec<&str> = json.ssh_key.split_whitespace().collect();
+    let key = parse_public_key_base64(ssh_key.get(1).ok_or("Invalid key format").unwrap())?;
+    let title = if !json.title.is_empty() {
+        json.title
+    } else {
+        ssh_key
+            .get(2)
+            .ok_or("Invalid key format")
+            .unwrap()
+            .to_owned()
+            .to_owned()
+    };
 
     let res = state
         .context
         .services
         .user_storage
-        .save_ssh_key(user.user_id, &json.ssh_key, &key.fingerprint())
+        .save_ssh_key(user.user_id, &title, &json.ssh_key, &key.fingerprint())
         .await;
     let res = match res {
         Ok(_) => CommonResult::success(None),
diff --git a/mono/src/server/https_server.rs b/mono/src/server/https_server.rs
index e8dc9ba0..164ed0a2 100644
--- a/mono/src/server/https_server.rs
+++ b/mono/src/server/https_server.rs
@@ -145,8 +145,7 @@ pub async fn app(config: Config, host: String, port: u16, common: CommonOptions)
 }
 
 lazy_static! {
-    /// The following regular expressions are used to match the LFS server discovery protocol.
-    /// Git Protocol
+    /// The following regular expressions are used to match the Git server protocol.
     static ref INFO_REFS_REGEX: Regex = Regex::new(r"/info/refs$").unwrap();
     static ref REGEX_GIT_UPLOAD_PACK: Regex = Regex::new(r"/git-upload-pack$").unwrap();
     static ref REGEX_GIT_RECEIVE_PACK: Regex = Regex::new(r"/git-receive-pack$").unwrap();
@@ -186,6 +185,12 @@ pub async fn post_method_router(
         pack_protocol.service_type = Some(ServiceType::UploadPack);
         crate::git_protocol::http::git_upload_pack(req, pack_protocol).await
     } else if REGEX_GIT_RECEIVE_PACK.is_match(uri.path()) {
+        if state.context.config.monorepo.disable_http_push {
+            return Err((
+                StatusCode::FORBIDDEN,
+                String::from("HTTP Push Has Been Disabled"),
+            ))
+        }
         let mut pack_protocol = SmartProtocol::new(
             remove_git_suffix(uri.clone(), "/git-receive-pack"),
             state.context.clone(),
diff --git a/mono/src/server/ssh_server.rs b/mono/src/server/ssh_server.rs
index c6f39d7a..b849daa5 100644
--- a/mono/src/server/ssh_server.rs
+++ b/mono/src/server/ssh_server.rs
@@ -1,18 +1,14 @@
 use std::collections::HashMap;
-use std::fs::{create_dir_all, File};
-use std::io::Read;
 use std::net::SocketAddr;
-use std::path::{Path, PathBuf};
 use std::str::FromStr;
 use std::sync::Arc;
 
-use anyhow::Result;
 use bytes::BytesMut;
 use clap::Args;
 
 use common::config::Config;
 use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
-use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
+use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
 use ed25519_dalek::SigningKey;
 use russh::server::Server;
 use russh_keys::key::KeyPair;
@@ -20,6 +16,7 @@ use russh_keys::key::KeyPair;
 use common::model::CommonOptions;
 use jupiter::context::Context;
 use tokio::sync::Mutex;
+use vault::vault::{read_secret, write_secret};
 
 use crate::git_protocol::ssh::SshServer;
 
@@ -36,18 +33,12 @@ pub struct SshOptions {
 pub struct SshCustom {
     #[arg(long, default_value_t = 2222)]
     ssh_port: u16,
-
-    #[arg(long, value_name = "FILE")]
-    ssh_key_path: Option<PathBuf>,
-
-    #[arg(long, value_name = "FILE")]
-    ssh_cert_path: Option<PathBuf>,
 }
 
 /// start a ssh server
 pub async fn start_server(config: Config, command: &SshOptions) {
     // we need to persist the key to prevent key expired after server restart.
-    let client_key = load_key(config.ssh.ssh_key_path.clone()).unwrap();
+    let client_key = load_key();
     let client_pubkey = Arc::new(client_key.clone_public_key().unwrap());
 
     let mut ru_config = russh::server::Config {
@@ -65,12 +56,7 @@ pub async fn start_server(config: Config, command: &SshOptions) {
 
     let SshOptions {
         common: CommonOptions { host, .. },
-        custom:
-            SshCustom {
-                ssh_port,
-                ssh_key_path: _,
-                ssh_cert_path: _,
-            },
+        custom: SshCustom { ssh_port },
     } = command;
     let context = Context::new(config).await;
     let mut ssh_server = SshServer {
@@ -86,51 +72,28 @@ pub async fn start_server(config: Config, command: &SshOptions) {
     ssh_server.run_on_address(ru_config, addr).await.unwrap();
 }
 
-/// # Loads an SSH keypair.
-///
-/// This function follows the following steps:
-/// 1. It retrieves the root directory for the SSH key from path.
-/// 2. It constructs the path to the SSH private key file by joining the root directory with the filename "id_rsa" using PathBuf.
-/// 3. It checks if the key file exists. If it doesn't, it generates a new Ed25519 keypair using KeyPair::generate_ed25519.
-/// - The generated keypair is then written to the key file.
-/// 4. If the key file exists, it reads the keypair from the file.
-/// - The keypair is loaded from the file and returned.
-///
-/// # Returns
-///
-/// An asynchronous Result containing the loaded SSH keypair if successful, or an error if any of the steps fail.
-pub fn load_key(key_root: PathBuf) -> Result<KeyPair> {
-    let key_path = PathBuf::from(&key_root).join("id_rsa");
-
-    if !Path::new(&key_root).exists() {
-        // create ssh directory if not exists
-        create_dir_all(&key_root).unwrap();
-    }
-
-    if !key_path.exists() {
+pub fn load_key() -> KeyPair {
+    let ssh_key = read_secret("ssh_server_key").unwrap();
+    if let Some(ssh_key) = ssh_key {
+        // load the keypair from the vault
+        let data = ssh_key.data.unwrap();
+        let secret_key = data["secret_key"].as_str().unwrap().to_string();
+        let keypair = SigningKey::from_pkcs8_pem(&secret_key).expect("parsing key err");
+        KeyPair::Ed25519(keypair)
+    } else {
         // generate a keypair if not exists
         let keys = KeyPair::generate_ed25519().unwrap();
-
         if let KeyPair::Ed25519(inner_pair) = &keys {
-            // Handle other variants or provide a default behavior
-            inner_pair
-                .write_pkcs8_pem_file(key_path, LineEnding::CR)
-                .unwrap();
-            inner_pair
-                .verifying_key()
-                .write_public_key_pem_file(
-                    PathBuf::from(&key_root).join("id_rsa.pub"),
-                    LineEnding::CR,
-                )
-                .unwrap();
+            let secret = serde_json::json!({
+                "secret_key": *inner_pair.to_pkcs8_pem(LineEnding::CR).unwrap()
+            })
+            .as_object()
+            .unwrap()
+            .clone();
+            write_secret("ssh_server_key", Some(secret)).unwrap_or_else(|e| {
+                panic!("Failed to write ssh_server_key: {:?}", e);
+            });
         }
-        Ok(keys)
-    } else {
-        // load the keypair from the file
-        let mut file = File::open(&key_path)?;
-        let mut pem_str = String::new();
-        file.read_to_string(&mut pem_str).unwrap();
-        let keypair = SigningKey::from_pkcs8_pem(&pem_str)?;
-        Ok(KeyPair::Ed25519(keypair))
+        keys
     }
 }
diff --git a/moon/src/app/(dashboard)/user/keys/add/page.tsx b/moon/src/app/(dashboard)/user/keys/add/page.tsx
index 9a01909c..0c2b834f 100644
--- a/moon/src/app/(dashboard)/user/keys/add/page.tsx
+++ b/moon/src/app/(dashboard)/user/keys/add/page.tsx
@@ -9,6 +9,7 @@ import { useRouter } from 'next/navigation';
 const { TextArea } = Input;
 
 export default function Settings() {
+  const [title, setTitle] = useState('');
   const [ssh_key, setSSHKey] = useState('');
   const [messageApi, contextHolder] = message.useMessage();
   const router = useRouter();
@@ -20,13 +21,14 @@ export default function Settings() {
     });
   };
 
-  const save_ssh_key = async (ssh_key) => {
+  const save_ssh_key = async (title, ssh_key) => {
     const res = await fetch('/api/user/ssh', {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
       },
       body: JSON.stringify({
+        title: title,
         ssh_key: ssh_key
       }),
     });
@@ -48,7 +50,7 @@ export default function Settings() {
           <Subheading>Title</Subheading>
         </div>
         <div>
-          <Input aria-label="title" name="title"
+          <Input aria-label="title" name="title" value={title} onChange={(e) => setTitle(e.target.value)}
           />
         </div>
       </section>
@@ -72,7 +74,7 @@ export default function Settings() {
         <Button>
           Reset
         </Button>
-        <Button type="primary" disabled={!ssh_key} onClick={() => save_ssh_key(ssh_key)} >Save changes</Button>
+        <Button type="primary" disabled={!ssh_key} onClick={() => save_ssh_key(title, ssh_key)} >Save changes</Button>
       </div>
     </form>
   )
diff --git a/moon/src/app/(dashboard)/user/keys/page.tsx b/moon/src/app/(dashboard)/user/keys/page.tsx
index de55c436..6cc61578 100644
--- a/moon/src/app/(dashboard)/user/keys/page.tsx
+++ b/moon/src/app/(dashboard)/user/keys/page.tsx
@@ -14,6 +14,7 @@ interface KeyItem {
     ssh_key: string,
     created_at: string,
     finger: string,
+    title: string,
 }
 
 export default function KeysPage() {
@@ -80,7 +81,13 @@ export default function KeysPage() {
                             avatar={
                                 <KeyIcon className="size-6" />
                             }
-                            title={`SHA256:${item.finger}`}
+                            title={
+                                <>
+                                    {item.title}
+                                    <br />
+                                    SHA256: {item.finger}
+                                </>
+                            }
                             description={`Added on ${format(new Date(item.created_at), "MMM dd,yyyy")}`}
                         />
                         <Button danger onClick={() => delete_ssh_key(item.id)} >Delete</Button>
diff --git a/sql/postgres/pg_20240910__init.sql b/sql/postgres/pg_20240912__init.sql
similarity index 99%
rename from sql/postgres/pg_20240910__init.sql
rename to sql/postgres/pg_20240912__init.sql
index ce97b2ec..13875622 100644
--- a/sql/postgres/pg_20240910__init.sql
+++ b/sql/postgres/pg_20240912__init.sql
@@ -291,6 +291,7 @@ CREATE TABLE IF NOT EXISTS "user" (
 CREATE TABLE IF NOT EXISTS "ssh_keys" (
   "id" BIGINT PRIMARY KEY,
   "user_id" BIGINT NOT NULL,
+  "title" TEXT NOT NULL,
   "ssh_key" TEXT NOT NULL,
   "finger" TEXT NOT NULL,
   "created_at" TIMESTAMP NOT NULL
diff --git a/sql/sqlite/sqlite_20240910_init.sql b/sql/sqlite/sqlite_20240912_init.sql
similarity index 99%
rename from sql/sqlite/sqlite_20240910_init.sql
rename to sql/sqlite/sqlite_20240912_init.sql
index 32a2b267..8e0e30e4 100644
--- a/sql/sqlite/sqlite_20240910_init.sql
+++ b/sql/sqlite/sqlite_20240912_init.sql
@@ -291,6 +291,7 @@ CREATE TABLE IF NOT EXISTS "user" (
 CREATE TABLE IF NOT EXISTS "ssh_keys" (
   "id" BIGINT PRIMARY KEY,
   "user_id" BIGINT NOT NULL,
+  "title" TEXT NOT NULL,
   "ssh_key" TEXT NOT NULL,
   "finger" TEXT NOT NULL,
   "created_at" TIMESTAMP NOT NULL


Test patch: