Include peer_inbound_access_key in /ui/sites peers JSON

The sites.html edit modal reads peer_inbound_access_key from the row's
data attribute, but the peers JSON built by sites_dashboard omitted the
field, so every edit cleared an existing key. Add the field to the JSON
so the modal renders the stored value and preserves it on save.
This commit is contained in:
2026-04-26 20:29:09 +08:00
parent 6ba948bcc0
commit 069049b146
5 changed files with 280 additions and 12 deletions

View File

@@ -345,6 +345,12 @@ pub async fn register_peer_site(
.get("connection_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
peer_inbound_access_key: payload
.get("peer_inbound_access_key")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
created_at: Some(chrono::Utc::now().to_rfc3339()),
is_healthy: false,
last_health_check: None,
@@ -467,6 +473,16 @@ pub async fn update_peer_site(
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or(existing.connection_id),
peer_inbound_access_key: if payload.get("peer_inbound_access_key").is_some() {
payload
.get("peer_inbound_access_key")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
} else {
existing.peer_inbound_access_key
},
created_at: existing.created_at,
is_healthy: existing.is_healthy,
last_health_check: existing.last_health_check,
@@ -1428,12 +1444,8 @@ fn require_admin_or_registered_peer(state: &AppState, principal: &Principal) ->
}
};
for peer in registry.list_peers() {
if let Some(conn_id) = peer.connection_id.as_deref() {
if let Some(conn) = state.connections.get(conn_id) {
if conn.access_key == principal.access_key {
return None;
}
}
if peer.peer_inbound_access_key.as_deref() == Some(principal.access_key.as_str()) {
return None;
}
}
Some(json_error(

View File

@@ -1192,6 +1192,7 @@ pub async fn sites_dashboard(
"region": p.region,
"priority": p.priority,
"connection_id": p.connection_id,
"peer_inbound_access_key": p.peer_inbound_access_key,
"is_healthy": p.is_healthy,
"last_health_check": p.last_health_check,
})
@@ -1496,6 +1497,8 @@ pub struct PeerSiteForm {
#[serde(default)]
pub connection_id: String,
#[serde(default)]
pub peer_inbound_access_key: String,
#[serde(default)]
pub csrf_token: String,
}
@@ -1657,6 +1660,14 @@ pub async fn add_peer_site(
}
let has_connection = connection_id.is_some();
let peer_inbound_access_key = {
let value = form.peer_inbound_access_key.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
};
let peer = crate::services::site_registry::PeerSite {
site_id: site_id.clone(),
endpoint,
@@ -1671,6 +1682,7 @@ pub async fn add_peer_site(
}
},
connection_id: connection_id.clone(),
peer_inbound_access_key,
created_at: None,
is_healthy: false,
last_health_check: None,
@@ -1755,6 +1767,14 @@ pub async fn update_peer_site(
}
}
let peer_inbound_access_key = {
let value = form.peer_inbound_access_key.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
};
let peer = crate::services::site_registry::PeerSite {
site_id: site_id.clone(),
endpoint: form.endpoint.trim().to_string(),
@@ -1769,6 +1789,7 @@ pub async fn update_peer_site(
}
},
connection_id,
peer_inbound_access_key,
created_at: existing.created_at,
is_healthy: existing.is_healthy,
last_health_check: existing.last_health_check,

View File

@@ -38,6 +38,8 @@ pub struct PeerSite {
#[serde(default)]
pub connection_id: Option<String>,
#[serde(default)]
pub peer_inbound_access_key: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub is_healthy: bool,

View File

@@ -142,6 +142,11 @@
</select>
<div class="form-text">Link to a remote connection for health checks</div>
</div>
<div class="mb-3">
<label for="peer_inbound_access_key" class="form-label fw-medium">Peer Inbound Access Key</label>
<input type="text" class="form-control" id="peer_inbound_access_key" name="peer_inbound_access_key" placeholder="AKIA... (optional)" autocomplete="off" spellcheck="false">
<div class="form-text">Access key the peer presents when calling this site (e.g. /admin/cluster/overview). Leave blank to require admin credentials.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
@@ -287,6 +292,7 @@
data-priority="{{ peer.priority }}"
data-display-name="{{ peer.display_name }}"
data-connection-id="{{ peer.connection_id | default(value="") }}"
data-peer-inbound-access-key="{{ peer.peer_inbound_access_key | default(value="") }}"
title="Edit peer">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
@@ -423,6 +429,11 @@
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="edit_peer_inbound_access_key" class="form-label fw-medium">Peer Inbound Access Key</label>
<input type="text" class="form-control" id="edit_peer_inbound_access_key" name="peer_inbound_access_key" placeholder="AKIA... (optional)" autocomplete="off" spellcheck="false">
<div class="form-text">Access key the peer presents when calling this site (e.g. /admin/cluster/overview). Leave blank to require admin credentials.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -525,6 +536,7 @@
document.getElementById('edit_priority').value = button.getAttribute('data-priority');
document.getElementById('edit_display_name').value = button.getAttribute('data-display-name');
document.getElementById('edit_connection_id').value = button.getAttribute('data-connection-id');
document.getElementById('edit_peer_inbound_access_key').value = button.getAttribute('data-peer-inbound-access-key') || '';
document.getElementById('editPeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/update';
});
}
@@ -868,15 +880,43 @@
});
});
}
document.querySelectorAll('.peer-actions-dropdown').forEach(function(dd) {
dd.addEventListener('shown.bs.dropdown', function() {
document.querySelectorAll('.peer-actions-dropdown').forEach(function(dd, idx) {
var menu = dd.querySelector('.dropdown-menu');
if (!menu) return;
var pairId = 'peer-dd-' + idx;
dd.dataset.peerDdPair = pairId;
menu.dataset.peerDdPair = pairId;
function reposition() {
var toggle = dd.querySelector('[data-bs-toggle="dropdown"]');
var menu = dd.querySelector('.dropdown-menu');
if (!toggle || !menu) return;
if (!toggle) return;
var rect = toggle.getBoundingClientRect();
menu.style.top = rect.bottom + 'px';
menu.style.left = (rect.right - menu.offsetWidth) + 'px';
var menuWidth = menu.offsetWidth;
var menuHeight = menu.offsetHeight;
var pad = 8;
var left = rect.right - menuWidth;
if (left + menuWidth > window.innerWidth - pad) left = window.innerWidth - pad - menuWidth;
if (left < pad) left = pad;
var top = rect.bottom;
if (top + menuHeight > window.innerHeight - pad) {
top = Math.max(pad, rect.top - menuHeight);
}
menu.style.position = 'fixed';
menu.style.top = top + 'px';
menu.style.left = left + 'px';
menu.style.right = 'auto';
menu.style.bottom = 'auto';
menu.style.transform = 'none';
}
dd.addEventListener('show.bs.dropdown', function() {
if (menu.parentNode !== document.body) document.body.appendChild(menu);
});
dd.addEventListener('shown.bs.dropdown', reposition);
dd.addEventListener('hidden.bs.dropdown', function() {
menu.style.cssText = '';
if (menu.parentNode !== dd) dd.appendChild(menu);
});
window.addEventListener('resize', function() { if (menu.classList.contains('show')) reposition(); });
window.addEventListener('scroll', function() { if (menu.classList.contains('show')) reposition(); }, true);
});
})();

View File

@@ -8252,3 +8252,196 @@ async fn test_suspended_put_purges_stale_archived_null_version() {
"the surviving null version must be the most recent (suspended) write"
);
}
#[tokio::test]
async fn test_cluster_overview_matches_peer_inbound_access_key_not_outbound_connection() {
const ADMIN_AK: &str = "AKIAADMINADMINADMIN0";
const ADMIN_SK: &str = "admin-secret-admin-secret-admin-secret00";
const PEER_AK: &str = "AKIAPEERPEERPEERPEER";
const PEER_SK: &str = "peer-secret-peer-secret-peer-secret-peer";
const OUTBOUND_AK: &str = "AKIAOUTBOUNDOUTBOUND";
const OUTBOUND_SK: &str = "outbound-secret-outbound-secret-outbound";
let iam_json = serde_json::json!({
"version": 2,
"users": [
{
"user_id": "u-admin",
"display_name": "admin",
"enabled": true,
"access_keys": [{
"access_key": ADMIN_AK,
"secret_key": ADMIN_SK,
"status": "active"
}],
"policies": [{
"bucket": "*",
"actions": ["*"],
"prefix": "*"
}]
},
{
"user_id": "u-peer",
"display_name": "peer",
"enabled": true,
"access_keys": [{
"access_key": PEER_AK,
"secret_key": PEER_SK,
"status": "active"
}],
"policies": [{
"bucket": "peer-bucket",
"actions": ["read"],
"prefix": "*"
}]
},
{
"user_id": "u-outbound",
"display_name": "outbound",
"enabled": true,
"access_keys": [{
"access_key": OUTBOUND_AK,
"secret_key": OUTBOUND_SK,
"status": "active"
}],
"policies": [{
"bucket": "outbound-bucket",
"actions": ["read"],
"prefix": "*"
}]
}
]
});
let tmp = tempfile::TempDir::new().unwrap();
let iam_path = tmp.path().join(".myfsio.sys").join("config");
std::fs::create_dir_all(&iam_path).unwrap();
std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap();
let config = myfsio_server::config::ServerConfig {
bind_addr: "127.0.0.1:0".parse().unwrap(),
ui_bind_addr: "127.0.0.1:0".parse().unwrap(),
storage_root: tmp.path().to_path_buf(),
region: "us-east-1".to_string(),
iam_config_path: iam_path.join("iam.json"),
sigv4_timestamp_tolerance_secs: 900,
presigned_url_min_expiry: 1,
presigned_url_max_expiry: 604800,
secret_key: None,
encryption_enabled: false,
kms_enabled: false,
gc_enabled: false,
integrity_enabled: false,
metrics_enabled: false,
metrics_history_enabled: false,
metrics_interval_minutes: 5,
metrics_retention_hours: 24,
metrics_history_interval_minutes: 5,
metrics_history_retention_hours: 24,
lifecycle_enabled: false,
website_hosting_enabled: false,
replication_connect_timeout_secs: 5,
replication_read_timeout_secs: 30,
replication_max_retries: 2,
replication_streaming_threshold_bytes: 10_485_760,
replication_max_failures_per_bucket: 50,
site_sync_enabled: false,
site_sync_interval_secs: 60,
site_sync_batch_size: 100,
site_sync_connect_timeout_secs: 10,
site_sync_read_timeout_secs: 120,
site_sync_max_retries: 2,
site_sync_clock_skew_tolerance: 1.0,
ui_enabled: false,
templates_dir: std::path::PathBuf::from("templates"),
static_dir: std::path::PathBuf::from("static"),
multipart_min_part_size: 1,
..myfsio_server::config::ServerConfig::default()
};
let state = myfsio_server::state::AppState::new(config);
state
.connections
.add(myfsio_server::stores::connections::RemoteConnection {
id: "conn-to-peer".to_string(),
name: "Peer".to_string(),
endpoint_url: "http://127.0.0.1:1".to_string(),
access_key: OUTBOUND_AK.to_string(),
secret_key: OUTBOUND_SK.to_string(),
region: "us-east-1".to_string(),
})
.unwrap();
let app = myfsio_server::create_router(state.clone());
let register_body = serde_json::json!({
"site_id": "peer-site",
"endpoint": "http://peer.example.com",
"region": "us-east-1",
"priority": 100,
"display_name": "Peer Site",
"connection_id": "conn-to-peer",
"peer_inbound_access_key": PEER_AK,
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/admin/sites")
.header("x-access-key", ADMIN_AK)
.header("x-secret-key", ADMIN_SK)
.header("content-type", "application/json")
.body(Body::from(register_body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"admin must be able to register peer with peer_inbound_access_key"
);
let resp_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let registered: Value = serde_json::from_slice(&resp_bytes).unwrap();
assert_eq!(registered["peer_inbound_access_key"], PEER_AK);
assert_eq!(registered["connection_id"], "conn-to-peer");
let resp = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/admin/cluster/overview")
.header("x-access-key", PEER_AK)
.header("x-secret-key", PEER_SK)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"registered peer's inbound access key must be authorized for cluster overview"
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/admin/cluster/overview")
.header("x-access-key", OUTBOUND_AK)
.header("x-secret-key", OUTBOUND_SK)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"outbound connection access key must NOT grant cluster overview when not configured as the peer's inbound key"
);
}