mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor(proxy): 简化 HMAC 认证、移除明文 HTTP 代理并优化 setup 流程
- HMAC 签名移除 node_id,仅使用 timestamp,消除重注册时的认证竞态问题 - 删除 plain HTTP forward proxy(plain.rs),仅保留 CONNECT 隧道和 delegate,非支持方法返回 405 - 提取共享 BoxBody 类型和 empty_box_body() 到 proxy/mod.rs - CLI 解析重构为 clap 原生 subcommand,启用 subcommand_negates_reqs - setup 向导返回 SetupOutcome 枚举,支持保存后自动启动 proxy - setup TUI 增加未保存变更的退出确认(pending_quit) - validate_target 改为 async,使用 tokio::net::lookup_host 避免阻塞 DNS - 显式初始化 rustls ring CryptoProvider - ConfigFile 新增 inject_env_override() 用于 setup 后重载配置 - Python 端 HMAC 签名同步移除 node_id,缓存时间桶从 120s 调整为 240s
This commit is contained in:
@@ -26,7 +26,7 @@ hex = "0.4"
|
||||
anyhow = "1"
|
||||
toml = "0.8"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-pki-types = "1"
|
||||
rustls-pemfile = "2"
|
||||
rcgen = "0.13"
|
||||
|
||||
@@ -37,7 +37,11 @@ impl std::fmt::Display for AuthError {
|
||||
/// Validate Proxy-Authorization header.
|
||||
///
|
||||
/// Expected format: `Basic base64(hmac:{timestamp}.{signature})`
|
||||
/// where signature = hex(HMAC-SHA256(hmac_key, "{timestamp}\n{node_id}"))
|
||||
/// where signature = hex(HMAC-SHA256(hmac_key, "{timestamp}"))
|
||||
///
|
||||
/// The signature no longer includes `node_id`, eliminating race conditions
|
||||
/// during re-registration where the Aether server's cached `node_id` could
|
||||
/// differ from the proxy's freshly assigned `node_id`.
|
||||
///
|
||||
/// `timestamp_tolerance` is accepted separately so the caller can supply
|
||||
/// the value from [`DynamicConfig`](crate::runtime::DynamicConfig) (which
|
||||
@@ -45,7 +49,6 @@ impl std::fmt::Display for AuthError {
|
||||
pub fn validate_proxy_auth(
|
||||
proxy_auth_header: Option<&str>,
|
||||
config: &Config,
|
||||
node_id: &str,
|
||||
timestamp_tolerance: u64,
|
||||
) -> Result<(), AuthError> {
|
||||
let header = proxy_auth_header.ok_or(AuthError::MissingHeader)?;
|
||||
@@ -88,11 +91,10 @@ pub fn validate_proxy_auth(
|
||||
return Err(AuthError::TimestampExpired);
|
||||
}
|
||||
|
||||
// Recompute signature
|
||||
let payload = format!("{}\n{}", timestamp_str, node_id);
|
||||
// Recompute signature: HMAC-SHA256(key, timestamp)
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(config.hmac_key.as_bytes()).expect("HMAC accepts any key size");
|
||||
mac.update(payload.as_bytes());
|
||||
mac.update(timestamp_str.as_bytes());
|
||||
let expected = mac.finalize().into_bytes();
|
||||
let expected_hex = hex::encode(expected);
|
||||
|
||||
@@ -131,14 +133,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_valid_auth(config: &Config, node_id: &str) -> String {
|
||||
fn make_valid_auth(config: &Config) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let payload = format!("{}\n{}", now, node_id);
|
||||
let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
|
||||
mac.update(payload.as_bytes());
|
||||
mac.update(now.to_string().as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let cred = format!("hmac:{}.{}", now, sig);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(cred);
|
||||
@@ -148,28 +149,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_valid_auth() {
|
||||
let config = make_config();
|
||||
let header = make_valid_auth(&config, "node-1");
|
||||
assert!(
|
||||
validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_node_id() {
|
||||
let config = make_config();
|
||||
let header = make_valid_auth(&config, "node-1");
|
||||
assert!(matches!(
|
||||
validate_proxy_auth(Some(&header), &config, "node-2", config.timestamp_tolerance),
|
||||
Err(AuthError::SignatureMismatch)
|
||||
));
|
||||
let header = make_valid_auth(&config);
|
||||
assert!(validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_header() {
|
||||
let config = make_config();
|
||||
assert!(matches!(
|
||||
validate_proxy_auth(None, &config, "node-1", config.timestamp_tolerance),
|
||||
validate_proxy_auth(None, &config, config.timestamp_tolerance),
|
||||
Err(AuthError::MissingHeader)
|
||||
));
|
||||
}
|
||||
@@ -181,7 +169,7 @@ mod tests {
|
||||
let header = format!("Basic {}", encoded);
|
||||
let config = make_config();
|
||||
assert!(matches!(
|
||||
validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance),
|
||||
validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance),
|
||||
Err(AuthError::InvalidUsername)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -139,10 +139,21 @@ impl ConfigFile {
|
||||
/// Only sets variables that are **not** already present in the
|
||||
/// environment, preserving the precedence: CLI > env > config file.
|
||||
pub fn inject_env(&self) {
|
||||
self.inject_env_inner(false);
|
||||
}
|
||||
|
||||
/// Inject values as environment variables, **overriding** any existing
|
||||
/// values. Used after setup to ensure the freshly-saved config takes
|
||||
/// effect before re-parsing.
|
||||
pub fn inject_env_override(&self) {
|
||||
self.inject_env_inner(true);
|
||||
}
|
||||
|
||||
fn inject_env_inner(&self, force: bool) {
|
||||
macro_rules! set {
|
||||
($env:expr, $val:expr) => {
|
||||
if let Some(ref v) = $val {
|
||||
if std::env::var($env).is_err() {
|
||||
if force || std::env::var($env).is_err() {
|
||||
std::env::set_var($env, v.to_string());
|
||||
}
|
||||
}
|
||||
@@ -165,7 +176,7 @@ impl ConfigFile {
|
||||
|
||||
// allowed_ports needs special handling (comma-separated)
|
||||
if let Some(ref ports) = self.allowed_ports {
|
||||
if std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
|
||||
if force || std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
|
||||
let s: String = ports
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
|
||||
@@ -11,42 +11,49 @@ mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
|
||||
use config::Config;
|
||||
|
||||
/// Default config file name.
|
||||
const DEFAULT_CONFIG: &str = "aether-proxy.toml";
|
||||
|
||||
/// Build the full clap command: Config args + discoverable subcommands.
|
||||
///
|
||||
/// `subcommand_negates_reqs` lets subcommands bypass the required Config
|
||||
/// flags so that e.g. `aether-proxy setup` doesn't demand `--aether-url`.
|
||||
fn build_command() -> clap::Command {
|
||||
Config::command()
|
||||
.subcommand(
|
||||
clap::Command::new("setup")
|
||||
.about("Interactive setup wizard (TUI)")
|
||||
.arg(
|
||||
clap::Arg::new("config_path")
|
||||
.help("Path to config file")
|
||||
.default_value(DEFAULT_CONFIG),
|
||||
),
|
||||
)
|
||||
.subcommand(clap::Command::new("start").about("Start the systemd service"))
|
||||
.subcommand(clap::Command::new("status").about("Show service status"))
|
||||
.subcommand(clap::Command::new("logs").about("Tail service logs"))
|
||||
.subcommand(clap::Command::new("restart").about("Restart the systemd service"))
|
||||
.subcommand(clap::Command::new("stop").about("Stop the systemd service"))
|
||||
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service"))
|
||||
.subcommand(
|
||||
clap::Command::new("upgrade")
|
||||
.about("Self-upgrade from GitHub releases")
|
||||
.arg(clap::Arg::new("version").help("Target version (e.g. 0.2.0)")),
|
||||
)
|
||||
.subcommand_negates_reqs(true)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to install rustls CryptoProvider"))?;
|
||||
|
||||
// Handle subcommands before clap parsing (these don't need Config)
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"setup" => {
|
||||
let path = args
|
||||
.get(2)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
|
||||
return setup::run(path);
|
||||
}
|
||||
"start" => return setup::service::cmd_start(),
|
||||
"status" => return setup::service::cmd_status(),
|
||||
"logs" => return setup::service::cmd_logs(),
|
||||
"restart" => return setup::service::cmd_restart(),
|
||||
"stop" => return setup::service::cmd_stop(),
|
||||
"uninstall" => return setup::service::cmd_uninstall(),
|
||||
"upgrade" => {
|
||||
let version = args.get(2).cloned();
|
||||
return setup::upgrade::cmd_upgrade(version).await;
|
||||
}
|
||||
_ => {} // fall through to clap (--help, --version, config args)
|
||||
}
|
||||
}
|
||||
|
||||
// Load config file as env-var defaults (before clap)
|
||||
// Load config file as env-var defaults (before clap parsing)
|
||||
let config_file_path =
|
||||
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
if std::path::Path::new(&config_file_path).exists() {
|
||||
@@ -55,18 +62,70 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse config; fall back to setup TUI if required args are missing
|
||||
let config = match Config::try_parse() {
|
||||
Ok(c) => c,
|
||||
// Parse CLI (subcommands + config args in one pass)
|
||||
match build_command().try_get_matches() {
|
||||
Ok(matches) => match matches.subcommand() {
|
||||
Some(("setup", sub_m)) => {
|
||||
let path = sub_m
|
||||
.get_one::<String>("config_path")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
|
||||
handle_setup_result(setup::run(path)?).await
|
||||
}
|
||||
Some(("start", _)) => setup::service::cmd_start(),
|
||||
Some(("status", _)) => setup::service::cmd_status(),
|
||||
Some(("logs", _)) => setup::service::cmd_logs(),
|
||||
Some(("restart", _)) => setup::service::cmd_restart(),
|
||||
Some(("stop", _)) => setup::service::cmd_stop(),
|
||||
Some(("uninstall", _)) => setup::service::cmd_uninstall(),
|
||||
Some(("upgrade", sub_m)) => {
|
||||
let version = sub_m.get_one::<String>("version").cloned();
|
||||
setup::upgrade::cmd_upgrade(version).await
|
||||
}
|
||||
Some(_) => unreachable!(),
|
||||
None => {
|
||||
// No subcommand — run the proxy with parsed config.
|
||||
let config = Config::from_arg_matches(&matches)?;
|
||||
run_proxy(config).await
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
|
||||
eprintln!("Missing required config, launching setup wizard...\n");
|
||||
return setup::run(PathBuf::from(&config_file_path));
|
||||
handle_setup_result(setup::run(PathBuf::from(&config_file_path))?).await
|
||||
} else {
|
||||
e.exit();
|
||||
}
|
||||
e.exit();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what to do after the setup wizard completes.
|
||||
async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()> {
|
||||
match outcome {
|
||||
setup::SetupOutcome::ServiceInstalled => Ok(()),
|
||||
setup::SetupOutcome::ReadyToRun(config_path) => {
|
||||
// Reload config from the file that setup just wrote, overriding
|
||||
// any stale env vars from a previous config.
|
||||
match config::ConfigFile::load(&config_path) {
|
||||
Ok(file_cfg) => file_cfg.inject_env_override(),
|
||||
Err(e) => anyhow::bail!("failed to reload config after setup: {}", e),
|
||||
}
|
||||
// Parse from env-only (argv may still contain "setup" etc.)
|
||||
let config = Config::try_parse_from(["aether-proxy"])
|
||||
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
|
||||
eprintln!(" Starting proxy...\n");
|
||||
run_proxy(config).await
|
||||
}
|
||||
setup::SetupOutcome::Cancelled => {
|
||||
eprintln!(" Setup cancelled.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the proxy server, checking for systemd conflicts first.
|
||||
async fn run_proxy(config: Config) -> anyhow::Result<()> {
|
||||
// Warn if systemd service is already running (would cause port conflict).
|
||||
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
|
||||
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {
|
||||
|
||||
@@ -16,7 +16,6 @@ use crate::proxy::target_filter;
|
||||
pub async fn handle_connect(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
node_id: &str,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
timestamp_tolerance: u64,
|
||||
) -> Response<http_body_util::Empty<bytes::Bytes>> {
|
||||
@@ -27,7 +26,7 @@ pub async fn handle_connect(
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// HMAC authentication
|
||||
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, node_id, timestamp_tolerance) {
|
||||
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, timestamp_tolerance) {
|
||||
warn!(error = %e, "CONNECT auth failed");
|
||||
return proxy_auth_required(&e.to_string());
|
||||
}
|
||||
@@ -45,7 +44,7 @@ pub async fn handle_connect(
|
||||
let port = authority.port_u16().unwrap_or(443);
|
||||
|
||||
// Target filter: private IP + port whitelist
|
||||
let target_addr = match target_filter::validate_target(&host, port, allowed_ports) {
|
||||
let target_addr = match target_filter::validate_target(&host, port, allowed_ports).await {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!(host = %host, port, error = %e, "CONNECT target rejected");
|
||||
|
||||
@@ -10,9 +10,9 @@ use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
use url::Url;
|
||||
|
||||
use super::BoxBody;
|
||||
use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::proxy::plain::BoxBody;
|
||||
use crate::proxy::target_filter;
|
||||
|
||||
/// Delegation request payload sent by Aether.
|
||||
@@ -34,7 +34,6 @@ struct DelegateRequest {
|
||||
pub async fn handle_delegate(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
node_id: &str,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
timestamp_tolerance: u64,
|
||||
http_client: &reqwest::Client,
|
||||
@@ -45,7 +44,7 @@ pub async fn handle_delegate(
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
if let Err(e) = auth::validate_proxy_auth(auth_header, &config, node_id, timestamp_tolerance) {
|
||||
if let Err(e) = auth::validate_proxy_auth(auth_header, &config, timestamp_tolerance) {
|
||||
warn!(error = %e, "delegate auth failed");
|
||||
return error_response(401, "authentication_failed", &e.to_string());
|
||||
}
|
||||
@@ -87,7 +86,7 @@ pub async fn handle_delegate(
|
||||
|
||||
let port = parsed_url.port_or_known_default().unwrap_or(443);
|
||||
|
||||
if let Err(e) = target_filter::validate_target(&host, port, allowed_ports) {
|
||||
if let Err(e) = target_filter::validate_target(&host, port, allowed_ports).await {
|
||||
warn!(host = %host, port, error = %e, "delegate target rejected");
|
||||
return error_response(403, "target_not_allowed", &e.to_string());
|
||||
}
|
||||
@@ -165,7 +164,7 @@ pub async fn handle_delegate(
|
||||
|
||||
builder
|
||||
.body(stream_body)
|
||||
.unwrap_or_else(|_| Response::builder().status(500).body(empty_box()).unwrap())
|
||||
.unwrap_or_else(|_| Response::builder().status(500).body(super::empty_box_body()).unwrap())
|
||||
}
|
||||
|
||||
// ── Sanitisation ─────────────────────────────────────────────────────────────
|
||||
@@ -195,12 +194,6 @@ fn sanitize_upstream_error(msg: &str) -> String {
|
||||
|
||||
// ── Error response helpers ───────────────────────────────────────────────────
|
||||
|
||||
fn empty_box() -> BoxBody {
|
||||
Full::new(bytes::Bytes::new())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn error_response(status: u16, error: &str, detail: &str) -> Response<BoxBody> {
|
||||
let body = serde_json::json!({
|
||||
"error": error,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
pub mod connect;
|
||||
pub mod delegate;
|
||||
pub mod plain;
|
||||
pub mod server;
|
||||
pub mod target_filter;
|
||||
pub mod tls;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
/// Boxed body type used across proxy handlers.
|
||||
pub type BoxBody =
|
||||
http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Create an empty [`BoxBody`] (for error responses, 405, etc.).
|
||||
pub fn empty_box_body() -> BoxBody {
|
||||
http_body_util::Full::new(bytes::Bytes::new())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed()
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Request, Response};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::proxy::target_filter;
|
||||
|
||||
/// Boxed body that unifies `Full` (error responses) and `Incoming` (streamed upstream).
|
||||
pub type BoxBody =
|
||||
http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Handle plain HTTP forward proxy requests (non-CONNECT).
|
||||
///
|
||||
/// Flow: validate auth -> check target filter -> forward request -> **stream** response
|
||||
pub async fn handle_plain(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
node_id: &str,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
timestamp_tolerance: u64,
|
||||
) -> Response<BoxBody> {
|
||||
// Extract Proxy-Authorization header
|
||||
let proxy_auth = req
|
||||
.headers()
|
||||
.get("proxy-authorization")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// HMAC authentication
|
||||
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, node_id, timestamp_tolerance) {
|
||||
warn!(error = %e, "HTTP proxy auth failed");
|
||||
return proxy_auth_required(&e.to_string());
|
||||
}
|
||||
|
||||
// Parse target from absolute URI
|
||||
let uri = req.uri().clone();
|
||||
let host = match uri.host() {
|
||||
Some(h) => h.to_string(),
|
||||
None => {
|
||||
warn!(uri = %uri, "HTTP proxy request missing host");
|
||||
return bad_request("missing host in URI");
|
||||
}
|
||||
};
|
||||
let port = uri.port_u16().unwrap_or(80);
|
||||
|
||||
// Target filter
|
||||
let target_addr = match target_filter::validate_target(&host, port, allowed_ports) {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!(host = %host, port, error = %e, "HTTP proxy target rejected");
|
||||
return forbidden(&e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let method = req.method().clone();
|
||||
debug!(target = %target_addr, method = %method, "HTTP proxy forwarding");
|
||||
|
||||
// Build outgoing request (strip proxy headers, use relative URI)
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
|
||||
let mut builder = Request::builder()
|
||||
.method(req.method())
|
||||
.uri(path_and_query)
|
||||
.version(req.version());
|
||||
|
||||
// Copy headers, skipping proxy-specific and forwarding-related ones
|
||||
for (name, value) in req.headers() {
|
||||
if name == "proxy-authorization"
|
||||
|| name == "proxy-connection"
|
||||
|| name == "x-forwarded-for"
|
||||
|| name == "x-forwarded-host"
|
||||
|| name == "x-forwarded-proto"
|
||||
|| name == "x-real-ip"
|
||||
|| name == "forwarded"
|
||||
|| name == "via"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
// Collect the incoming request body (client payloads are small)
|
||||
let body_bytes = match req.into_body().collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to read request body");
|
||||
return bad_gateway("failed to read request body");
|
||||
}
|
||||
};
|
||||
|
||||
// Connect and send via raw TCP + hyper client
|
||||
let stream = match tokio::net::TcpStream::connect(target_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(target = %target_addr, error = %e, "HTTP proxy connection failed");
|
||||
return bad_gateway(&format!("connection failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "HTTP handshake failed");
|
||||
return bad_gateway(&format!("handshake failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
debug!(error = %e, "HTTP proxy client connection error");
|
||||
}
|
||||
});
|
||||
|
||||
let outgoing = builder
|
||||
.body(Full::new(body_bytes))
|
||||
.expect("failed to build outgoing request");
|
||||
|
||||
match sender.send_request(outgoing).await {
|
||||
Ok(resp) => {
|
||||
debug!(target = %target_addr, method = %method, status = resp.status().as_u16(), "HTTP proxy response");
|
||||
// Stream the response body directly — no buffering
|
||||
let (parts, body) = resp.into_parts();
|
||||
let body: BoxBody = body
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })
|
||||
.boxed();
|
||||
Response::from_parts(parts, body)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "HTTP proxy request failed");
|
||||
bad_gateway(&format!("upstream request failed: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error response helpers ───────────────────────────────────────────────────
|
||||
|
||||
fn empty_box() -> BoxBody {
|
||||
Full::new(bytes::Bytes::new())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn proxy_auth_required(msg: &str) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(407)
|
||||
.header("Proxy-Authenticate", "HMAC-SHA256")
|
||||
.header("X-Error", msg)
|
||||
.body(empty_box())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn forbidden(msg: &str) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(403)
|
||||
.header("X-Error", msg)
|
||||
.body(empty_box())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(400)
|
||||
.header("X-Error", msg)
|
||||
.body(empty_box())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bad_gateway(msg: &str) -> Response<BoxBody> {
|
||||
Response::builder()
|
||||
.status(502)
|
||||
.header("X-Error", msg)
|
||||
.body(empty_box())
|
||||
.unwrap()
|
||||
}
|
||||
@@ -7,20 +7,21 @@ use hyper::body::Incoming;
|
||||
use hyper::rt::{Read, Write};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request};
|
||||
use hyper::{Method, Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::proxy::{connect, delegate, plain, tls};
|
||||
use crate::proxy::{connect, delegate, tls, BoxBody};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Start the proxy server.
|
||||
///
|
||||
/// Listens for incoming TCP connections and dispatches:
|
||||
/// - CONNECT requests -> tunnel handler
|
||||
/// - Other HTTP requests -> plain forward proxy handler
|
||||
/// - POST /_aether/delegate -> delegate handler
|
||||
/// - Other requests -> 405 Method Not Allowed
|
||||
///
|
||||
/// When TLS is configured, the server operates in dual-stack mode:
|
||||
/// it peeks at the first byte of each connection to distinguish TLS ClientHello
|
||||
@@ -104,38 +105,24 @@ where
|
||||
I: Read + Write + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::clone(&state.config);
|
||||
let node_id = Arc::clone(&state.node_id);
|
||||
let dynamic = Arc::clone(&state.dynamic);
|
||||
let delegate_client = state.delegate_client.clone();
|
||||
|
||||
let service = service_fn(move |req: Request<Incoming>| {
|
||||
let config = Arc::clone(&config);
|
||||
let node_id = Arc::clone(&node_id);
|
||||
let dynamic = Arc::clone(&dynamic);
|
||||
let delegate_client = delegate_client.clone();
|
||||
|
||||
async move {
|
||||
type BoxBody = http_body_util::combinators::BoxBody<
|
||||
bytes::Bytes,
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
>;
|
||||
|
||||
// Snapshot current dynamic values (may be updated by remote config)
|
||||
let current_node_id = node_id.read().unwrap().clone();
|
||||
let (allowed_ports, timestamp_tolerance) = {
|
||||
let d = dynamic.read().unwrap();
|
||||
(d.allowed_ports.clone(), d.timestamp_tolerance)
|
||||
};
|
||||
|
||||
if req.method() == Method::CONNECT {
|
||||
let resp = connect::handle_connect(
|
||||
req,
|
||||
config,
|
||||
¤t_node_id,
|
||||
&allowed_ports,
|
||||
timestamp_tolerance,
|
||||
)
|
||||
.await;
|
||||
let resp =
|
||||
connect::handle_connect(req, config, &allowed_ports, timestamp_tolerance).await;
|
||||
let resp = resp.map(|_| -> BoxBody {
|
||||
http_body_util::Empty::new()
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
|
||||
@@ -147,7 +134,6 @@ where
|
||||
let resp = delegate::handle_delegate(
|
||||
req,
|
||||
config,
|
||||
¤t_node_id,
|
||||
&allowed_ports,
|
||||
timestamp_tolerance,
|
||||
&delegate_client,
|
||||
@@ -155,15 +141,14 @@ where
|
||||
.await;
|
||||
Ok(resp)
|
||||
} else {
|
||||
let resp = plain::handle_plain(
|
||||
req,
|
||||
config,
|
||||
¤t_node_id,
|
||||
&allowed_ports,
|
||||
timestamp_tolerance,
|
||||
)
|
||||
.await;
|
||||
Ok(resp)
|
||||
// Only CONNECT tunnels and /_aether/delegate are supported;
|
||||
// plain HTTP forward proxy was removed (all API traffic is HTTPS).
|
||||
Ok(Response::builder()
|
||||
.status(405)
|
||||
.header("Allow", "CONNECT")
|
||||
.header("Content-Length", "0")
|
||||
.body(crate::proxy::empty_box_body())
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
/// Check if an IP address belongs to a private/reserved network.
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
@@ -82,8 +82,11 @@ impl std::fmt::Display for FilterError {
|
||||
|
||||
/// Validate that the target host:port is allowed.
|
||||
///
|
||||
/// Uses async DNS resolution (via `tokio::net::lookup_host`) to avoid
|
||||
/// blocking the async runtime on potentially slow DNS lookups.
|
||||
///
|
||||
/// Returns the resolved socket address to connect to.
|
||||
pub fn validate_target(
|
||||
pub async fn validate_target(
|
||||
host: &str,
|
||||
port: u16,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
@@ -93,7 +96,7 @@ pub fn validate_target(
|
||||
return Err(FilterError::PortNotAllowed(port));
|
||||
}
|
||||
|
||||
// Try parsing as IP directly
|
||||
// Try parsing as IP directly (no DNS needed)
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_private_ip(&ip) {
|
||||
return Err(FilterError::PrivateIp(ip));
|
||||
@@ -101,10 +104,10 @@ pub fn validate_target(
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
|
||||
// DNS resolution with private IP check (DNS rebinding protection)
|
||||
// Async DNS resolution with private IP check (DNS rebinding protection)
|
||||
let addr_str = format!("{}:{}", host, port);
|
||||
let addrs: Vec<SocketAddr> = addr_str
|
||||
.to_socket_addrs()
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
|
||||
.await
|
||||
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
|
||||
.collect();
|
||||
|
||||
@@ -157,21 +160,21 @@ mod tests {
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_not_allowed() {
|
||||
let result = validate_target("8.8.8.8", 22, &ports());
|
||||
#[tokio::test]
|
||||
async fn test_port_not_allowed() {
|
||||
let result = validate_target("8.8.8.8", 22, &ports()).await;
|
||||
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_private_ip_blocked() {
|
||||
let result = validate_target("127.0.0.1", 80, &ports());
|
||||
#[tokio::test]
|
||||
async fn test_private_ip_blocked() {
|
||||
let result = validate_target("127.0.0.1", 80, &ports()).await;
|
||||
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_public_ip_allowed() {
|
||||
let result = validate_target("8.8.8.8", 443, &ports());
|
||||
#[tokio::test]
|
||||
async fn test_public_ip_allowed() {
|
||||
let result = validate_target("8.8.8.8", 443, &ports()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ pub type SharedDynamicConfig = Arc<RwLock<DynamicConfig>>;
|
||||
// ── Log-level hot-reload ─────────────────────────────────────────────────────
|
||||
|
||||
/// Global log-level reloader function, set during tracing init.
|
||||
static LOG_RELOADER: OnceLock<Box<dyn Fn(&str) + Send + Sync>> = OnceLock::new();
|
||||
type LogReloader = Box<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
static LOG_RELOADER: OnceLock<LogReloader> = OnceLock::new();
|
||||
|
||||
/// Register the log-level reload function (called once from `init_tracing`).
|
||||
pub fn set_log_reloader(f: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
pub fn set_log_reloader(f: LogReloader) {
|
||||
let _ = LOG_RELOADER.set(f);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ pub(crate) mod service;
|
||||
mod tui;
|
||||
pub(crate) mod upgrade;
|
||||
|
||||
pub use self::tui::run;
|
||||
pub use self::tui::{run, SetupOutcome};
|
||||
|
||||
@@ -93,9 +93,11 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Commands:");
|
||||
eprintln!(" sudo systemctl status {} # status", SERVICE_NAME);
|
||||
eprintln!(" sudo systemctl restart {} # restart", SERVICE_NAME);
|
||||
eprintln!(" sudo journalctl -u {} -f # logs", SERVICE_NAME);
|
||||
eprintln!(" aether-proxy status # service status");
|
||||
eprintln!(" aether-proxy logs # tail logs");
|
||||
eprintln!(" sudo aether-proxy restart # restart");
|
||||
eprintln!(" sudo aether-proxy stop # stop");
|
||||
eprintln!(" sudo aether-proxy uninstall # remove service");
|
||||
eprintln!();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -21,6 +21,16 @@ use ratatui::Terminal;
|
||||
|
||||
use crate::config::ConfigFile;
|
||||
|
||||
/// Outcome of the setup wizard, returned to the caller.
|
||||
pub enum SetupOutcome {
|
||||
/// Config saved; systemd service installed and started.
|
||||
ServiceInstalled,
|
||||
/// Config saved; no service — caller should start the proxy directly.
|
||||
ReadyToRun(PathBuf),
|
||||
/// User quit without saving.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Column width reserved for the field label (chars).
|
||||
const LABEL_WIDTH: usize = 22;
|
||||
|
||||
@@ -63,6 +73,7 @@ struct App {
|
||||
message: Option<(String, Instant, bool)>, // (text, when, is_error)
|
||||
scroll_offset: usize,
|
||||
saved_once: bool,
|
||||
pending_quit: bool, // true after first q/Esc with unsaved changes
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -148,6 +159,7 @@ impl App {
|
||||
message: None,
|
||||
scroll_offset: 0,
|
||||
saved_once: false,
|
||||
pending_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,9 +247,9 @@ impl App {
|
||||
|
||||
/// Returns `true` when the app should exit.
|
||||
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
// Expire old messages
|
||||
// Expire old messages (but keep quit-confirmation messages alive)
|
||||
if let Some((_, when, _)) = &self.message {
|
||||
if when.elapsed() > Duration::from_secs(4) {
|
||||
if !self.pending_quit && when.elapsed() > Duration::from_secs(4) {
|
||||
self.message = None;
|
||||
}
|
||||
}
|
||||
@@ -252,8 +264,29 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_normal(&mut self, key: KeyEvent) -> bool {
|
||||
// ── Quit handling (with unsaved-changes confirmation) ─────────
|
||||
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
|
||||
|
||||
if is_quit_key {
|
||||
if !self.modified || self.pending_quit {
|
||||
return true;
|
||||
}
|
||||
self.pending_quit = true;
|
||||
self.message = Some((
|
||||
"unsaved changes! q again to discard, ^S to save".into(),
|
||||
Instant::now(),
|
||||
true,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other key cancels the pending quit
|
||||
if self.pending_quit {
|
||||
self.pending_quit = false;
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Err(e) = self.save() {
|
||||
self.message = Some((format!("error: {}", e), Instant::now(), true));
|
||||
@@ -564,7 +597,7 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
|
||||
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
|
||||
// Setup terminal
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
@@ -584,46 +617,42 @@ pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
|
||||
|
||||
result?;
|
||||
|
||||
// Post-TUI message
|
||||
if app.saved_once {
|
||||
eprintln!();
|
||||
eprintln!(" Config saved to {}", config_path.display());
|
||||
eprintln!();
|
||||
// ── Post-TUI: decide outcome ─────────────────────────────────────
|
||||
|
||||
let wants_service = app
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "install_service")
|
||||
.map(|f| f.value == "true")
|
||||
.unwrap_or(false);
|
||||
if !app.saved_once {
|
||||
return Ok(SetupOutcome::Cancelled);
|
||||
}
|
||||
|
||||
if wants_service {
|
||||
match super::service::install_service(&config_path) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
eprintln!(" Service install failed: {}", e);
|
||||
eprintln!();
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" Config saved to {}", config_path.display());
|
||||
eprintln!();
|
||||
|
||||
let wants_service = app
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "install_service")
|
||||
.map(|f| f.value == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if wants_service {
|
||||
match super::service::install_service(&config_path) {
|
||||
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
|
||||
Err(e) => {
|
||||
eprintln!(" Service install failed: {}", e);
|
||||
eprintln!(" Starting proxy directly instead.\n");
|
||||
}
|
||||
} else {
|
||||
// Uninstall service if it was previously installed
|
||||
if super::service::is_installed() {
|
||||
if let Err(e) = super::service::uninstall_service() {
|
||||
eprintln!(" Service uninstall failed: {}", e);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Uninstall service if it was previously installed but toggled off
|
||||
if super::service::is_installed() {
|
||||
if let Err(e) = super::service::uninstall_service() {
|
||||
eprintln!(" Service uninstall failed: {}", e);
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
eprintln!(" Run with:");
|
||||
eprintln!(
|
||||
" aether-proxy (auto-reads {})",
|
||||
config_path.display()
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(SetupOutcome::ReadyToRun(config_path))
|
||||
}
|
||||
|
||||
fn event_loop(
|
||||
|
||||
@@ -94,23 +94,24 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
|
||||
def build_hmac_proxy_url(ip: str, port: int, *, tls_enabled: bool = False) -> str:
|
||||
"""
|
||||
构建带 HMAC BasicAuth 的 httpx proxy URL
|
||||
|
||||
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
|
||||
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
|
||||
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}") 的 hex
|
||||
|
||||
签名不再包含 node_id,避免 proxy 重新注册后 Aether 端缓存的旧 node_id
|
||||
与 proxy 端新 node_id 不一致导致的认证失败窗口。
|
||||
|
||||
当 tls_enabled=True 时使用 https:// scheme。
|
||||
"""
|
||||
if not config.proxy_hmac_key:
|
||||
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id)
|
||||
raise ProxyNodeUnavailableError(
|
||||
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
|
||||
)
|
||||
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
|
||||
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
payload = f"{timestamp}\n{node_id}".encode("utf-8")
|
||||
payload = timestamp.encode("utf-8")
|
||||
signature = _hmac.new(
|
||||
config.proxy_hmac_key.encode("utf-8"),
|
||||
payload,
|
||||
@@ -318,7 +319,6 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
||||
return build_hmac_proxy_url(
|
||||
node_info["ip"],
|
||||
node_info["port"],
|
||||
node_id,
|
||||
tls_enabled=node_info.get("tls_enabled", False),
|
||||
)
|
||||
|
||||
@@ -421,7 +421,7 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
|
||||
node_id = proxy_config.get("node_id")
|
||||
if isinstance(node_id, str) and node_id.strip():
|
||||
time_bucket = int(time.time() / 120) # 120 秒一个桶
|
||||
time_bucket = int(time.time() / 240) # 240s bucket, within 300s HMAC tolerance
|
||||
return f"proxy_node:{node_id.strip()}:{time_bucket}"
|
||||
|
||||
# 构建代理 URL 作为缓存键的基础
|
||||
@@ -438,18 +438,18 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_hmac_auth_header(node_id: str) -> str:
|
||||
def _build_hmac_auth_header() -> str:
|
||||
"""
|
||||
构建代发请求的 Authorization 头
|
||||
|
||||
格式: Basic base64(hmac:{timestamp}.{signature})
|
||||
签名算法与 build_hmac_proxy_url 相同。
|
||||
签名算法与 build_hmac_proxy_url 相同(仅使用 timestamp,不含 node_id)。
|
||||
"""
|
||||
if not config.proxy_hmac_key:
|
||||
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式", node_id=node_id)
|
||||
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式")
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
payload = f"{timestamp}\n{node_id}".encode("utf-8")
|
||||
payload = timestamp.encode("utf-8")
|
||||
signature = _hmac.new(
|
||||
config.proxy_hmac_key.encode("utf-8"),
|
||||
payload,
|
||||
@@ -497,9 +497,9 @@ def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, An
|
||||
scheme = "https" if tls_enabled else "http"
|
||||
delegate_url = f"{scheme}://{host}:{int(node_info['port'])}/_aether/delegate"
|
||||
|
||||
# 闭包捕获 node_id,每次调用生成新鲜签名
|
||||
# 每次调用生成新鲜签名(避免长连接内时间戳过期)
|
||||
def _fresh() -> str:
|
||||
return _build_hmac_auth_header(node_id)
|
||||
return _build_hmac_auth_header()
|
||||
|
||||
return {
|
||||
"delegate_url": delegate_url,
|
||||
|
||||
Reference in New Issue
Block a user