mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Merge pull request #458 from RWDai/opencode/sunny-orchid
Add one-click proxy node installation
This commit is contained in:
2
.github/workflows/build-proxy.yml
vendored
2
.github/workflows/build-proxy.yml
vendored
@@ -185,7 +185,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: aether-rust-pioneer
|
||||
ref: main
|
||||
|
||||
- name: Update README download links
|
||||
env:
|
||||
|
||||
18
.github/workflows/rust-ci.yml
vendored
18
.github/workflows/rust-ci.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- aether-rust-pioneer
|
||||
- main
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
@@ -35,9 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
run: rustup component add rustfmt
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
@@ -49,9 +47,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
run: rustup component add clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -82,9 +78,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
run: rustup component add clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -115,9 +109,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
run: rustup component add clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -65,6 +65,8 @@ curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | s
|
||||
|
||||
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
- Docker Compose 部署或下载预编译二进制直接运行
|
||||
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `proxy-v*` 制品并向现有 `aether-proxy.toml` 追加 `[[servers]]`
|
||||
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [apps/aether-proxy/README.md](apps/aether-proxy/README.md)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn mount_public_support_routes(router: Router<AppState>) -> Router<Ap
|
||||
.route("/api/capabilities/user-configurable", get(proxy_request))
|
||||
.route("/api/capabilities/model/{*model_path}", get(proxy_request))
|
||||
.route("/install/{*install_path}", get(proxy_request))
|
||||
.route("/install-proxy/{*install_path}", get(proxy_request))
|
||||
.route("/i/{*install_path}", get(proxy_request))
|
||||
.route("/", get(proxy_request))
|
||||
}
|
||||
|
||||
@@ -288,6 +288,19 @@ pub(super) fn classify_admin_operations_family_route(
|
||||
"admin:proxy_nodes",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
"/api/admin/proxy-nodes/install-sessions" | "/api/admin/proxy-nodes/install-sessions/"
|
||||
)
|
||||
{
|
||||
Some(classified(
|
||||
"admin_proxy",
|
||||
"proxy_nodes_manage",
|
||||
"create_proxy_node_install_session",
|
||||
"admin:proxy_nodes",
|
||||
false,
|
||||
))
|
||||
} else if method == http::Method::POST
|
||||
&& matches!(
|
||||
normalized_path,
|
||||
|
||||
@@ -675,6 +675,7 @@ pub(super) fn classify_public_support_route(
|
||||
))
|
||||
} else if method == http::Method::GET
|
||||
&& (has_single_segment_after_prefix(normalized_path, "/install/")
|
||||
|| has_single_segment_after_prefix(normalized_path, "/install-proxy/")
|
||||
|| has_single_segment_after_prefix(normalized_path, "/i/"))
|
||||
{
|
||||
Some(classified(
|
||||
|
||||
@@ -82,6 +82,15 @@ fn classifies_admin_proxy_nodes_manual_create_as_admin_proxy_route() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_install_session_create_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/install-sessions",
|
||||
"create_proxy_node_install_session",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_manual_update_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -89,6 +89,7 @@ fn frontend_path_bypasses_static(path: &str) -> bool {
|
||||
|| path.starts_with("/_gateway/")
|
||||
|| path.starts_with("/.well-known/")
|
||||
|| path.starts_with("/install/")
|
||||
|| path.starts_with("/install-proxy/")
|
||||
|| path.starts_with("/i/")
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,43 @@ Tunnel 模式下代理节点**无需对外监听端口**,仅需出站连接到
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 / 添加节点
|
||||
|
||||
一键脚本会自动从 GitHub Releases 中筛选最新的 `proxy-v*` tag,并按当前系统下载对应制品:Linux x86_64/ARM64(GNU 或 musl)、macOS x86_64/ARM64、Windows x86_64。仓库的通用 `latest` release 可能不是 proxy 版本,因此脚本不会使用 `/releases/latest`。
|
||||
|
||||
脚本会安装/更新 `aether-proxy` 二进制,并把新的服务器配置追加到 `aether-proxy.toml` 的 `[[servers]]` 数组中;如果配置文件已存在,不会覆盖原有内容。检测到相同 `aether_url + node_name` 时会跳过追加。
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1 | iex
|
||||
```
|
||||
|
||||
也可以用环境变量非交互式执行,适合在控制台“添加代理节点”时生成命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh | \
|
||||
AETHER_PROXY_AETHER_URL="https://aether.example.com" \
|
||||
AETHER_PROXY_MANAGEMENT_TOKEN="ae_xxx" \
|
||||
AETHER_PROXY_NODE_NAME="jp-proxy-01" \
|
||||
sh
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:AETHER_PROXY_AETHER_URL = "https://aether.example.com"
|
||||
$env:AETHER_PROXY_MANAGEMENT_TOKEN = "ae_xxx"
|
||||
$env:AETHER_PROXY_NODE_NAME = "jp-proxy-01"
|
||||
irm https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1 | iex
|
||||
```
|
||||
|
||||
可选变量:`AETHER_PROXY_RELEASE_TAG` 固定安装某个 `proxy-v*` tag,`AETHER_PROXY_CONFIG` 指定配置文件路径,`AETHER_PROXY_INSTALL_DIR` 指定二进制安装目录。
|
||||
|
||||
```bash
|
||||
# 1. 首次安装配置(TUI 向导,勾选 Install Service 随系统启动服务)
|
||||
sudo ./aether-proxy setup
|
||||
|
||||
161
apps/aether-proxy/install.ps1
Normal file
161
apps/aether-proxy/install.ps1
Normal file
@@ -0,0 +1,161 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Repo = if ($env:AETHER_PROXY_RELEASE_REPO) { $env:AETHER_PROXY_RELEASE_REPO } else { 'fawney19/Aether' }
|
||||
$ReleaseTag = $env:AETHER_PROXY_RELEASE_TAG
|
||||
$InstallDir = $env:AETHER_PROXY_INSTALL_DIR
|
||||
$ConfigPath = $env:AETHER_PROXY_CONFIG
|
||||
|
||||
function Say([string]$Message) { Write-Host "[Aether Proxy] $Message" }
|
||||
function Fail([string]$Message) { throw "[Aether Proxy] $Message" }
|
||||
|
||||
function Prompt-IfEmpty([string]$Name, [string]$Value, [string]$Prompt) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value }
|
||||
$Read = Read-Host $Prompt
|
||||
if ([string]::IsNullOrWhiteSpace($Read)) { Fail "$Name cannot be empty" }
|
||||
return $Read
|
||||
}
|
||||
|
||||
function ConvertTo-TomlQuotedString([string]$Value) {
|
||||
return ($Value | ConvertTo-Json -Compress)
|
||||
}
|
||||
|
||||
function Resolve-LatestProxyTag {
|
||||
if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { return $ReleaseTag }
|
||||
$Uri = "https://api.github.com/repos/$Repo/releases?per_page=100"
|
||||
$Releases = Invoke-RestMethod -Uri $Uri -Headers @{ 'User-Agent' = 'aether-proxy-installer' }
|
||||
$ProxyReleases = @($Releases | Where-Object { -not $_.draft -and $_.tag_name -like 'proxy-v*' } | Sort-Object published_at -Descending)
|
||||
if ($ProxyReleases.Count -eq 0) { Fail "No proxy-v* release found in $Repo" }
|
||||
return $ProxyReleases[0].tag_name
|
||||
}
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$Principal = [Security.Principal.WindowsPrincipal]::new($Identity)
|
||||
return $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Initialize-Paths {
|
||||
if ([string]::IsNullOrWhiteSpace($script:InstallDir)) {
|
||||
if (Test-IsAdministrator) {
|
||||
$script:InstallDir = Join-Path $env:ProgramFiles 'AetherProxy'
|
||||
} else {
|
||||
$script:InstallDir = Join-Path $env:LOCALAPPDATA 'AetherProxy'
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($script:ConfigPath)) {
|
||||
if (Test-IsAdministrator) {
|
||||
$script:ConfigPath = Join-Path $env:ProgramData 'AetherProxy\aether-proxy.toml'
|
||||
} else {
|
||||
$script:ConfigPath = Join-Path $env:APPDATA 'AetherProxy\aether-proxy.toml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Install-AetherProxyBinary([string]$Tag, [string]$TempDir) {
|
||||
if (-not [Environment]::Is64BitOperatingSystem) { Fail 'Windows release currently supports amd64 only' }
|
||||
$Asset = 'aether-proxy-windows-amd64.zip'
|
||||
$Base = "https://github.com/$Repo/releases/download/$Tag"
|
||||
$Archive = Join-Path $TempDir $Asset
|
||||
$Sums = Join-Path $TempDir 'SHA256SUMS.txt'
|
||||
|
||||
Say "Downloading $Tag / $Asset"
|
||||
Invoke-WebRequest -Uri "$Base/$Asset" -OutFile $Archive
|
||||
try { Invoke-WebRequest -Uri "$Base/SHA256SUMS.txt" -OutFile $Sums } catch { $Sums = $null }
|
||||
|
||||
if ($Sums -and (Test-Path $Sums)) {
|
||||
$ExpectedLine = Get-Content $Sums | Where-Object { $_ -match "\s$([regex]::Escape($Asset))$" } | Select-Object -First 1
|
||||
if ($ExpectedLine) {
|
||||
$Expected = ($ExpectedLine -split '\s+')[0]
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
|
||||
if ($Actual -ne $Expected.ToLowerInvariant()) { Fail "SHA256 verification failed for $Asset" }
|
||||
}
|
||||
}
|
||||
|
||||
$ExtractDir = Join-Path $TempDir 'extract'
|
||||
Expand-Archive -Path $Archive -DestinationPath $ExtractDir -Force
|
||||
$Binary = Join-Path $ExtractDir 'aether-proxy.exe'
|
||||
if (-not (Test-Path $Binary)) { Fail 'aether-proxy.exe not found in release asset' }
|
||||
New-Item -ItemType Directory -Force -Path $script:InstallDir | Out-Null
|
||||
Copy-Item $Binary (Join-Path $script:InstallDir 'aether-proxy.exe') -Force
|
||||
Say "Installed binary: $(Join-Path $script:InstallDir 'aether-proxy.exe')"
|
||||
}
|
||||
|
||||
function Test-LegacySingleServerConfig([string]$Path) {
|
||||
if (-not (Test-Path $Path)) { return $false }
|
||||
foreach ($Line in Get-Content $Path) {
|
||||
if ($Line -match '^\s*\[') { return $false }
|
||||
if ($Line -match '^\s*(aether_url|management_token)\s*=') { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-ServerExists([string]$Path, [string]$QuotedUrl, [string]$QuotedName) {
|
||||
if (-not (Test-Path $Path)) { return $false }
|
||||
$FoundUrl = $false
|
||||
$FoundName = $false
|
||||
foreach ($Line in Get-Content $Path) {
|
||||
if ($Line -match '^\s*\[\[servers\]\]\s*$') {
|
||||
if ($FoundUrl -and $FoundName) { return $true }
|
||||
$FoundUrl = $false
|
||||
$FoundName = $false
|
||||
}
|
||||
if ($Line.Trim() -eq "aether_url = $QuotedUrl") { $FoundUrl = $true }
|
||||
if ($Line.Trim() -eq "node_name = $QuotedName") { $FoundName = $true }
|
||||
}
|
||||
return ($FoundUrl -and $FoundName)
|
||||
}
|
||||
|
||||
function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]$NodeName) {
|
||||
$ConfigDir = Split-Path -Parent $script:ConfigPath
|
||||
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
|
||||
|
||||
if (Test-LegacySingleServerConfig $script:ConfigPath) {
|
||||
Fail "Existing config uses removed top-level aether_url/management_token. Run aether-proxy setup to migrate to [[servers]] first: $script:ConfigPath"
|
||||
}
|
||||
|
||||
$QuotedUrl = ConvertTo-TomlQuotedString $AetherUrl
|
||||
$QuotedToken = ConvertTo-TomlQuotedString $ManagementToken
|
||||
$QuotedName = ConvertTo-TomlQuotedString $NodeName
|
||||
|
||||
if (Test-ServerExists $script:ConfigPath $QuotedUrl $QuotedName) {
|
||||
Say "Same aether_url + node_name already exists, skipping config append: $script:ConfigPath"
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path $script:ConfigPath) {
|
||||
Copy-Item $script:ConfigPath "$script:ConfigPath.bak.$(Get-Date -Format yyyyMMddHHmmss)" -Force
|
||||
}
|
||||
|
||||
$Prefix = if ((Test-Path $script:ConfigPath) -and ((Get-Item $script:ConfigPath).Length -gt 0)) { "`n" } else { '' }
|
||||
$Block = @(
|
||||
"$Prefix# Added by Aether Proxy one-click installer. Existing config is preserved.",
|
||||
'[[servers]]',
|
||||
"aether_url = $QuotedUrl",
|
||||
"management_token = $QuotedToken",
|
||||
"node_name = $QuotedName"
|
||||
) -join "`n"
|
||||
Add-Content -Path $script:ConfigPath -Value ($Block + "`n") -Encoding UTF8
|
||||
Say "Appended [[servers]] to: $script:ConfigPath"
|
||||
}
|
||||
|
||||
function Main {
|
||||
Initialize-Paths
|
||||
$AetherUrl = Prompt-IfEmpty 'AETHER_PROXY_AETHER_URL' $env:AETHER_PROXY_AETHER_URL 'Aether URL'
|
||||
$ManagementToken = Prompt-IfEmpty 'AETHER_PROXY_MANAGEMENT_TOKEN' $env:AETHER_PROXY_MANAGEMENT_TOKEN 'Management token (ae_xxx)'
|
||||
$NodeName = Prompt-IfEmpty 'AETHER_PROXY_NODE_NAME' $env:AETHER_PROXY_NODE_NAME 'Node name'
|
||||
|
||||
$TempDir = Join-Path ([IO.Path]::GetTempPath()) ("aether-proxy-" + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
|
||||
try {
|
||||
$Tag = Resolve-LatestProxyTag
|
||||
Install-AetherProxyBinary $Tag $TempDir
|
||||
Add-ServerConfig $AetherUrl $ManagementToken $NodeName
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Say 'Complete. Start or configure the node with:'
|
||||
Say " & '$(Join-Path $script:InstallDir 'aether-proxy.exe')' setup '$script:ConfigPath'"
|
||||
}
|
||||
|
||||
Main
|
||||
241
apps/aether-proxy/install.sh
Executable file
241
apps/aether-proxy/install.sh
Executable file
@@ -0,0 +1,241 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPO="${AETHER_PROXY_RELEASE_REPO:-fawney19/Aether}"
|
||||
TAG="${AETHER_PROXY_RELEASE_TAG:-}"
|
||||
INSTALL_DIR="${AETHER_PROXY_INSTALL_DIR:-}"
|
||||
CONFIG_PATH="${AETHER_PROXY_CONFIG:-}"
|
||||
TMP_DIR=""
|
||||
|
||||
say() { printf '%s\n' "[Aether Proxy] $1"; }
|
||||
fail() { printf '%s\n' "[Aether Proxy] $1" >&2; exit 1; }
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"
|
||||
}
|
||||
|
||||
download() {
|
||||
url="$1"
|
||||
out="$2"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fL --retry 3 --connect-timeout 10 -o "$out" "$url"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "$out" "$url"
|
||||
else
|
||||
fail "需要 curl 或 wget 下载 release 制品"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_if_empty() {
|
||||
name="$1"
|
||||
value="$2"
|
||||
prompt="$3"
|
||||
if [ -n "$value" ]; then
|
||||
printf '%s' "$value"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$prompt" >&2
|
||||
if [ -r /dev/tty ]; then
|
||||
IFS= read -r value < /dev/tty
|
||||
else
|
||||
fail "$name 未通过环境变量提供,且当前环境无法交互输入"
|
||||
fi
|
||||
[ -n "$value" ] || fail "$name 不能为空"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
toml_quote() {
|
||||
value="$1"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$value"
|
||||
else
|
||||
escaped=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
printf '"%s"\n' "$escaped"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_proxy_tag() {
|
||||
[ -n "$TAG" ] && { printf '%s\n' "$TAG"; return; }
|
||||
api_url="https://api.github.com/repos/${REPO}/releases?per_page=100"
|
||||
releases="$TMP_DIR/releases.json"
|
||||
download "$api_url" "$releases" >/dev/null 2>&1 || fail "无法读取 GitHub Releases:$api_url"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$releases" <<'PY'
|
||||
import json, sys
|
||||
releases = json.load(open(sys.argv[1], encoding='utf-8'))
|
||||
proxy = [r for r in releases if not r.get('draft') and str(r.get('tag_name', '')).startswith('proxy-v')]
|
||||
proxy.sort(key=lambda r: r.get('published_at') or r.get('created_at') or '', reverse=True)
|
||||
if proxy:
|
||||
print(proxy[0]['tag_name'])
|
||||
PY
|
||||
else
|
||||
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"proxy-v[^"]*"' "$releases" | head -n 1 | sed 's/.*"\(proxy-v[^"]*\)".*/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
detect_asset() {
|
||||
os=$(uname -s 2>/dev/null || printf unknown)
|
||||
arch=$(uname -m 2>/dev/null || printf unknown)
|
||||
|
||||
case "$os" in
|
||||
Linux) platform=linux ;;
|
||||
Darwin) platform=macos ;;
|
||||
MINGW*|MSYS*|CYGWIN*) fail "检测到 Windows shell,请使用 PowerShell:irm <install.ps1-url> | iex" ;;
|
||||
*) fail "不支持的系统:$os" ;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
x86_64|amd64) cpu=amd64 ;;
|
||||
aarch64|arm64) cpu=arm64 ;;
|
||||
*) fail "不支持的 CPU 架构:$arch" ;;
|
||||
esac
|
||||
|
||||
if [ "$platform" = "linux" ] && command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then
|
||||
printf 'aether-proxy-linux-musl-%s.tar.gz\n' "$cpu"
|
||||
else
|
||||
printf 'aether-proxy-%s-%s.tar.gz\n' "$platform" "$cpu"
|
||||
fi
|
||||
}
|
||||
|
||||
choose_paths() {
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
if [ "$(id -u 2>/dev/null || printf 1)" = "0" ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
else
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$CONFIG_PATH" ]; then
|
||||
if [ "$(id -u 2>/dev/null || printf 1)" = "0" ]; then
|
||||
CONFIG_PATH="/etc/aether-proxy/aether-proxy.toml"
|
||||
else
|
||||
CONFIG_PATH="$HOME/.aether-proxy/aether-proxy.toml"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
verify_checksum() {
|
||||
archive="$1"
|
||||
sums="$2"
|
||||
asset="$3"
|
||||
[ -f "$sums" ] || return 0
|
||||
expected=$(awk -v asset="$asset" '$2 == asset { print $1 }' "$sums" | head -n 1)
|
||||
[ -n "$expected" ] || return 0
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$archive" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$archive" | awk '{print $1}')
|
||||
else
|
||||
say "未找到 sha256sum/shasum,跳过校验"
|
||||
return 0
|
||||
fi
|
||||
[ "$actual" = "$expected" ] || fail "SHA256 校验失败:$asset"
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
tag="$1"
|
||||
asset="$2"
|
||||
base="https://github.com/${REPO}/releases/download/${tag}"
|
||||
archive="$TMP_DIR/$asset"
|
||||
say "下载 $tag / $asset"
|
||||
download "$base/$asset" "$archive"
|
||||
download "$base/SHA256SUMS.txt" "$TMP_DIR/SHA256SUMS.txt" >/dev/null 2>&1 || true
|
||||
verify_checksum "$archive" "$TMP_DIR/SHA256SUMS.txt" "$asset"
|
||||
|
||||
tar -xzf "$archive" -C "$TMP_DIR"
|
||||
[ -f "$TMP_DIR/aether-proxy" ] || fail "制品中未找到 aether-proxy"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp "$TMP_DIR/aether-proxy" "$INSTALL_DIR/aether-proxy"
|
||||
chmod +x "$INSTALL_DIR/aether-proxy"
|
||||
say "已安装二进制:$INSTALL_DIR/aether-proxy"
|
||||
}
|
||||
|
||||
has_legacy_single_server_keys() {
|
||||
[ -f "$CONFIG_PATH" ] || return 1
|
||||
awk '
|
||||
/^[[:space:]]*\[/ { exit }
|
||||
/^[[:space:]]*(aether_url|management_token)[[:space:]]*=/ { found=1; exit }
|
||||
END { exit found ? 0 : 1 }
|
||||
' "$CONFIG_PATH"
|
||||
}
|
||||
|
||||
server_exists() {
|
||||
[ -f "$CONFIG_PATH" ] || return 1
|
||||
quoted_url="$1"
|
||||
quoted_name="$2"
|
||||
awk -v url="aether_url = $quoted_url" -v name="node_name = $quoted_name" '
|
||||
BEGIN { found_url=0; found_name=0 }
|
||||
/^\[\[servers\]\]/ {
|
||||
if (found_url && found_name) { found=1 }
|
||||
found_url=0; found_name=0
|
||||
}
|
||||
$0 == url { found_url=1 }
|
||||
$0 == name { found_name=1 }
|
||||
END { if (found_url && found_name) { found=1 }; exit found ? 0 : 1 }
|
||||
' "$CONFIG_PATH"
|
||||
}
|
||||
|
||||
append_server_config() {
|
||||
aether_url="$1"
|
||||
management_token="$2"
|
||||
node_name="$3"
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
quoted_url=$(toml_quote "$aether_url")
|
||||
quoted_token=$(toml_quote "$management_token")
|
||||
quoted_name=$(toml_quote "$node_name")
|
||||
|
||||
if has_legacy_single_server_keys; then
|
||||
fail "现有配置仍使用旧的顶层 aether_url/management_token,请先运行 aether-proxy setup 迁移为 [[servers]] 后重试:$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
if server_exists "$quoted_url" "$quoted_name"; then
|
||||
say "配置中已存在相同 aether_url + node_name,跳过追加:$CONFIG_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
cp "$CONFIG_PATH" "$CONFIG_PATH.bak.$(date +%Y%m%d%H%M%S)"
|
||||
fi
|
||||
|
||||
{
|
||||
if [ -f "$CONFIG_PATH" ] && [ -s "$CONFIG_PATH" ]; then
|
||||
printf '\n'
|
||||
fi
|
||||
printf '# Added by Aether Proxy one-click installer. Existing config is preserved.\n'
|
||||
printf '[[servers]]\n'
|
||||
printf 'aether_url = %s\n' "$quoted_url"
|
||||
printf 'management_token = %s\n' "$quoted_token"
|
||||
printf 'node_name = %s\n' "$quoted_name"
|
||||
} >> "$CONFIG_PATH"
|
||||
chmod 600 "$CONFIG_PATH" 2>/dev/null || true
|
||||
say "已追加 [[servers]] 到:$CONFIG_PATH"
|
||||
}
|
||||
|
||||
main() {
|
||||
TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t aether-proxy)
|
||||
need_cmd tar
|
||||
choose_paths
|
||||
|
||||
aether_url=$(prompt_if_empty AETHER_PROXY_AETHER_URL "${AETHER_PROXY_AETHER_URL:-}" "Aether URL: ")
|
||||
management_token=$(prompt_if_empty AETHER_PROXY_MANAGEMENT_TOKEN "${AETHER_PROXY_MANAGEMENT_TOKEN:-}" "Management token (ae_xxx): ")
|
||||
node_name=$(prompt_if_empty AETHER_PROXY_NODE_NAME "${AETHER_PROXY_NODE_NAME:-}" "Node name: ")
|
||||
|
||||
tag=$(resolve_latest_proxy_tag)
|
||||
[ -n "$tag" ] || fail "没有找到可用的 proxy-v* release"
|
||||
asset=$(detect_asset)
|
||||
install_binary "$tag" "$asset"
|
||||
append_server_config "$aether_url" "$management_token" "$node_name"
|
||||
|
||||
say "完成。运行以下命令启动/配置服务:"
|
||||
say " $INSTALL_DIR/aether-proxy setup $CONFIG_PATH"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -164,6 +164,20 @@ export interface ManualProxyNodeUpdateRequest {
|
||||
region?: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeInstallSessionCreateRequest {
|
||||
node_name: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeInstallSession {
|
||||
install_code: string
|
||||
expires_at_unix_secs: number
|
||||
expires_in_seconds: number
|
||||
node_name: string
|
||||
aether_url: string
|
||||
unix_command: string
|
||||
powershell_command: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeTestResult {
|
||||
success: boolean
|
||||
latency_ms: number | null
|
||||
@@ -245,6 +259,11 @@ export const proxyNodesApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createInstallSession(data: ProxyNodeInstallSessionCreateRequest): Promise<ProxyNodeInstallSession> {
|
||||
const response = await apiClient.post<ProxyNodeInstallSession>('/api/admin/proxy-nodes/install-sessions', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateManualNode(nodeId: string, data: ManualProxyNodeUpdateRequest): Promise<{ node_id: string; node: ProxyNode }> {
|
||||
const response = await apiClient.patch<{ node_id: string; node: ProxyNode }>(`/api/admin/proxy-nodes/${nodeId}`, data)
|
||||
return response.data
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
proxyNodesApi,
|
||||
type ManualProxyNodeCreateRequest,
|
||||
type ProxyNode,
|
||||
type ProxyNodeInstallSession,
|
||||
type ProxyNodeInstallSessionCreateRequest,
|
||||
type ProxyNodeUpgradeRolloutStatus,
|
||||
} from '@/api/proxy-nodes'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -68,6 +70,15 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function createInstallSession(data: ProxyNodeInstallSessionCreateRequest): Promise<ProxyNodeInstallSession> {
|
||||
try {
|
||||
return await proxyNodesApi.createInstallSession(data)
|
||||
} catch (err: unknown) {
|
||||
error.value = parseApiError(err, '生成代理节点安装命令失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(nodeId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -95,6 +106,7 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||
fetchNodes,
|
||||
ensureLoaded,
|
||||
createManualNode,
|
||||
createInstallSession,
|
||||
deleteNode,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="showAddDialog = true"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
添加
|
||||
@@ -114,7 +114,7 @@
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="手动添加"
|
||||
@click="showAddDialog = true"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -523,13 +523,117 @@
|
||||
<!-- 手动添加/编辑代理节点对话框 -->
|
||||
<Dialog
|
||||
:model-value="showAddDialog"
|
||||
:title="editingNode ? '编辑代理节点' : '手动添加代理节点'"
|
||||
:description="editingNode ? '修改手动代理节点的配置' : '手动配置的代理节点,用于无法部署 aether-proxy 的场景'"
|
||||
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
|
||||
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-proxy,也可手动添加已有 HTTP/SOCKS 代理'"
|
||||
:icon="editingNode ? SquarePen : Plus"
|
||||
size="md"
|
||||
size="lg"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<div
|
||||
v-if="!editingNode"
|
||||
class="mb-4 grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="addMode === 'script' ? 'default' : 'ghost'"
|
||||
class="h-9"
|
||||
@click="addMode = 'script'"
|
||||
>
|
||||
<Terminal class="w-3.5 h-3.5 mr-1.5" />
|
||||
脚本自动添加
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="addMode === 'manual' ? 'default' : 'ghost'"
|
||||
class="h-9"
|
||||
@click="addMode = 'manual'"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
手动添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!editingNode && addMode === 'script'"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
输入节点名称后生成一次性安装命令,有效期 15 分钟。复制的命令不含敏感授权信息,只能在目标机器使用一次,避免 Token 暴露在页面或聊天记录中。
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>节点名称 *</Label>
|
||||
<Input
|
||||
v-model="installForm.node_name"
|
||||
placeholder="例如: jp-proxy-01"
|
||||
@keyup.enter="refreshProxyInstallCommand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-semibold">目标系统</Label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:variant="installSystem === 'unix' ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="installSystem = 'unix'"
|
||||
>
|
||||
macOS / Linux
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="installSystem === 'windows' ? 'default' : 'outline'"
|
||||
class="justify-start h-auto py-3"
|
||||
@click="installSystem = 'windows'"
|
||||
>
|
||||
Windows PowerShell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-sm font-semibold">复制到代理节点机器执行</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
:disabled="installLoading || !proxyInstallCommand"
|
||||
@click="copyProxyInstallCommand"
|
||||
>
|
||||
<CheckCircle
|
||||
v-if="installCopied"
|
||||
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
{{ installCopied ? '已复制' : '一键复制' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="installLoading || !installForm.node_name.trim()"
|
||||
@click="refreshProxyInstallCommand"
|
||||
>
|
||||
{{ installLoading ? '生成中...' : (proxyInstallSession ? '重新生成' : '生成命令') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ proxyInstallCommand || '输入节点名称后点击“生成命令”' }}</pre>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ proxyInstallHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleAddManualNode"
|
||||
>
|
||||
@@ -583,7 +687,27 @@
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div
|
||||
v-if="!editingNode && addMode === 'script'"
|
||||
class="flex items-center justify-end gap-2 w-full"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleDialogClose(false)"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="installLoading || !proxyInstallCommand"
|
||||
@click="copyProxyInstallCommand"
|
||||
>
|
||||
{{ installCopied ? '已复制' : '复制命令' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-between w-full"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="testingUrl || !addForm.proxy_url"
|
||||
@@ -833,14 +957,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import {
|
||||
proxyNodesApi,
|
||||
type ProxyNode,
|
||||
type ProxyNodeEvent,
|
||||
type ProxyNodeInstallSession,
|
||||
type ProxyNodeMetricsResponse,
|
||||
type ProxyNodeRemoteConfig,
|
||||
type ProxyNodeSchedulingState,
|
||||
@@ -871,7 +997,7 @@ import {
|
||||
Dialog,
|
||||
} from '@/components/ui'
|
||||
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle } from 'lucide-vue-next'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import HardwareTooltip from './components/HardwareTooltip.vue'
|
||||
@@ -879,6 +1005,7 @@ import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
|
||||
|
||||
const { success, error: toastError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const store = useProxyNodesStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
@@ -895,6 +1022,7 @@ const pageSize = ref(20)
|
||||
const showAddDialog = ref(false)
|
||||
const addingNode = ref(false)
|
||||
const editingNode = ref<ProxyNode | null>(null)
|
||||
const addMode = ref<'script' | 'manual'>('script')
|
||||
const addForm = ref({
|
||||
name: '',
|
||||
proxy_url: '',
|
||||
@@ -902,6 +1030,28 @@ const addForm = ref({
|
||||
password: '',
|
||||
region: '',
|
||||
})
|
||||
const installForm = ref({
|
||||
node_name: '',
|
||||
})
|
||||
const installSystem = ref<'unix' | 'windows'>('unix')
|
||||
const installLoading = ref(false)
|
||||
const installCopied = ref(false)
|
||||
const proxyInstallSession = ref<ProxyNodeInstallSession | null>(null)
|
||||
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const proxyInstallCommand = computed(() => {
|
||||
if (!proxyInstallSession.value) return ''
|
||||
return installSystem.value === 'windows'
|
||||
? proxyInstallSession.value.powershell_command
|
||||
: proxyInstallSession.value.unix_command
|
||||
})
|
||||
|
||||
const proxyInstallHint = computed(() => {
|
||||
if (!proxyInstallSession.value) {
|
||||
return '脚本会自动安装或更新代理程序,并保留已有配置。'
|
||||
}
|
||||
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
|
||||
})
|
||||
|
||||
// 远程配置对话框 (aether-proxy 节点)
|
||||
const showConfigDialog = ref(false)
|
||||
@@ -967,10 +1117,23 @@ watch([searchQuery, filterStatus], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch(() => installForm.value.node_name, () => {
|
||||
resetProxyInstallState()
|
||||
})
|
||||
|
||||
watch(installSystem, () => {
|
||||
installCopied.value = false
|
||||
clearInstallCopiedResetTimer()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchNodes()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInstallCopiedResetTimer()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
await store.fetchNodes()
|
||||
}
|
||||
@@ -1015,6 +1178,56 @@ async function handleTestUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearInstallCopiedResetTimer() {
|
||||
if (installCopiedResetTimer) {
|
||||
clearTimeout(installCopiedResetTimer)
|
||||
installCopiedResetTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetProxyInstallState() {
|
||||
proxyInstallSession.value = null
|
||||
installCopied.value = false
|
||||
clearInstallCopiedResetTimer()
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
editingNode.value = null
|
||||
addMode.value = 'script'
|
||||
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
||||
installForm.value = { node_name: '' }
|
||||
resetProxyInstallState()
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
async function refreshProxyInstallCommand() {
|
||||
const nodeName = installForm.value.node_name.trim()
|
||||
if (!nodeName || installLoading.value) return
|
||||
installLoading.value = true
|
||||
resetProxyInstallState()
|
||||
try {
|
||||
proxyInstallSession.value = await store.createInstallSession({ node_name: nodeName })
|
||||
success('代理节点安装命令已生成')
|
||||
} catch (err: unknown) {
|
||||
toastError(parseApiError(err, '生成代理节点安装命令失败'))
|
||||
} finally {
|
||||
installLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyProxyInstallCommand() {
|
||||
if (!proxyInstallCommand.value) return
|
||||
const copied = await copyToClipboard(proxyInstallCommand.value, false)
|
||||
if (!copied) return
|
||||
installCopied.value = true
|
||||
success('安装命令已复制到剪贴板')
|
||||
clearInstallCopiedResetTimer()
|
||||
installCopiedResetTimer = setTimeout(() => {
|
||||
installCopied.value = false
|
||||
installCopiedResetTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function handleEdit(node: ProxyNode) {
|
||||
try {
|
||||
const { node: detail } = await proxyNodesApi.getNode(node.id)
|
||||
@@ -1026,6 +1239,8 @@ async function handleEdit(node: ProxyNode) {
|
||||
password: detail.proxy_password || '',
|
||||
region: detail.region || '',
|
||||
}
|
||||
addMode.value = 'manual'
|
||||
resetProxyInstallState()
|
||||
showAddDialog.value = true
|
||||
} catch (err: unknown) {
|
||||
toastError(parseApiError(err, '读取代理节点详情失败'))
|
||||
@@ -1036,7 +1251,10 @@ function handleDialogClose(open: boolean) {
|
||||
if (!open) {
|
||||
showAddDialog.value = false
|
||||
editingNode.value = null
|
||||
addMode.value = 'script'
|
||||
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
||||
installForm.value = { node_name: '' }
|
||||
resetProxyInstallState()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${AETHER_REPO:-fawney19/Aether}"
|
||||
SOURCE_REF="${AETHER_SOURCE_REF:-aether-rust-pioneer}"
|
||||
SOURCE_REF="${AETHER_SOURCE_REF:-main}"
|
||||
VERSION="${AETHER_VERSION:-}"
|
||||
CHANNEL="${AETHER_CHANNEL:-stable}"
|
||||
CHANNEL_EXPLICIT="false"
|
||||
@@ -60,7 +60,7 @@ Options:
|
||||
beta resolves the latest tag like v0.7.0-beta.1
|
||||
--version VERSION Exact release tag to install, for example v0.7.0-rc.1
|
||||
--repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether)
|
||||
--source-ref REF Source branch/tag used for compose templates (default: aether-rust-pioneer)
|
||||
--source-ref REF Source branch/tag used for compose templates (default: main)
|
||||
--archive PATH Install from a local release tarball instead of downloading
|
||||
--download-url URL Download the release archive from this URL instead of GitHub
|
||||
--env-file PATH Use an existing aether-gateway.env file
|
||||
|
||||
Reference in New Issue
Block a user