5 files changed
+58
-11
crates/cli/src/cloud-deploy/mod.rs
+38
-6
@@ -27,7 +27,7 @@ pub(crate) use smbcloud_deploy::known_hosts;
27
/// Build the rsync transport for the current project.
28
///
29
/// This resolves the front-end-specific bits the engine deliberately doesn't
30
-/// know about: the server host (from the runner), the remote path (from config,
30
+/// know about: the server host (from the runner or an approved override), the remote path (from config,
31
/// defaulting to `apps/web/<name>`), and the user's local SSH identity file.
32
pub(crate) fn rsync_transport(
33
config: &Config,
@@ -42,9 +42,41 @@ pub(crate) fn rsync_transport(
42
None => format!("apps/web/{}", config.project.name),
43
};
44
45
- Ok(RsyncTransport::new(
46
- runner.rsync_host(),
47
- remote_path,
48
- identity_file,
49
- ))
45
+ let rsync_host = resolve_rsync_host(config.project.rsync_host.as_deref(), runner)?;
46
+
47
+ Ok(RsyncTransport::new(rsync_host, remote_path, identity_file))
48
+}
49
+
50
+pub(crate) fn resolve_rsync_host(configured_host: Option<&str>, runner: &Runner) -> Result<String> {
51
+ let host = configured_host
52
+ .map(str::to_owned)
53
+ .unwrap_or_else(|| runner.rsync_host());
54
+
55
+ if !known_hosts::is_pinned_host(&host) {
56
+ return Err(anyhow!(
57
+ "Unsupported rsync host '{host}'. Choose a smbCloud host with a pinned SSH key."
58
+ ));
59
+ }
60
+
61
+ Ok(host)
62
+}
63
+
64
+#[cfg(test)]
65
+mod tests {
66
+ use super::*;
67
+
68
+ #[test]
69
+ fn configured_rsync_host_overrides_runner_default() {
70
+ let host = resolve_rsync_host(Some("api-1.smbcloud.xyz"), &Runner::NodeJs)
71
+ .expect("pinned host should be accepted");
72
+
73
+ assert_eq!(host, "api-1.smbcloud.xyz");
74
+ }
75
+
76
+ #[test]
77
+ fn unpinned_rsync_host_is_rejected() {
78
+ let result = resolve_rsync_host(Some("example.com"), &Runner::NodeJs);
79
+
80
+ assert!(result.is_err());
81
+ }
82
}
crates/cli/src/cloud-deploy/process_deploy_vite_spa.rs
+3
-5
@@ -62,13 +62,11 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
62
.await
63
.ok();
64
65
- // ── Step 3: rsync <project_path>/<output_dir>/ to api.smbcloud.xyz ───────
65
+ // ── Step 3: rsync <project_path>/<output_dir>/ to the configured host ───
66
//
67
// config.project.path holds the remote destination on the server
68
- // (e.g. "apps/web/myapp"). rsync_deploy appends a
69
- // trailing slash and targets git@api.smbcloud.xyz:<path>/ using the
70
- // pinned known-hosts and the user's smbCloud SSH key — exactly the same
71
- // transport used for static site deployments.
68
+ // (e.g. "apps/web/myapp"). The transport appends a trailing slash and
69
+ // uses the selected host's pinned SSH key and the user's smbCloud identity.
70
71
let runner = config.project.runner;
72
let transport = crate::deploy::rsync_transport(&config, &runner, user.id)?;
crates/cli/src/cloud-deploy/process_migrate.rs
+1
@@ -188,6 +188,7 @@ fn strip_project(project: &Project) -> Project {
188
path: None,
189
repository: None,
190
deploy_repo_id: None,
191
+ rsync_host: None,
192
source_path: None,
193
created_at: project.created_at,
194
updated_at: project.updated_at,
crates/smbcloud-deploy/src/known_hosts.rs
+12
@@ -49,6 +49,11 @@ pub fn for_host(rsync_host: &str) -> &'static str {
49
}
50
}
51
52
+/// Returns whether `host` has a host key pinned by this crate.
53
+pub fn is_pinned_host(host: &str) -> bool {
54
+ matches!(host, "api.smbcloud.xyz" | "api-1.smbcloud.xyz")
55
+}
56
+
57
#[cfg(test)]
58
mod tests {
59
use super::*;
@@ -77,4 +82,11 @@ mod tests {
82
assert!(API_SMBCLOUD_XYZ.contains("AAAA"));
83
assert!(API_1_SMBCLOUD_XYZ.contains("AAAA"));
84
}
85
+
86
+ #[test]
87
+ fn rejects_unpinned_hosts() {
88
+ assert!(is_pinned_host("api.smbcloud.xyz"));
89
+ assert!(is_pinned_host("api-1.smbcloud.xyz"));
90
+ assert!(!is_pinned_host("example.com"));
91
+ }
92
}
crates/smbcloud-model/src/project.rs
+4
@@ -75,6 +75,10 @@ pub struct Project {
75
/// Repo ID backing this deploy target. Optional until the API exposes it
76
/// consistently to the CLI.
77
pub deploy_repo_id: Option<i64>,
78
+ /// Approved rsync host override for deployments that do not use the
79
+ /// runner's default static tier.
80
+ #[serde(default)]
81
+ pub rsync_host: Option<String>,
82
/// Repo-relative app path for monorepo targets, e.g. "apps/web/console".
83
pub source_path: Option<String>,
84
#[serde(default = "default_datetime")]