Merge pull request #458 from RWDai/opencode/sunny-orchid

Add one-click proxy node installation
This commit is contained in:
fawney19
2026-05-15 12:49:08 +08:00
committed by GitHub
21 changed files with 1160 additions and 29 deletions

View File

@@ -17,6 +17,9 @@ use aether_admin::system::{
use aether_contracts::tunnel::{
TUNNEL_RELAY_FORWARDED_BY_HEADER, TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
};
use aether_data::repository::management_tokens::{
CreateManagementTokenRecord, StoredManagementTokenUserSummary,
};
use aether_data::repository::proxy_nodes::{ProxyNodeEventQuery, ProxyNodeMetricsStep};
use axum::{
body::{Body, Bytes},
@@ -27,6 +30,12 @@ use axum::{
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::handlers::public::build_proxy_node_install_session_response;
use crate::handlers::shared::generate_gateway_secret_plaintext;
use crate::LocalMutationOutcome;
#[derive(Debug, Deserialize)]
struct ProxyNodeRegisterRequest {
@@ -119,6 +128,11 @@ struct ProxyNodeTestUrlRequest {
password: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ProxyNodeInstallSessionCreateRequest {
node_name: String,
}
#[derive(Debug, Deserialize)]
struct ProxyNodeBatchUpgradeRequest {
version: String,
@@ -208,6 +222,7 @@ impl Drop for ProxyConnectivityProbeUrlOverrideGuard {
pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
headers: &http::HeaderMap,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
@@ -432,6 +447,37 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
));
}
if decision.route_kind.as_deref() == Some("create_proxy_node_install_session")
&& request_context.method() == http::Method::POST
{
if !state.app().has_management_token_writer() {
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let input = match parse_json_body::<ProxyNodeInstallSessionCreateRequest>(request_body) {
Ok(input) => input,
Err(response) => return Ok(Some(response)),
};
let node_name = match validate_proxy_install_node_name(&input.node_name) {
Ok(node_name) => node_name,
Err(response) => return Ok(Some(response)),
};
let raw_token =
match create_proxy_install_management_token(state, request_context, &node_name).await {
Ok(token) => token,
Err(response) => return Ok(Some(response)),
};
return Ok(Some(
build_proxy_node_install_session_response(
state.app(),
request_context.public(),
headers,
node_name,
raw_token,
)
.await,
));
}
if decision.route_kind.as_deref() == Some("update_manual_node")
&& request_context.method() == http::Method::PATCH
{
@@ -2021,6 +2067,121 @@ fn validate_optional_object(value: Option<&Value>, field: &str) -> Result<(), Re
Ok(())
}
fn validate_proxy_install_node_name(value: &str) -> Result<String, Response<Body>> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.chars().count() > 100 {
return Err(bad_request_response(
"节点名称不能为空,且不能超过 100 个字符",
));
}
Ok(trimmed.to_string())
}
fn generate_proxy_install_management_token_plaintext() -> String {
generate_gateway_secret_plaintext("ae", "-")
}
fn hash_proxy_install_management_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn proxy_install_management_token_prefix(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value[..value.len().min(12)].to_string())
}
async fn create_proxy_install_management_token(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
node_name: &str,
) -> Result<String, Response<Body>> {
let Some(principal) = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
else {
return Err((
http::StatusCode::UNAUTHORIZED,
Json(json!({ "detail": "未认证管理员" })),
)
.into_response());
};
let user = match state.app().find_user_auth_by_id(&principal.user_id).await {
Ok(value) => value,
Err(err) => {
return Err((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": format!("admin user lookup failed: {err:?}") })),
)
.into_response())
}
};
let user = user
.map(|user| {
StoredManagementTokenUserSummary::new(
user.id,
user.email,
user.username,
user.role,
)
})
.unwrap_or_else(|| {
StoredManagementTokenUserSummary::new(
principal.user_id.clone(),
None,
principal.user_id.clone(),
principal.user_role.clone(),
)
})
.map_err(|err| {
(
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": format!("management token user summary build failed: {err:?}") })),
)
.into_response()
})?;
let raw_token = generate_proxy_install_management_token_plaintext();
let short_id = Uuid::new_v4()
.simple()
.to_string()
.chars()
.take(8)
.collect::<String>();
let record = CreateManagementTokenRecord {
id: Uuid::new_v4().to_string(),
user_id: user.id.clone(),
user,
token_hash: hash_proxy_install_management_token(&raw_token),
token_prefix: proxy_install_management_token_prefix(&raw_token),
name: format!("aether-proxy {node_name} {short_id}"),
description: Some("Created by proxy node one-click installer".to_string()),
allowed_ips: None,
permissions: Some(json!(["admin:proxy_nodes:write"])),
expires_at_unix_secs: None,
is_active: true,
};
match state.app().create_management_token(&record).await {
Ok(LocalMutationOutcome::Applied(_)) => Ok(raw_token),
Ok(LocalMutationOutcome::Invalid(detail)) => Err(bad_request_response(detail)),
Ok(LocalMutationOutcome::Unavailable) => {
Err(build_admin_proxy_nodes_data_unavailable_response())
}
Ok(LocalMutationOutcome::NotFound) => Err((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "管理员不存在" })),
)
.into_response()),
Err(err) => Err((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": format!("management token create failed: {err:?}") })),
)
.into_response()),
}
}
fn parse_proxy_node_event_query(
query: Option<&str>,
) -> Result<ProxyNodeEventQuery, Response<Body>> {

View File

@@ -38,6 +38,7 @@ pub(crate) async fn maybe_build_local_admin_system_response(
if let Some(response) = proxy_nodes::maybe_build_local_admin_proxy_nodes_response(
&request.state(),
&request.request_context(),
request.request_headers(),
request.request_body(),
)
.await?

View File

@@ -20,7 +20,8 @@ pub(crate) use self::system_modules_helpers::{
};
pub(crate) use self::support::{
build_api_key_install_session_response, build_unhandled_public_support_response,
matches_model_mapping_for_models, maybe_build_local_admin_announcements_response,
maybe_build_local_public_support_response, CreateApiKeyInstallSessionRequest,
build_api_key_install_session_response, build_proxy_node_install_session_response,
build_unhandled_public_support_response, matches_model_mapping_for_models,
maybe_build_local_admin_announcements_response, maybe_build_local_public_support_response,
CreateApiKeyInstallSessionRequest,
};

View File

@@ -64,7 +64,8 @@ use self::support_auth::{
};
use self::support_dashboard::maybe_build_local_dashboard_response;
pub(crate) use self::support_install::{
build_api_key_install_session_response, CreateApiKeyInstallSessionRequest,
build_api_key_install_session_response, build_proxy_node_install_session_response,
CreateApiKeyInstallSessionRequest,
};
use self::support_install::{
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,

View File

@@ -14,6 +14,11 @@ use super::{
const INSTALL_SESSION_TTL_SECS: u64 = 15 * 60;
const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
const PROXY_INSTALL_SESSION_KEY_PREFIX: &str = "proxy-install:session:";
const PROXY_INSTALL_UNIX_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh";
const PROXY_INSTALL_POWERSHELL_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1";
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -49,6 +54,14 @@ struct StoredInstallSession {
expires_at_unix_secs: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredProxyInstallSession {
aether_url: String,
management_token: String,
node_name: String,
expires_at_unix_secs: u64,
}
pub(super) fn users_me_api_key_install_sessions_path_matches(request_path: &str) -> bool {
users_me_api_key_install_session_id_from_path(request_path).is_some()
}
@@ -78,10 +91,27 @@ fn install_code_from_path(request_path: &str) -> Option<(String, bool)> {
(!code.is_empty()).then(|| (code.to_string(), is_powershell))
}
fn proxy_install_code_from_path(request_path: &str) -> Option<(String, bool)> {
let raw = request_path
.strip_prefix("/install-proxy/")?
.trim()
.trim_matches('/');
if raw.is_empty() || raw.contains('/') {
return None;
}
let is_powershell = raw.ends_with(".ps1");
let code = raw.strip_suffix(".ps1").unwrap_or(raw).trim();
(!code.is_empty()).then(|| (code.to_string(), is_powershell))
}
fn install_session_runtime_key(code: &str) -> String {
format!("{INSTALL_SESSION_KEY_PREFIX}{code}")
}
fn proxy_install_session_runtime_key(code: &str) -> String {
format!("{PROXY_INSTALL_SESSION_KEY_PREFIX}{code}")
}
fn generate_install_code() -> String {
uuid::Uuid::new_v4()
.simple()
@@ -95,7 +125,7 @@ fn unix_secs_now() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
fn base_url_from_request(
pub(crate) fn base_url_from_request(
headers: &http::HeaderMap,
request_context: &GatewayPublicRequestContext,
) -> String {
@@ -134,6 +164,45 @@ fn powershell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn build_proxy_unix_script(session: &StoredProxyInstallSession) -> String {
format!(
r###"#!/bin/sh
set -eu
export AETHER_PROXY_AETHER_URL={aether_url}
export AETHER_PROXY_MANAGEMENT_TOKEN={management_token}
export AETHER_PROXY_NODE_NAME={node_name}
if command -v curl >/dev/null 2>&1; then
curl -fsSL {script_url} | sh
elif command -v wget >/dev/null 2>&1; then
wget -qO- {script_url} | sh
else
printf '%s\n' "[Aether Proxy] 需要 curl 或 wget 下载安装脚本" >&2
exit 1
fi
"###,
aether_url = shell_single_quote(&session.aether_url),
management_token = shell_single_quote(&session.management_token),
node_name = shell_single_quote(&session.node_name),
script_url = shell_single_quote(PROXY_INSTALL_UNIX_SCRIPT_URL),
)
}
fn build_proxy_powershell_script(session: &StoredProxyInstallSession) -> String {
format!(
r###"$ErrorActionPreference = 'Stop'
$env:AETHER_PROXY_AETHER_URL = {aether_url}
$env:AETHER_PROXY_MANAGEMENT_TOKEN = {management_token}
$env:AETHER_PROXY_NODE_NAME = {node_name}
irm {script_url} | iex
"###,
aether_url = powershell_single_quote(&session.aether_url),
management_token = powershell_single_quote(&session.management_token),
node_name = powershell_single_quote(&session.node_name),
script_url = powershell_single_quote(PROXY_INSTALL_POWERSHELL_SCRIPT_URL),
)
}
fn cli_label(target_cli: InstallTargetCli) -> &'static str {
match target_cli {
InstallTargetCli::ClaudeCode => "Claude Code",
@@ -581,6 +650,59 @@ pub(crate) async fn build_api_key_install_session_response(
.into_response()
}
pub(crate) async fn build_proxy_node_install_session_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
node_name: String,
management_token: String,
) -> Response<Body> {
let code = generate_install_code();
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
let session = StoredProxyInstallSession {
aether_url: base_url_from_request(headers, request_context),
management_token,
node_name,
expires_at_unix_secs,
};
let serialized = match serde_json::to_string(&session) {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session serialize failed: {err:?}"),
false,
)
}
};
if let Err(err) = state
.runtime_kv_setex(
&proxy_install_session_runtime_key(&code),
&serialized,
INSTALL_SESSION_TTL_SECS,
)
.await
{
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session create failed: {err:?}"),
false,
);
}
let base_url = session.aether_url.trim_end_matches('/');
Json(json!({
"install_code": code,
"expires_at_unix_secs": expires_at_unix_secs,
"expires_in_seconds": INSTALL_SESSION_TTL_SECS,
"node_name": session.node_name,
"aether_url": session.aether_url,
"unix_command": format!("curl -fsSL {base_url}/install-proxy/{code} | sh"),
"powershell_command": format!("irm {base_url}/install-proxy/{code}.ps1 | iex"),
}))
.into_response()
}
pub(super) async fn maybe_build_local_install_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -589,6 +711,9 @@ pub(super) async fn maybe_build_local_install_response(
if decision.route_family.as_deref() != Some("install") {
return None;
}
if request_context.request_path.starts_with("/install-proxy/") {
return Some(maybe_build_local_proxy_install_response(state, request_context).await);
}
let Some((code, wants_powershell)) = install_code_from_path(&request_context.request_path)
else {
return Some(build_auth_error_response(
@@ -664,6 +789,86 @@ pub(super) async fn maybe_build_local_install_response(
Some(response)
}
async fn maybe_build_local_proxy_install_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Response<Body> {
let Some((code, wants_powershell)) =
proxy_install_code_from_path(&request_context.request_path)
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 不存在或已失效",
false,
);
};
let raw = match state
.runtime_kv_getdel(&proxy_install_session_runtime_key(&code))
.await
{
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 不存在、已过期或已使用",
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session lookup failed: {err:?}"),
false,
)
}
};
let session = match serde_json::from_str::<StoredProxyInstallSession>(&raw) {
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"proxy install code 数据无效",
false,
)
}
};
if session.expires_at_unix_secs <= unix_secs_now() {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 已过期",
false,
);
}
let body = if wants_powershell {
build_proxy_powershell_script(&session)
} else {
build_proxy_unix_script(&session)
};
let content_type = if wants_powershell {
"text/plain; charset=utf-8"
} else {
"text/x-shellscript; charset=utf-8"
};
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static(content_type),
);
response.headers_mut().insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-store"),
);
response.headers_mut().insert(
http::header::PRAGMA,
http::HeaderValue::from_static("no-cache"),
);
response.headers_mut().insert(
http::header::HeaderName::from_static("x-content-type-options"),
http::HeaderValue::from_static("nosniff"),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
@@ -680,6 +885,56 @@ mod tests {
}
}
fn test_proxy_session() -> StoredProxyInstallSession {
StoredProxyInstallSession {
aether_url: "https://aether.example".to_string(),
management_token: "ae-test-token".to_string(),
node_name: "jp-proxy-01".to_string(),
expires_at_unix_secs: u64::MAX,
}
}
#[test]
fn proxy_install_path_accepts_shell_and_powershell_codes() {
assert_eq!(
proxy_install_code_from_path("/install-proxy/abc123"),
Some(("abc123".to_string(), false))
);
assert_eq!(
proxy_install_code_from_path("/install-proxy/abc123.ps1"),
Some(("abc123".to_string(), true))
);
assert_eq!(proxy_install_code_from_path("/install-proxy/a/b"), None);
}
#[test]
fn proxy_unix_script_exports_session_values_and_reuses_proxy_installer() {
let script = build_proxy_unix_script(&test_proxy_session());
assert!(script.contains("export AETHER_PROXY_AETHER_URL='https://aether.example'"));
assert!(script.contains("export AETHER_PROXY_MANAGEMENT_TOKEN='ae-test-token'"));
assert!(script.contains("export AETHER_PROXY_NODE_NAME='jp-proxy-01'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh"
));
assert!(!script.contains("aether-rust-pioneer"));
assert!(!script.contains("[[servers]]"));
}
#[test]
fn proxy_powershell_script_exports_session_values_and_reuses_proxy_installer() {
let script = build_proxy_powershell_script(&test_proxy_session());
assert!(script.contains("$env:AETHER_PROXY_AETHER_URL = 'https://aether.example'"));
assert!(script.contains("$env:AETHER_PROXY_MANAGEMENT_TOKEN = 'ae-test-token'"));
assert!(script.contains("$env:AETHER_PROXY_NODE_NAME = 'jp-proxy-01'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1"
));
assert!(!script.contains("aether-rust-pioneer"));
assert!(!script.contains("[[servers]]"));
}
#[test]
fn codex_unix_script_preserves_config_and_uses_responses_bearer_token() {
let script = build_unix_script(&test_session(InstallTargetCli::CodexCli));

View File

@@ -316,6 +316,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
| (Some("adaptive_manage"), http::Method::PATCH, Some("toggle_mode"))
| (Some("proxy_nodes_manage"), http::Method::POST, Some("create_manual_node"))
| (
Some("proxy_nodes_manage"),
http::Method::POST,
Some("create_proxy_node_install_session"),
)
| (Some("proxy_nodes_manage"), http::Method::POST, Some("register_node"))
| (Some("proxy_nodes_manage"), http::Method::POST, Some("heartbeat_node"))
| (Some("proxy_nodes_manage"), http::Method::POST, Some("unregister_node"))